One image for the whole app: engine, API and dashboard on one port

The backend image now builds the SPA and serves it on the API's own origin
(fluksio/spa.py), so an instance is a single container with no proxy, DNS,
port 80 or .env: `docker compose up -d` with the new compose.yml runs it on
localhost:8080. It starts on fluksio serve (persisted secret key, first
admin printed once) and brings its own Redis when REDIS_HOST is unset.

Slimmer: python:3.13-slim, no dev group, uv once, fastapi without the cloud
CLI. Optional features are per-feature extras (mqtt, influx, email, sentry,
ml, server for all) picked with the EXTRAS build argument; numpy moved to
`ml`. ffmpeg left the image for imageio-ffmpeg via the Modules screen.

The operator stack runs the same image with app. and api. routed to it; the
nginx frontend service, the LAN nginx proxy and DOMAIN in the backend are
gone, and the MCP issuer is FRONTEND_HOST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 11:40:02 +02:00
co-authored by Claude Opus 5.5
parent 29aca91978
commit 48aac3855b
39 changed files with 573 additions and 728 deletions
+13 -21
View File
@@ -124,27 +124,19 @@ async function captureRuns(page, dir) {
await page.waitForTimeout(1500)
await page.screenshot({ path: `${dir}/app-runs-compare.png`, fullPage: true })
// The API is its own host here, unless the stack serves it under the SPA's
// own origin (a LAN stack), whose CSP then refuses the other host.
const apiUrl = APP_URL.replace("//app.", "//api.")
const results = await page.evaluate(
async (bases) => {
for (const base of bases) {
const answer = await fetch(`${base}/api/v1/dashboards/`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
},
}).catch(() => null)
if (!answer?.ok) continue
const body = await answer.json()
return (body.data || [])
.map((one) => one.name)
.find((n) => n.endsWith("_results"))
}
return null
},
[apiUrl, ""],
)
// The API is a path on the SPA's own origin.
const results = await page.evaluate(async () => {
const answer = await fetch("/api/v1/dashboards/", {
headers: {
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
},
}).catch(() => null)
if (!answer?.ok) return null
const body = await answer.json()
return (body.data || [])
.map((one) => one.name)
.find((n) => n.endsWith("_results"))
})
// A dashboard read against two runs: the same page a live run is watched
// on, showing finished ones. This is the seam the feature exists for — and