29 Commits
Author SHA1 Message Date
stroblmeandClaude Opus 5 d01a8dad37 Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
2026-08-31 10:12:01 +02:00
stroblmeandClaude Opus 5 8b65e1ecfd Give the frontend's check scripts something that runs them
Eight `*.check.ts` scripts existed and nothing ran any of them, so a
helper's arithmetic was only checked by hand. `make test-checks` runs
every one and `make test` now includes it — they need no stack, so they
sit between the backend suite and Playwright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
2026-08-30 17:19:20 +02:00
stroblme 4c4920bda8 Keep the lan overlay when the frontend is rebuilt
Docs / docs (push) Successful in 25s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m14s
Playwright Tests / test-playwright (2, 2) (push) Failing after 12s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m31s
Compose Smoke Test / test-compose (push) Failing after 10s
Playwright Tests / merge-reports (push) Failing after 2m22s
`rebuild-frontend` already took the domain off the running stack, for exactly
the right reason — `VITE_API_URL` is baked in at build time, so rebuilding
under a different one leaves the SPA calling an API that answers elsewhere. It
then dropped `compose.lan.yml` regardless of whether the stack had been started
with it, which is the same mistake with a worse blast radius: that overlay is
what publishes the host port and what builds with an *empty* VITE_API_URL, and
it is the only way a wall panel reaches the app at all, since a screen on the
LAN cannot resolve app.${DOMAIN}. Rebuilding without it unpublished the port
and baked in a name that device cannot resolve — the panel went dark for an
hour and it took a log to see why.

Recognised by the published host port, because that is the one thing only that
overlay adds. Same shape as the domain sniffing above it: what is running is
the authority, not what happens to be typed on the command line.
2026-08-30 15:07:15 +02:00
stroblmeandClaude Opus 5 22c682e505 Put the deployment-only dependencies behind a server extra
A data-science environment installing fluksio waited for lxml, aiohttp and
the rest of a connector stack it has nothing to talk to. Outbound mail,
error reporting and the MQTT and InfluxDB clients moved to
`fluksio[server]`, which the image installs; each import is guarded and
names the extra. `tenacity` had no import site at all and is gone.

23 fewer packages and the compiled ones among them — a bare `pip install
fluksio` still serves, runs every python node, and registers the mqtt and
influxdb node types, which only need the library when one is actually
built. sentry-sdk arrives anyway underneath `fastapi[standard]`; what
changed there is that nothing of ours requires it.

The dev environment keeps every extra: the suite exercises the connectors
and strict mypy checks their call sites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 14:06:50 +02:00
stroblmeandClaude Opus 5 b80b92aad7 Local test targets read the running stack, not the deployment's domain
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Successful in 1m57s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m43s
pre-commit / pre-commit (push) Failing after 3m32s
Test Backend / test-backend (push) Successful in 2m18s
Compose Smoke Test / test-compose (push) Successful in 31s
Playwright Tests / merge-reports (push) Successful in 1m27s
`make test-frontend` built PLAYWRIGHT_BASE_URL from DOMAIN in .env, which in a
checkout configured for a deployment is that deployment's domain — so the suite
that creates and deletes flows, dashboards and users was pointed at
app.fluksio.com, held local only by --add-host and tests/guard.ts.

The hostname now comes off the running stack (the frontend container's own
Traefik rule), so a name no local container answers to cannot be reached at
all, and the local targets default to *.localhost instead of reading .env.
Target-specific on purpose: an exported DOMAIN outranks --env-file in compose
interpolation and would put the production targets on localhost.

`rebuild-frontend` replaces the raw compose line CLAUDE.md spelled out, taking
the same domain so the baked VITE_API_URL cannot disagree with what Traefik
serves. Also: a coverage HTML report that cannot be written no longer fails
test-backend after a green suite, and both artifact actions in playwright.yml
drop to @v3, which is the only version without the github.com-only guard that
failed every run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:48:05 +02:00
stroblmeandClaude Opus 5 1430c4060b Give the engine bench a make target
And stop its drain loop asking for the whole of `stats()` every twenty
milliseconds — four round trips a poll, competing with the engine for the
connection it is measuring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BpfSinyCBfjuieikyfMPbf
2026-08-26 10:16:04 +02:00
stroblmeandClaude Opus 5 68711e2ee7 Start the docs site with the integrated local stack
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UytviPMJbXzD8P84nLvXcq
2026-08-25 18:33:28 +02:00
stroblmeandClaude Opus 5 9746c6ce95 Move the seed scripts out to the private workspace root
Docs / docs (push) Successful in 50s
Playwright Tests / test-playwright (1, 2) (push) Failing after 45s
Playwright Tests / test-playwright (2, 2) (push) Failing after 17s
pre-commit / pre-commit (push) Failing after 2m35s
Test Backend / test-backend (push) Failing after 50s
Compose Smoke Test / test-compose (push) Failing after 14s
Playwright Tests / merge-reports (push) Failing after 1m4s
Several of them describe this installation specifically — its broker, its
aircon, its DMX wiring — and this repository is about to be public. The
test and client-generation scripts stay; CI depends on scripts/test.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:27:30 +02:00
stroblmeandClaude Fable 5 a38e2745eb Add a Python SDK: flows declared in your own repository
A data scientist keeps their code where it is and decorates it: `@node`
declares a function's ports beside the function, `Flow(name, nodes=[...])`
says which of them make a flow, and `use(fn, wire=..., **settings)` rebinds
one for a single flow. `fluksio sync` uploads the document plus a generated
import shim per node, so the store still holds a complete, runnable,
git-versioned definition while the code it imports stays theirs.

`fluksio login|run|runs` and `flow.submit().wait()` are the client half, over
the run endpoints that already existed. Runs record the user repository's
commit beside the store's, so "what code produced this number" is answerable
on the side that now holds the code.

- `fluksio/sdk/`: ports, decorators, the flow builder and its checks, the shim
  generator, an HTTP client and sync. Standard library only at import, so
  `from fluksio import node` in a training script pulls in no engine.
- `FlowDef.origin` marks a flow code-defined; `Run.origin_commit` carries the
  repository's commit; `POST /modules/refresh` retires the workers without an
  install, which every sync calls — a worker holds the imported package in
  memory, so an edit to it is invisible until the process goes.
- The canvas shows a generated body read-only and names the repository to edit
  instead; a body edited there stops the next sync rather than being discarded.
- The worker's reporter carries inert `Port`, `node`, `use` and `Flow`, since
  the shim imports a module whose first line declares them.
- `examples/myresearch` is the worked example, `make sync-example` uploads it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ue1tkFWB1bcGy3aWhCKpU
2026-08-23 20:16:08 +02:00
stroblmeandClaude Opus 5 2538246242 Seed the TinyHouse: nineteen flows in place of eight hundred nodes
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>
2026-08-22 14:44:21 +02:00
stroblme 1fe712e7fc Add make dev-lan: the app on a host port, API proxied on the same origin 2026-08-22 09:25:02 +02:00
stroblmeandClaude Opus 5 11e032386b Publish the documentation site: docs.fluksio.com
A zensical site under docs/, served by a new `docs` compose service behind
Traefik, built with --strict in CI. Same pattern the sibling n3xd workspace
uses.

Getting started splits the way the landing page does — one path is
`pip install fluksio` and a training script, the other is a Docker stack and
an afternoon in the browser — because the two audiences will not spend the same
amount of time. Everything after that is shared: the concepts, the web
interface (app and portal), the CLI and the API, and a reference for node types,
payload types and configuration.

The three flow guides move here from the docs submodule rather than being
copied, so there is one version of them.

Styling mirrors DESIGN-GUIDELINES.md: the app's token palette remapped onto
Material's variables in both schemes, Inter, the 16px panel radius, and the one
terracotta accent spent on the facility lane of the audience split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7Xv3cJEW5c8AXxn2hoojV
2026-08-22 05:55:34 +02:00
stroblmeandClaude Opus 5 10b0ba9e49 Add the fluksio CLI: serve, enroll, worker
`pip install fluksio && fluksio serve` on a machine with no Docker, no
database and no configuration — which is the case this is for: a node on
a cluster where ports cannot be opened. It makes its data directory, its
key and an admin account, prints the password once, and serves. Pairing
is `fluksio enroll <code> --portal …`, doing what the Settings screen
does through the same function, before the engine starts and without one
running — a machine nobody can route to has no browser pointed at it
either. The portal serves the dashboard, so nothing is served here.

Two things had to give way. `fastapi[standard]` pulls a cloud CLI that
wants sentry-sdk 2.x while we pinned below it — no pip resolution
existed, so the pin is lifted, which the comment beside it had been
waiting for and which also lets the Python cap go. And `uv` is now a
dependency rather than something to find on PATH: the Modules screen is
how a data scientist installs torch, and it was quietly falling back to
the engine's own interpreter.

The CLI imports nothing from the engine before it has set DATA_DIR — the
settings are built on the first import of core.config, and reaching it
early put the database in the working directory. There is a test for
that now, because the failure is silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:51:13 +02:00
stroblmeandClaude Opus 5 961a8f881d Keep the engine's state in SQLite, not Postgres
One process owns this database — the image has run a single uvicorn
worker for that reason since the four-engines bug — so a file beside the
flows is the honest shape for it, and it is what lets `fluksio serve`
need no infrastructure at all. Live values, node execution and the work
queue never came here anyway; what does is a rollup a minute at a time,
a row per cascade and the run history, and WAL keeps the readers going
while that one writer works.

DATA_DIR is now the one setting that moves everything an installation
keeps; the rest derive from it and the images still spell theirs out.
The schema is prepared in-process at startup, so the prestart service is
gone, and the ten Postgres-only revisions collapse into one portable
baseline.

Three things only worked because psycopg was casting for us: a token's
subject arriving as a string where the column is a UUID, `greatest`, and
`date_bin`. The timestamps needed a column type of their own — SQLite
stores no offset, and a naive datetime read back either raises against an
aware `now` or serialises as local time.

Postgres stays in the stack only for Umami, behind the analytics profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:19:45 +02:00
stroblmeandClaude Opus 5 2c369ac75f Split the worker into a distribution of its own
A cluster or GPU host installs `pip install fluksio-worker` and gets the
agent and the runner, not psycopg, numpy and the MCP SDK. The engine
depends on it as a workspace member, so the file it launches node code
with is the same file a remote worker runs — which is what keeps a node
unable to tell the difference. Copying the two files by hand still works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:54:10 +02:00
stroblmeandClaude Opus 5 60d7ec81c0 Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a
user's venv, so the package that is about to be published takes the name
it is published under. Only the Python package moves; the repo, the
Docker WORKDIR and the compose project keep theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:48:05 +02:00
stroblmeandClaude Opus 5 c912388ed6 Make lint report instead of rewriting, and stop verify guessing the domain
`make lint-frontend` was `biome check --write --unsafe ./` — a lint target that
reformatted the whole tree rather than checking it, which is why every parallel
change in this repo has had to work around it. `lint` checks now and a new
`format` writes. The pre-commit hook and CI needed no edit at all: both call
`bun run lint`, so they became checks the moment its meaning changed.

`app/Makefile` assigned DOMAIN from .env, and a plain assignment beats an
inherited environment variable and is not exported — so `cd app && make
dev-local` served localhost while the same checkout's tests targeted the
configured domain. `export DOMAIN ?=` gives the lattice that was intended:
command line, then environment, then .env.

Alongside: the backend's htmlcov bind mount created that directory as root, so
`make test-backend` died on the coverage step after every test had passed,
which reads like a test failure and is not one. The alerts screen's copy of
ALERTING_EVENTS is now checked by a test rather than trusted. And the shard
comment claimed two spec files where there are nine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
2026-08-21 14:34:14 +02:00
stroblme 37f0c55a8d Seed the aircon write-path rig
Four controls and the unit's own answer beside them. Two catches, both on:
the flow is seeded stopped and the node's commands setting is off. The initial
values are read off the unit when the script runs, so starting the flow asks
for what it was already doing rather than commanding it to something else.
2026-08-20 22:43:03 +02:00
stroblme 906203da1e Let a connector write, and publish strings bare
Two things stopped the engine commanding this house. ConnectorNode hardwired
its node function to a no-op, so an input message reaching a connector was
discarded and Art-Net's packet builder was unreachable; write() now carries
the input ports, which is additive so the contract version holds. And the MQTT
publisher JSON-encoded every payload, so "ON" went on the wire quoted and the
devices on a shared broker, which speak bare values, ignored it.

seed_house_control.py is the rig: a flow that drives the washing machine plug,
a dimmer and a colour fixture over MQTT, carries the same two as DMX on an
Art-Net node with transmit still off, and a dashboard to drive it by hand.
2026-08-20 21:57:27 +02:00
stroblmeandClaude Opus 5 41b2c28b2b The e2e suite names its own origins, and refuses a live instance
`tests/utils/api.ts` took the API origin from `VITE_API_URL`, which
`tests/config.ts` loads out of `app/.env`. In a checkout configured for a
deployment that names the deployment — so the browser went to the local stack
while every setup and teardown call, `deleteAll` included, went to the live
one. `privateApi.ts` had the same reading, and it creates users.

Both origins now come from one place: `PLAYWRIGHT_BASE_URL`, with the API
derived from it (`app.<domain>` → `api.<domain>`) or named outright by
`PLAYWRIGHT_API_URL`, which is what CI and the compose service set. Nothing in
the suite reads `VITE_API_URL` any more.

Belt and braces, since a stack served under a real domain answers to the same
names its production instance does: a global setup resolves both origins and
refuses anything that is not loopback or a private range, before a test runs.
`PLAYWRIGHT_ALLOW_PUBLIC=1` says you meant it.

`make test-frontend` is now that safe run — the Playwright image on the proxy
network with both names mapped onto Traefik by address, as the host user so it
does not leave root-owned results behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
2026-08-20 20:46:04 +02:00
stroblmeandClaude Opus 5 8683ee71f3 Add the hosted-demo seed: a house panel over three flows
`scripts/seed_demo.py` wipes and recreates one persistent demo — `home`,
`home_history` and `pv_model`, plus a `demo` dashboard carrying all fifteen
widget types across three sections. Operational script for the hosted
instance only: `make seed-hosted-demo`, with `API_URL` selecting which one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
2026-08-20 12:15:18 +02:00
stroblmeandClaude Fable 5 eaabb405d9 An example to evaluate: a training run, its dashboard, and two bugs it found
make seed-demo builds demo_training — prepare on the engine, a GPU-bound
train, evaluate back here — and a panel that draws the loss curve while the
training is still going. It is the session's whole argument in one flow: batch
runs with parameters and a result, a generator yielding on a declared port
rather than logging, fluksio.emit from inside a callback, artifacts carrying
the dataset and the weights between machines, and a sweep whose configs are
isolated from each other. The train node prefers its label rather than
requiring it, so it runs before a GPU box exists and says which machine and
which numeric backend it actually used.

Building it turned up two real bugs. A run waited for a worker its flow only
*preferred*, because required_labels ignored device_policy — so the example
hung on a label it did not need. And a run's seed never reached the flow, so
sweeping over seeds ran the same experiment N times; it now fills an input of
that name when the flow declares one, which is what the field looked like it
did all along.

Pressing Run on a batch flow now submits a run rather than taking the old
non-durable path — that button is the first thing anyone evaluating will press,
and it was quietly doing something else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
2026-08-18 22:04:14 +02:00
stroblmeandClaude Fable 5 52d4de62c9 Benchmark the startup claim, and write down what a run is
The milestone is measured on being lighter than Kedro, so make bench-startup
measures it rather than asserting it: 61 ms from submit to result against
1110 ms for kedro run on a pipeline that does the same nothing. The difference
is not orchestration, it is that nothing is booted per run — on a 510-config
sweep that is about nine minutes of pure startup that never happens.

docs/flows/runs.md is the guide: batch flows, sweeps, reporting from inside a
node, artifacts, and the two sanctioned patterns for objects that cannot be
serialized — keep them in one node, or cross at a checkpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
2026-08-18 18:00:36 +02:00
Melvin Strobl aa40ccb58b analytics
Signed-off-by: Melvin Strobl <lc3267@kit.edu>
2026-08-18 13:17:07 +02:00
stroblmeandClaude Opus 5 1c09b8209d dashboard: pace a querying chart by its window, and an example to evaluate it
A chart is drawn in buckets, and nothing it can show changes until the
bucket it is drawing closes — so the resolution sets the refresh rather
than a flat five-second floor. A week at quarter-hour buckets now asks
four times an hour instead of sixty, for the same picture. Leaving the
field empty follows the window; a slower rate is still honoured.

`make seed-example` builds the thing to evaluate it with: a flow that
logs a temperature to InfluxDB, a flow that answers a chart's request by
turning the window into Flux and the rows back into a series, and a
dashboard holding the chart. The reading flow declares the request as an
input with a starting value, which is how a flow says a value reaches it
from a panel rather than from a node upstream.

Axis labels keep enough decimals to stay distinct — `si` rounds to three
figures, so every tick of a chart living inside one degree read "19".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:34:32 +02:00
stroblmeandClaude Fable 5 b33be3fdd4 Load the engine, then take its pieces away and check what survives
A standalone harness, never part of the test run, that drives a real stack
through the durable path — the webhook, which journals every trigger — and
then stops Redis, kills the engine mid-cascade and restarts the broker
under a live subscription. Latest-value-wins with concurrent cascades means
counter equality is not a promise, so what it asserts is that the queue
drains, that state ends on the last value sent, and that nothing reached
the dead-letter stream.

Every docker verb goes through one helper that checks the compose label
before it acts and refuses anything outside this project, because the
machines this runs on host unrelated services.

Two invariants are deliberately looser than they look. The queue belongs to
the whole stack, so "nothing pending" would be measuring other people's
traffic; the harness waits on the age of the oldest unacknowledged entry
instead, which a stuck item always dominates. And the observability tables
are cleared only after the collector's flush interval has passed, since
deleting a flow publishes an audit event of its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
2026-08-16 23:14:14 +02:00
stroblmeandClaude Fable 5 75f68655ef Point the backend suite at the db container while the stack is up
The integrated stack drops every published host port, so make test-backend
could not reach Postgres on localhost:5432 whenever the stack it shares a
machine with was running. It now asks Docker where the container is; with the
stack down, .env still stands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
2026-08-16 16:46:25 +02:00
rootandClaude Fable 5 5462842b8a Supervise the engine's host: deep health, loop watchdog, one worker
The API image ran four uvicorn workers, and each one built a full flow
controller — four sets of MQTT subscriptions, cron ticks and webhooks.
Runs one worker now; scaling out is the worker split, not more processes.

Adds a loop-lag watchdog and a deep /utils/health/ that fails when the
event loop is wedged or Redis is unreachable, the two failure modes a
process-alive check never sees. Autoheal restarts on that signal, behind
a compose profile because it mounts the Docker socket.

The private user-seeding routes now need an explicit opt-in rather than
just ENVIRONMENT=local, so a deployment that kept the default never
exposes them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 07:14:28 +02:00
Melvin StroblandClaude Opus 5 529b5f9ed5 Restructure docker into layered compose files, add a Makefile
Move compose.yml/compose.override.yml/compose.traefik.yml into docker/ and
split into the explicit prod -> dev -> local layering; compose.override.yml
had to be renamed because docker compose auto-loads that filename, which
defeats the layering.

- external network traefik-public -> proxy (shared with the website stack)
- frontend host dashboard.${DOMAIN} -> app.${DOMAIN}
- stable container_names, security_opt no-new-privileges on prod services
- adminer bound to 127.0.0.1 in dev instead of all interfaces
- .env.example replaces the committed .env
- pre-commit biome hook ran npm in a bun repo

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:21:34 +02:00