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
+