diff --git a/.env.example b/.env.example index 4d65eea..6a18345 100644 --- a/.env.example +++ b/.env.example @@ -5,24 +5,19 @@ # Domain Traefik routes on and acquires TLS certificates for. DOMAIN=localhost -# Used by the backend to build links in outgoing emails. +# The address people and devices reach this instance at: panel links, mails +# and the MCP issuer are built from it. FRONTEND_HOST=http://localhost:5173 # local, staging, production ENVIRONMENT=local -# Baked into the SPA at build time: the address the interface calls. The -# workspace root's scripts/setup.sh derives it from DOMAIN and ENVIRONMENT — -# plain http for a local target, where nothing terminates TLS. -VITE_API_URL=http://api.localhost - PROJECT_NAME="Fluksio" STACK_NAME=fluksio-app -# Backend -# `http://frontend` is the compose-network origin the Playwright job loads the -# SPA from (docker/compose.ci.yml); without it every spec fails CORS at login. -BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,http://app.localhost,https://localhost,https://localhost:5173,http://frontend" +# Backend. The image serves the dashboard on the API's own origin, so these +# only matter to a dashboard served from elsewhere, such as the Vite server. +BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,http://app.localhost,https://localhost,https://localhost:5173" SECRET_KEY=changethis FIRST_SUPERUSER=admin@fluksio.com FIRST_SUPERUSER_PASSWORD=changethis @@ -49,16 +44,19 @@ POSTGRES_DB=app POSTGRES_USER=postgres POSTGRES_PASSWORD=changethis -# Redis — flow engine state. Empty keeps the state in memory; the compose stack -# points the backend at its own `redis` service. +# Redis — flow engine state. Empty keeps the state in memory in a checkout, and +# the image starts a Redis of its own; the compose stack points the backend at +# its own `redis` service. REDIS_HOST= REDIS_PORT=6379 SENTRY_DSN= -# Docker registry images +# Docker registry image DOCKER_IMAGE_BACKEND=fluksio-backend -DOCKER_IMAGE_FRONTEND=fluksio-frontend +# Optional features built into it, comma separated: mqtt, influx, email, +# sentry, ml, or server for all of them. Empty is the minimal engine. +EXTRAS=server # The MCP endpoint agents connect to, and the OAuth server behind it. MCP_ENABLED=true diff --git a/.gitea/workflows/playwright.yml b/.gitea/workflows/playwright.yml index 7c37de7..00231a6 100644 --- a/.gitea/workflows/playwright.yml +++ b/.gitea/workflows/playwright.yml @@ -30,8 +30,8 @@ concurrency: env: # The playwright runner service comes from the dev overlay. It loads the SPA - # from the nginx `frontend` service and reaches the API as http://backend:8000 - # on the project network — both wired up in compose.ci.yml. + # and reaches the API on http://backend:8000, one origin on the project + # network, wired up in compose.ci.yml. COMPOSE_FILE: docker/compose.yml:docker/compose.dev.yml:docker/compose.ci.yml # frontend/playwright.config.ts keys the blob reporter, the retries and # forbidOnly off CI, and compose.dev.yml forwards it into the container. @@ -63,11 +63,9 @@ jobs: # Not committed; compose interpolates it and the runner container reads # FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD out of it (tests/config.ts). run: cp .env.example .env - - run: docker compose build backend frontend playwright + - run: docker compose build backend playwright - run: docker compose down -v --remove-orphans - # PLAYWRIGHT_BASE_URL points the browser at `frontend`, which is not a - # dependency of the playwright service, so bring it up explicitly. - - run: docker compose up -d --wait backend frontend + - run: docker compose up -d --wait backend # Named rather than --rm: the report is written inside the container, and # compose.ci.yml drops the mount that would have put it on this # filesystem — under a sibling daemon that mount lands somewhere this job diff --git a/.gitea/workflows/test-compose.yml b/.gitea/workflows/test-compose.yml index f34dbe1..df317eb 100644 --- a/.gitea/workflows/test-compose.yml +++ b/.gitea/workflows/test-compose.yml @@ -48,24 +48,22 @@ jobs: uses: actions/checkout@v4 - name: Write .env run: cp .env.example .env - # The Playwright job runs the SPA off a Vite server inside its own - # container, so the nginx image is only ever built there. This job is what - # covers it — the SPA fallback in frontend/nginx.conf is the part that - # breaks silently. - - run: docker compose build backend frontend + # The image serves the dashboard too; its SPA fallback (fluksio/spa.py) + # is the part that breaks silently. + - run: docker compose build backend - run: docker compose down -v --remove-orphans - # Redis comes along as a depends_on; --wait blocks on the backend - # healthcheck in docker/compose.yml, which is also what proves the - # schema was created before anything served a request. - - run: docker compose up -d --wait backend frontend + # Redis comes along as a depends_on; --wait blocks on the image's + # healthcheck (backend/Dockerfile), which is also what proves the schema + # was created before anything served a request. + - run: docker compose up -d --wait backend # Nothing is published on the host (compose.ci.yml), so the checks run - # from inside the backend container against the project network. + # from inside the backend container, which has python and no curl. - name: Backend is up - run: docker compose exec -T backend curl -f http://localhost:8000/api/v1/utils/health-check/ - - name: Frontend is up - run: docker compose exec -T backend curl -f http://frontend/ + run: docker compose exec -T backend python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/utils/health-check/')" + - name: Dashboard is up + run: docker compose exec -T backend python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" - name: An authenticated route serves the SPA shell - run: docker compose exec -T backend curl -f http://frontend/flows + run: docker compose exec -T backend python -c "import urllib.request; assert b'
:$(APP_PORT) (no DNS needed) @ip=$$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p'); \ DOMAIN=$${DOMAIN:-localhost} ENVIRONMENT=$${ENVIRONMENT:-local} APP_PORT=$(APP_PORT) \ LAN_URL=$${ip:+http://$$ip:$(APP_PORT)} \ - $(COMPOSE_LAN) up --build -d proxy backend frontend mailcatcher + $(COMPOSE_LAN) up --build -d --remove-orphans proxy backend mailcatcher -# The frontend is an nginx image, so a UI change needs a rebuild. Both the -# domain and the overlay set come off the *running* stack rather than from a -# file: VITE_API_URL is baked in at build time, so rebuilding under a different -# domain — or without the lan overlay a stack was started with — leaves the SPA -# calling an API that answers elsewhere. The lan overlay is what publishes the -# host port and builds with an empty VITE_API_URL, so dropping it takes a wall -# panel reaching http://:$(APP_PORT) off the air entirely and bakes a -# name it cannot resolve into the bundle. A published host port is how that -# overlay is recognised, because it is the thing only that overlay adds. -rebuild-frontend: ## Rebuild and restart the local frontend (after a UI change) - @domain=$$(docker inspect fluksio-app 2>/dev/null \ +# The dashboard is built into the app image, so a UI change needs a rebuild; +# only the image's frontend stage runs again. The domain and the overlay set +# come off the *running* stack rather than from a file, so the rebuilt +# container keeps the hostname and, on a LAN stack, the published port it was +# started with. A published host port is how the lan overlay is recognised, +# because it is the thing only that overlay adds. +rebuild-frontend: ## Rebuild the app image and restart it (after a UI change) + @domain=$$(docker inspect fluksio-api 2>/dev/null \ | grep -o 'Host(`app\.[^`]*`)' | head -1 | sed 's/Host(`app\.//;s/`)//'); \ - port=$$(docker inspect fluksio-app \ + port=$$(docker inspect fluksio-api \ --format '{{range $$p, $$c := .NetworkSettings.Ports}}{{range $$c}}{{.HostPort}} {{end}}{{end}}' \ 2>/dev/null | awk '{print $$1}'); \ if [ -n "$$port" ]; then \ @@ -112,7 +109,7 @@ rebuild-frontend: ## Rebuild and restart the local frontend (after a UI change) fi; \ DOMAIN=$${domain:-$$DOMAIN} ENVIRONMENT=$${ENVIRONMENT:-local} \ APP_PORT=$${port:-$(APP_PORT)} \ - $$compose up --build -d frontend + $$compose up --build -d backend up: ## Start the production stack $(COMPOSE_PROD_RUN) up --build -d @@ -204,14 +201,13 @@ PW_VERSION = $(shell sed -n 's/.*"@playwright\/test": "[^0-9]*\([0-9.]*\)".*/\1/ test-stack: ## Start or update the frontend suite's own stack at http://app.test.localhost @docker ps -q -f name='^fluksio-app-proxy-1$$' | grep -q . \ || { echo " ✗ the local Traefik is not running, 'make dev' first"; exit 1; } - $(COMPOSE_TEST) up --build -d --wait redis backend frontend + $(COMPOSE_TEST) up --build -d --wait --remove-orphans redis backend # The Playwright container, minus its network. tests/guard.ts refuses a public # address on top of the fixed hostname. PW_RUN = docker run --rm --ipc=host --user $$(id -u):$$(id -g) -e HOME=/tmp \ -v $(COMPOSE_ROOT):/app -w /app/frontend \ -e PLAYWRIGHT_BASE_URL=http://app.$(TEST_DOMAIN) \ - -e PLAYWRIGHT_API_URL=http://api.$(TEST_DOMAIN) \ -e CI=$${CI:-1} \ -e FIRST_SUPERUSER="$$(sed -n 's/^FIRST_SUPERUSER=//p' $(COMPOSE_ROOT)/.env | head -1)" \ -e FIRST_SUPERUSER_PASSWORD="$$(sed -n 's/^FIRST_SUPERUSER_PASSWORD=//p' $(COMPOSE_ROOT)/.env | head -1)" @@ -225,8 +221,8 @@ test-frontend: test-stack ## Run frontend tests (Playwright e2e) against the te 2>/dev/null | cut -d/ -f1); \ [ -n "$$ip" ] || { echo " ✗ the local Traefik is not on the proxy network"; exit 1; }; \ $(PW_RUN) --network proxy \ - --add-host app.$(TEST_DOMAIN):$$ip --add-host api.$(TEST_DOMAIN):$$ip \ - -e HOST_RESOLVER_RULES="MAP app.$(TEST_DOMAIN) $$ip, MAP api.$(TEST_DOMAIN) $$ip" \ + --add-host app.$(TEST_DOMAIN):$$ip \ + -e HOST_RESOLVER_RULES="MAP app.$(TEST_DOMAIN) $$ip" \ $(PW_IMAGE) npx playwright test $(PLAYWRIGHT_ARGS) # WebKit takes *.localhost to loopback through GLib and ignores /etc/hosts, so diff --git a/README.md b/README.md index 1035146..c0a2524 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ Fluksio is a node-based automation software that brings trust and reliability to It just works and looks good. The core of Fluksio: a node-based, test-driven automation software built to scale. This -repo holds the FastAPI backend, the flow engine, and the dashboard SPA. It is served on -`app.${DOMAIN}` (SPA) and `api.${DOMAIN}` (API) +repo holds the FastAPI backend, the flow engine, and the dashboard SPA, built into one +image that serves all three on one port. ## Layout @@ -16,7 +16,8 @@ backend/ FastAPI + SQLModel + Alembic + SQLite worker/ the `fluksio-worker` distribution: the agent and the node runner frontend/ React 19 + TanStack Router + Tailwind 4 + shadcn/ui docs/ the public documentation site (zensical), served on docs.${DOMAIN} -docker/ compose.yml → compose.dev.yml → compose.local.yml (+ compose.traefik.yml) +compose.yml the single container, as the getting-started guide runs it +docker/ the operator stack: compose.yml → compose.dev.yml → compose.local.yml (+ compose.traefik.yml) scripts/ generate-client.sh, test.sh ``` @@ -48,11 +49,21 @@ FLUKSIO_WORKER_TOKEN=… fluksio-worker --url wss://api.fluksio.com/api/v1/worke ## Getting started -Needs Docker, `make` and `openssl`. Port 80 must be free. +Needs Docker with Compose v2. + +```sh +docker compose up -d # http://localhost:8080 +docker compose logs fluksio # the admin password, printed once +``` + +Optional features (MQTT, InfluxDB, mail, …) are build-time extras; see `EXTRAS` in +`compose.yml`. + +For development, with hot reload behind Traefik on port 80 (also needs `make` and `openssl`): ```sh make init # once: .env with generated secrets, `proxy` network, prints the admin login -make dev-local # full stack at http://app.localhost (API at http://api.localhost) +make dev-local # full stack at http://app.localhost make down # stop it ``` diff --git a/backend/Dockerfile b/backend/Dockerfile index 290b454..7aba764 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,47 +1,44 @@ -FROM python:3.13 +# The whole instance in one image: engine, API and dashboard on port 8000. +# +# docker build -f backend/Dockerfile -t fluksio . +# docker run -d -p 8080:8000 -v fluksio-data:/data fluksio +# +# The build context is the app repository root. EXTRAS picks the optional +# features (mqtt, influx, email, sentry, ml, or server for all of them); the +# default is the minimal engine. -ENV PYTHONUNBUFFERED=1 +# ── Dashboard ──────────────────────────────────────────────────── +FROM oven/bun:1 AS frontend -# Install uv -# Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-uv -COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/ +WORKDIR /app +COPY package.json bun.lock /app/ +COPY frontend/package.json /app/frontend/ +WORKDIR /app/frontend +RUN bun install -# Compile bytecode -# Ref: https://docs.astral.sh/uv/guides/integration/docker/#compiling-bytecode -ENV UV_COMPILE_BYTECODE=1 +COPY ./frontend /app/frontend +# Empty: the dashboard calls the API as a path on whichever origin served it, +# so one build works behind any port, hostname or proxy. +ENV VITE_API_URL= +RUN bun run build -# uv Cache -# Ref: https://docs.astral.sh/uv/guides/integration/docker/#caching -ENV UV_LINK_MODE=copy +# ── Python environment ─────────────────────────────────────────── +FROM python:3.13-slim AS backend + +# Ref: https://docs.astral.sh/uv/guides/integration/docker/ +COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /bin/ +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy WORKDIR /app/ -# Place executables in the environment at the front of the path -# Ref: https://docs.astral.sh/uv/guides/integration/docker/#using-the-environment -ENV PATH="/app/.venv/bin:$PATH" +ARG EXTRAS="" -# /app/.venv holds the app and nothing of anybody else's, so there is nothing -# here to adopt: node code gets a venv of its own on the data volume, which is -# what the Modules screen installs into. A `pip install fluksio` into an -# environment somebody already works in is the case that adopts instead. -ENV NODE_VENV=managed - -# ffmpeg for node code that transcodes, such as the clip recipe in -# docs/code/nodes.md. Off by default: Debian's package and what it pulls in add -# about 400 MB. Set WITH_FFMPEG=true in .env and rebuild to include it. -ARG WITH_FFMPEG=false -RUN if [ "$WITH_FFMPEG" = true ]; then \ - apt-get update \ - && apt-get install -y --no-install-recommends ffmpeg \ - && rm -rf /var/lib/apt/lists/*; \ - fi - -# Install dependencies -# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers +# Dependencies first, so a source change does not reinstall them. RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-install-workspace --package fluksio --extra server + uv sync --frozen --no-dev --no-install-workspace --package fluksio \ + $(for e in $(echo "$EXTRAS" | tr , ' '); do printf -- '--extra %s ' "$e"; done) COPY ./backend/scripts /app/backend/scripts @@ -57,12 +54,11 @@ COPY ./backend/fluksio /app/backend/fluksio # node code with. COPY ./worker /app/worker -# Sync the project -# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --package fluksio --extra server + uv sync --frozen --no-dev --package fluksio \ + $(for e in $(echo "$EXTRAS" | tr , ' '); do printf -- '--extra %s ' "$e"; done) # Connectors are ordinary installed packages found through the # `fluksio.node_types` entry point. `make connectors` builds them into here; @@ -73,9 +69,45 @@ RUN --mount=type=cache,target=/root/.cache/uv \ && uv pip install --python /app/.venv/bin/python /tmp/connector-wheels/*.whl \ || echo "no connectors bundled"' -WORKDIR /app/backend/ +# ── Runtime ────────────────────────────────────────────────────── +FROM python:3.13-slim -# Single worker on purpose: the process hosts the flow engine, and a second -# worker would be a second engine — duplicated subscriptions, cron ticks and -# webhooks. Scaling out is the M5 worker split, not more uvicorn processes. -CMD ["fastapi", "run", "fluksio/main.py"] +# git versions the flows. redis-server keeps message state when no external +# Redis is named. tini reaps the python node workers that outlive their pool, +# which would otherwise pile up as zombies under PID 1. procps is uptime, ps +# and free for Command nodes, the demo's among them. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git procps redis-server tini \ + && rm -rf /var/lib/apt/lists/* + +# /app/.venv holds the app and nothing of anybody else's, so there is nothing +# here to adopt: node code gets a venv of its own on the data volume, which is +# what the Modules screen installs into. +ENV PYTHONUNBUFFERED=1 \ + PATH="/app/.venv/bin:$PATH" \ + NODE_VENV=managed \ + FRONTEND_DIST=/app/frontend/dist \ + FRONTEND_HOST=http://localhost:8000 + +# The venv installs the app editable, so the source stays at the path it was +# synced from. uv comes with it: the Modules screen installs with it. +COPY --from=backend /app /app +COPY --from=frontend /app/frontend/dist /app/frontend/dist +COPY ./backend/docker-entrypoint.sh /usr/local/bin/ + +WORKDIR /app/backend/ +VOLUME /data +EXPOSE 8000 + +# Deep health: fails when the event loop is wedged or Redis is gone, not just +# when the process is dead. +HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/utils/health/', timeout=4)" + +ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"] + +# Single process on purpose: it hosts the flow engine, and a second worker +# would be a second engine — duplicated subscriptions, cron ticks and webhooks. +# `serve` makes the secret key and the first admin on first start, and prints +# the admin's password once. +CMD ["fluksio", "serve", "--plain", "--host", "0.0.0.0", "--port", "8000", "--data-dir", "/data"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100755 index 0000000..72e37e1 --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Starts the bundled Redis unless an external one is named, then the command. +# +# Redis holds the last value of every message, node state and the work queue, +# so without it a restart forgets all three. In the background on purpose: its +# appendonly file is written on every change, so the SIGKILL it gets when the +# container stops loses nothing that was acknowledged. +set -e + +if [ -z "$REDIS_HOST" ]; then + mkdir -p /data/redis + redis-server --bind 127.0.0.1 --port 6379 --dir /data/redis \ + --appendonly yes --loglevel warning & + i=0 + until redis-cli -h 127.0.0.1 ping >/dev/null 2>&1; do + i=$((i + 1)) + [ "$i" -lt 100 ] || { echo "bundled redis did not start" >&2; exit 1; } + sleep 0.1 + done + export REDIS_HOST=127.0.0.1 +fi + +exec "$@" diff --git a/backend/fluksio/core/config.py b/backend/fluksio/core/config.py index f88d8af..248200a 100644 --- a/backend/fluksio/core/config.py +++ b/backend/fluksio/core/config.py @@ -54,7 +54,12 @@ class Settings(BaseSettings): SECRET_KEY: str = secrets.token_urlsafe(32) # 60 minutes * 24 hours * 8 days = 8 days ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 + #: The address people and devices reach this instance at: panel links, + #: mails and the MCP issuer are built from it. FRONTEND_HOST: str = "http://localhost:5173" + #: The built dashboard, served on the API's own origin when set. The image + #: sets it; a checkout runs Vite instead and leaves it empty. + FRONTEND_DIST: Path | None = None ENVIRONMENT: Literal["local", "staging", "production"] = "local" #: The proxies whose X-Forwarded-For is believed: uvicorn's own setting, #: read here too so a call replayed off the portal tunnel resolves its @@ -111,7 +116,6 @@ class Settings(BaseSettings): # unless asked for: an account can change what flows do, so who gets one # is a superuser's call (Admin → Users). SIGNUP_ENABLED: bool = False - DOMAIN: str = "localhost" OAUTH_PRIVATE_KEY_FILE: Path = Path("flow-data/oauth-key.pem") # Written only when someone enrols this instance with a portal. # Its absence is what keeps remote access off. @@ -237,14 +241,14 @@ class Settings(BaseSettings): @computed_field # type: ignore[prop-decorator] @property def oauth_issuer(self) -> str: - """Who issues MCP tokens — this app, on its API host. + """Who issues MCP tokens — this app, at the address it is reached on. - Kept separate from the app's own URL because a hosted deployment can - later point agents at a different issuer without the resource server + The API is a path on the dashboard's origin, so that one address is + both. Kept a property of its own because a hosted deployment can later + point agents at a different issuer without the resource server changing: it validates whatever issuer it is configured to trust. """ - scheme = "http" if self.ENVIRONMENT == "local" else "https" - return f"{scheme}://api.{self.DOMAIN}" + return self.FRONTEND_HOST.rstrip("/") @computed_field # type: ignore[prop-decorator] @property diff --git a/backend/fluksio/flow/nodes/influx.py b/backend/fluksio/flow/nodes/influx.py index 3043439..becfecd 100644 --- a/backend/fluksio/flow/nodes/influx.py +++ b/backend/fluksio/flow/nodes/influx.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) def _influxdb() -> Any: - """The client library, which is a `fluksio[server]` extra. + """The client library, which is the `influx` extra. Imported per use rather than at module level, because the node type is registered at boot and an instance with no InfluxDB behind it should @@ -25,7 +25,8 @@ def _influxdb() -> Any: import influxdb_client except ImportError: raise RuntimeError( - "the influxdb node needs the server extra: pip install 'fluksio[server]'" + "the influxdb node needs the influx extra: pip install 'fluksio[influx]', " + "or build the image with EXTRAS=influx" ) from None return influxdb_client diff --git a/backend/fluksio/flow/nodes/mlp.py b/backend/fluksio/flow/nodes/mlp.py index 75a9346..8eaaa45 100644 --- a/backend/fluksio/flow/nodes/mlp.py +++ b/backend/fluksio/flow/nodes/mlp.py @@ -7,7 +7,6 @@ import threading from collections.abc import Iterable from typing import Any -import numpy as np from pydantic import BaseModel, ConfigDict from fluksio.flow.messages import MessageSpec @@ -16,6 +15,22 @@ from fluksio.flow.nodes.base import Node logger = logging.getLogger(__name__) +def _numpy() -> Any: + """numpy, which is the `ml` extra. + + Imported when a node is built rather than at boot, so an instance without + it still offers every other type, and a node placed without it says so. + """ + try: + import numpy + except ImportError: + raise RuntimeError( + "the mlp node needs the ml extra: pip install 'fluksio[ml]', " + "or build the image with EXTRAS=ml" + ) from None + return numpy + + class MLPNode(Node): """ Multi-Layer Perceptron node for neural network processing in pipelines. @@ -72,6 +87,7 @@ class MLPNode(Node): ) cfg = self.Params.model_validate(self.params) + np = self._np = _numpy() rng = np.random.default_rng(seed=cfg.seed) num_inputs = max(1, len(self.input_ports)) num_outputs = max(1, len(self.output_ports)) @@ -91,6 +107,7 @@ class MLPNode(Node): self.id, ) + np = self._np if kwargs: input_array = np.array([float(v) for v in kwargs.values()]) else: diff --git a/backend/fluksio/flow/nodes/mqtt.py b/backend/fluksio/flow/nodes/mqtt.py index a6dbc6b..ad71667 100644 --- a/backend/fluksio/flow/nodes/mqtt.py +++ b/backend/fluksio/flow/nodes/mqtt.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) def _aiomqtt() -> Any: - """The client library, which is a `fluksio[server]` extra. + """The client library, which is the `mqtt` extra. Imported per use rather than at module level, because the node type is registered at boot and an instance that talks to no broker should not @@ -31,7 +31,8 @@ def _aiomqtt() -> Any: import aiomqtt except ImportError: raise RuntimeError( - "the mqtt node needs the server extra: pip install 'fluksio[server]'" + "the mqtt node needs the mqtt extra: pip install 'fluksio[mqtt]', " + "or build the image with EXTRAS=mqtt" ) from None return aiomqtt diff --git a/backend/fluksio/main.py b/backend/fluksio/main.py index dfc87c8..3f3d4bc 100644 --- a/backend/fluksio/main.py +++ b/backend/fluksio/main.py @@ -90,7 +90,7 @@ def custom_generate_unique_id(route: APIRoute) -> str: if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": - # Imported here rather than at the top: it is a `fluksio[server]` extra, so + # Imported here rather than at the top: it is the `sentry` extra, so # a pip install without one has no sentry to import — and no DSN either. import sentry_sdk @@ -506,6 +506,11 @@ if settings.all_cors_origins: app.add_middleware(DocsForThisMachine) +if settings.FRONTEND_DIST: + from fluksio.spa import Dashboard + + app.add_middleware(Dashboard, dist=settings.FRONTEND_DIST) + # The caller's address, for every per-address limit and the docs check above: # the rightmost X-Forwarded-For hop that is not a trusted proxy. uvicorn does # this for what it serves, but a call the portal tunnel replays arrives over an diff --git a/backend/fluksio/spa.py b/backend/fluksio/spa.py new file mode 100644 index 0000000..a73c451 --- /dev/null +++ b/backend/fluksio/spa.py @@ -0,0 +1,88 @@ +"""The dashboard, served by the API on its own origin. + +One process and one port: the built SPA comes off disk, and every path the API +does not own answers with ``index.html``, so a client-side route survives a +reload. Same origin also means the dashboard calls the API as a path, needs no +CORS allowance and no second hostname, which is what lets the whole instance +sit behind a single published port. +""" + +from __future__ import annotations + +import base64 +import hashlib +import mimetypes +import re +from pathlib import Path + +from starlette.middleware.gzip import GZipMiddleware +from starlette.responses import FileResponse +from starlette.types import ASGIApp, Receive, Scope, Send + +#: What the API answers itself. Everything else is the dashboard's. +API_PREFIXES = ("/api/", "/hooks/", "/mcp", "/.well-known/", "/docs", "/redoc") + +# Older mime tables predate the manifest, and it would go out as a download. +mimetypes.add_type("application/manifest+json", ".webmanifest") + + +def content_security_policy(index_html: str) -> str: + """The policy for the dashboard, admitting its inline scripts by hash. + + The hash is taken from the file being served, so editing the theme script + in ``index.html`` cannot leave a stale one behind. + style-src 'unsafe-inline': Monaco, Radix's scroll lock and the toasts insert +