The Node-RED installation this replaces is 865 nodes across three tabs, and roughly a fifth of it is unreachable — the pellet stove's controller, the scene engine and the awning's logic were all disconnected from the heartbeat they ran on. What is here is the intent rather than the wiring: nineteen named flows, 109 nodes, and no heartbeat at all. A sensor value is the event. The device layer moves with it. `actor/*` and `light/*` were never a device interface — Node-RED subscribed to its own topics, stamped a DMX channel on each and encoded one Art-Net universe — so those topics retire with it and the encoders are five nodes in the `dmx` flow. Two shared library nodes carry what every actuator needs. `arbiter` answers the thing this design was missing: a value someone sets on a screen is not undone by the next evaluation. A manual value wins for a hold, the house takes over when it expires, and a schedule can force past both — so "off at two in the morning" still means off. The control binds to the message the arbiter writes back, so one tile shows what reached the fixture and setting it is the override. `motor` is why a stop is now commanded once. A rollershutter has no position sensor, so time is the only feedback: it says how long to run and a trigger sends the single STOP that ends it. The reference sent STOP forever. Everything is seeded stopped, the Art-Net node does not transmit and the heat pump does not accept commands until house.json says so. `--dry` checks the whole set without an installation: names nothing provides, loops, type disagreements, widgets bound to nothing, and every Python node run once on values of the shape it declared — including whether what it returns goes anywhere. That last one has already caught a typo that would have published into silence. house.json holds this installation's addresses, MAC addresses and DMX map and is git-ignored, as the Node-RED inventory is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
221 lines
12 KiB
Makefile
221 lines
12 KiB
Makefile
# ─── Fluksio app Makefile ───
|
|
# Convenience targets for development, testing, linting, and deployment.
|
|
# The workspace root delegates to these (see ../Makefile).
|
|
|
|
.PHONY: dev-utils dev dev-local dev-lan up down update install dev-backend dev-frontend \
|
|
generate-client seed-example seed-demo seed-house seed-aircon seed-tinyhouse seed-hosted-demo test test-backend test-frontend soak bench-startup lint lint-backend \
|
|
lint-frontend format-frontend umami build docs docs-serve clean help
|
|
|
|
COMPOSE_ROOT := $(CURDIR)
|
|
# Explicit project name keeps this stack isolated from the sibling website
|
|
# stack (otherwise both default to "docker", the directory their compose
|
|
# files live in).
|
|
COMPOSE_PROJECT := fluksio-app
|
|
# Compose interpolation needs the project-local .env before reading compose.yml.
|
|
COMPOSE := docker compose -p $(COMPOSE_PROJECT) --env-file $(COMPOSE_ROOT)/.env
|
|
COMPOSE_PROD := $(COMPOSE) -f docker/compose.yml
|
|
# Production also runs autoheal, which restarts the backend when its deep
|
|
# health check fails. It is profile-gated because it mounts the Docker socket.
|
|
COMPOSE_PROD_RUN := $(COMPOSE_PROD) --profile autoheal
|
|
# Host port `make dev-lan` publishes the frontend on.
|
|
APP_PORT ?= 8080
|
|
COMPOSE_DEV := $(COMPOSE_PROD) -f docker/compose.dev.yml
|
|
# Integrated local stack: dev stack wired onto the shared `proxy` network.
|
|
COMPOSE_LOCAL := $(COMPOSE_DEV) -f docker/compose.local.yml
|
|
# Same stack, with the frontend also published on APP_PORT and proxying the API
|
|
# there, for clients that cannot resolve app.$(DOMAIN).
|
|
COMPOSE_LAN := $(COMPOSE_LOCAL) -f docker/compose.lan.yml
|
|
|
|
help: ## Show available targets
|
|
@awk 'BEGIN{FS=":.*?## "} /^[a-zA-Z_-]+:.*?##/ {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
|
|
|
# ── Development (Docker) ──────────────────────────────────────────
|
|
|
|
dev-utils: ## Start only the utility containers (proxy, mailcatcher)
|
|
$(COMPOSE_DEV) up --build proxy mailcatcher
|
|
|
|
dev: ## Start the full dev stack (includes a local Traefik proxy)
|
|
$(COMPOSE_DEV) up --build
|
|
|
|
dev-local: ## Start the integrated local stack (called by the root `make dev`)
|
|
DOMAIN=$${DOMAIN:-localhost} ENVIRONMENT=$${ENVIRONMENT:-local} \
|
|
$(COMPOSE_LOCAL) up --build -d proxy backend frontend mailcatcher
|
|
|
|
dev-lan: ## Same, plus the app on http://<host-ip>:$(APP_PORT) (no DNS needed)
|
|
DOMAIN=$${DOMAIN:-localhost} ENVIRONMENT=$${ENVIRONMENT:-local} APP_PORT=$(APP_PORT) \
|
|
$(COMPOSE_LAN) up --build -d proxy backend frontend mailcatcher
|
|
|
|
up: ## Start the production stack
|
|
$(COMPOSE_PROD_RUN) up --build -d
|
|
|
|
umami: ## Provision + start the optional Umami site-analytics service (idempotent)
|
|
# Guard: the dashboard is internet-facing on analytics.$(DOMAIN); refuse to
|
|
# start when its session-signing secret is unset or still the placeholder.
|
|
@secret=$$(grep -E '^UMAMI_APP_SECRET=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \
|
|
if [ -z "$$secret" ] || [ "$$secret" = "changethis" ]; then \
|
|
echo "ERROR: UMAMI_APP_SECRET is empty or 'changethis' in .env — run the workspace root's scripts/setup.sh (or set it: openssl rand -hex 32) before 'make umami'." >&2; \
|
|
exit 1; \
|
|
fi
|
|
# --no-recreate: reuse an already-running db instead of recreating it under
|
|
# the prod compose set, which would blip every service that depends on it.
|
|
$(COMPOSE_PROD) up -d --wait --no-recreate db
|
|
# Least-privilege role owning only the umami database, so the third-party
|
|
# image never holds the shared superuser credentials.
|
|
@pguser=$$(grep -E '^POSTGRES_USER=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); pguser=$${pguser:-postgres}; \
|
|
role=$$(grep -E '^UMAMI_DB_USER=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); role=$${role:-umami}; \
|
|
pw=$$(grep -E '^UMAMI_DB_PASSWORD=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \
|
|
db=$$(grep -E '^UMAMI_DB=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); db=$${db:-umami}; \
|
|
if [ -z "$$pw" ] || [ "$$pw" = "changethis" ]; then \
|
|
echo "ERROR: UMAMI_DB_PASSWORD is empty or 'changethis' in .env — run the workspace root's scripts/setup.sh before 'make umami'." >&2; \
|
|
exit 1; \
|
|
fi; \
|
|
$(COMPOSE_PROD) exec -T db psql -v ON_ERROR_STOP=1 -U "$$pguser" -d postgres \
|
|
-v role="$$role" -v pw="$$pw" -v db="$$db" < docker/provision-umami-db.sql
|
|
# --no-deps: only (re)create umami, never restart the shared db underneath it.
|
|
$(COMPOSE_PROD) --profile analytics up -d --no-deps umami
|
|
|
|
update: ## Pull, rebuild using the layer cache, and recreate changed containers
|
|
git pull
|
|
$(COMPOSE_PROD_RUN) build
|
|
$(COMPOSE_PROD_RUN) up -d --remove-orphans
|
|
docker image prune -f
|
|
|
|
down: ## Stop all running containers
|
|
-$(COMPOSE_LOCAL) down
|
|
-$(COMPOSE_PROD_RUN) down
|
|
|
|
# ── Development (local, no Docker) ───────────────────────────────
|
|
# Run `make dev-backend` and `make dev-frontend` in two separate terminals.
|
|
|
|
install: ## Install all dependencies (backend + frontend)
|
|
cd backend && uv sync
|
|
cd frontend && bun install
|
|
|
|
dev-backend: ## Start the FastAPI backend with hot-reload (local)
|
|
cd backend && uv run fastapi dev fluksio/main.py
|
|
|
|
dev-frontend: ## Start the Vite dev server (local)
|
|
cd frontend && bun dev
|
|
|
|
generate-client: ## Regenerate the frontend SDK from the backend's OpenAPI schema
|
|
bash scripts/generate-client.sh
|
|
|
|
seed-example: ## Seed the querying-chart example (needs a running stack + InfluxDB)
|
|
cd backend && uv run python ../scripts/seed_example_chart.py
|
|
|
|
seed-demo: ## Seed the training-run example: a batch flow and its dashboard
|
|
cd backend && uv run python ../scripts/seed_demo_training.py
|
|
|
|
seed-house: ## Seed the house write-path rig (needs the real broker reachable)
|
|
cd backend && uv run python ../scripts/seed_house_control.py
|
|
|
|
seed-aircon: ## Seed the aircon write-path rig (needs the unit reachable)
|
|
cd backend && uv run python ../scripts/seed_aircon_control.py
|
|
|
|
# The house itself. Reads ../house.json for this installation's addresses and
|
|
# DMX wiring; everything it creates is published and stopped. ARGS=--dry checks
|
|
# the whole set without an installation and pushes nothing.
|
|
seed-tinyhouse: ## Seed the TinyHouse flows (ARGS=--dry to check only)
|
|
@user=$$(grep -E '^FIRST_SUPERUSER=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \
|
|
pass=$$(grep -E '^FIRST_SUPERUSER_PASSWORD=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \
|
|
cd backend && \
|
|
FIRST_SUPERUSER="$${FIRST_SUPERUSER:-$$user}" \
|
|
FIRST_SUPERUSER_PASSWORD="$${FIRST_SUPERUSER_PASSWORD:-$$pass}" \
|
|
uv run python ../scripts/seed_tinyhouse.py $(ARGS)
|
|
|
|
# Operators of the hosted demo only — NOT part of any deployment, and nothing a
|
|
# self-hosted instance needs. It wipes and recreates its three flows and its
|
|
# dashboard, so re-running it is how the public demo is reset.
|
|
# `uv run` does not read .env, so the credentials are lifted out of it here;
|
|
# anything already exported wins, which is how a remote instance is targeted:
|
|
# API_URL=https://api.example.com make seed-hosted-demo
|
|
seed-hosted-demo: ## Seed the hosted demo panel (operators only; API_URL selects the instance)
|
|
@user=$$(grep -E '^FIRST_SUPERUSER=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \
|
|
pass=$$(grep -E '^FIRST_SUPERUSER_PASSWORD=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \
|
|
cd backend && \
|
|
FIRST_SUPERUSER="$${FIRST_SUPERUSER:-$$user}" \
|
|
FIRST_SUPERUSER_PASSWORD="$${FIRST_SUPERUSER_PASSWORD:-$$pass}" \
|
|
uv run python ../scripts/seed_demo.py
|
|
|
|
# ── Testing ───────────────────────────────────────────────────────
|
|
|
|
test: test-backend test-frontend ## Run all tests (backend + frontend)
|
|
|
|
test-backend: ## Run backend tests (pytest + coverage)
|
|
# Its own SQLite file in a temp directory (tests/__init__.py), so this needs
|
|
# nothing running and touches no development data.
|
|
cd backend && uv run bash scripts/test.sh
|
|
|
|
# The hostname the stack is served under. A checkout configured for a
|
|
# deployment carries the deployment's domain, and *.fluksio.com resolves to the
|
|
# live instance from here — which is why the run below maps both names onto the
|
|
# local Traefik by address and never lets DNS decide.
|
|
export DOMAIN ?= $(shell sed -n 's/^DOMAIN=//p' $(COMPOSE_ROOT)/.env | head -1)
|
|
PW_VERSION = $(shell sed -n 's/.*"@playwright\/test": "[^0-9]*\([0-9.]*\)".*/\1/p' frontend/package.json | head -1)
|
|
|
|
test-frontend: ## Run frontend tests (Playwright e2e) against the local stack
|
|
@ip=$$(docker network inspect proxy \
|
|
--format '{{range .Containers}}{{if eq .Name "fluksio-app-proxy-1"}}{{.IPv4Address}}{{end}}{{end}}' \
|
|
2>/dev/null | cut -d/ -f1); \
|
|
[ -n "$$ip" ] || { echo " ✗ proxy network or Traefik container not found — is the stack up?"; exit 1; }; \
|
|
docker run --rm --network proxy --ipc=host \
|
|
--user $$(id -u):$$(id -g) -e HOME=/tmp \
|
|
--add-host app.$(DOMAIN):$$ip --add-host api.$(DOMAIN):$$ip \
|
|
-v $(COMPOSE_ROOT):/app -w /app/frontend \
|
|
-e PLAYWRIGHT_BASE_URL=http://app.$(DOMAIN) \
|
|
-e PLAYWRIGHT_API_URL=http://api.$(DOMAIN) \
|
|
-e HOST_RESOLVER_RULES="MAP app.$(DOMAIN) $$ip, MAP api.$(DOMAIN) $$ip" \
|
|
-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)" \
|
|
mcr.microsoft.com/playwright:v$(PW_VERSION)-noble \
|
|
npx playwright test $(PLAYWRIGHT_ARGS)
|
|
|
|
# Load and chaos against a *running* stack, never part of `make test`: it
|
|
# restarts this stack's containers. `SOAK_ARGS="--dry-run"` only looks.
|
|
soak: ## Run the soak/chaos harness (SOAK_ARGS="--minutes 30 --scenario redis")
|
|
cd backend && uv run python scripts/soak.py $(SOAK_ARGS)
|
|
|
|
bench-startup: ## Time submitting a run (BENCH_ARGS="--kedro ../some/kedro/project")
|
|
cd backend && uv run python scripts/bench_startup.py $(BENCH_ARGS)
|
|
|
|
# ── Linting ───────────────────────────────────────────────────────
|
|
|
|
build: ## Build the fluksio and fluksio-worker wheels into dist/
|
|
uv build --all-packages --out-dir dist
|
|
|
|
lint: lint-backend lint-frontend ## Run all linters
|
|
|
|
lint-backend: ## Lint backend with ruff + mypy
|
|
cd backend && uv run ruff check .
|
|
cd backend && uv run ruff format --check .
|
|
cd backend && uv run mypy fluksio
|
|
cd worker && uv run --no-project --with mypy mypy fluksio_worker
|
|
|
|
lint-frontend: ## Lint frontend with biome
|
|
cd frontend && bun run lint
|
|
|
|
format-frontend: ## Apply biome's fixes to the frontend (what `lint-frontend` only reports)
|
|
cd frontend && bun run format
|
|
|
|
# ── Documentation ─────────────────────────────────────────────────
|
|
# The public site on docs.${DOMAIN}, served by the `docs` service in
|
|
# docker/compose.yml. These targets are for local authoring: zensical runs on
|
|
# demand via uvx, so it never touches the backend environment. Pinned to the
|
|
# version docker/Dockerfile.docs and .gitea/workflows/docs.yml ship, so local
|
|
# authoring builds with what docs.fluksio.com actually gets.
|
|
ZENSICAL := zensical==0.0.46
|
|
|
|
docs: ## Build the documentation site into ./site
|
|
uvx $(ZENSICAL) build --clean
|
|
|
|
docs-serve: ## Serve the documentation site locally with live reload
|
|
uvx $(ZENSICAL) serve
|
|
|
|
# ── Cleanup ───────────────────────────────────────────────────────
|
|
|
|
clean: ## Remove build artifacts and caches
|
|
rm -rf frontend/dist frontend/blob-report frontend/test-results
|
|
rm -rf backend/.pytest_cache backend/htmlcov site
|
|
find backend -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|