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.
This commit is contained in:
@@ -30,7 +30,7 @@ export default defineConfig({
|
||||
/* Base URL to use in actions like `await page.goto('/')`. Defaults to the
|
||||
suite's own stack (`make test-stack`), never the dev instance's data.
|
||||
Point PLAYWRIGHT_BASE_URL elsewhere to test another running server; the
|
||||
API origin follows it (see tests/config.ts). */
|
||||
API is on the same origin (see tests/config.ts). */
|
||||
baseURL: appUrl,
|
||||
|
||||
/* Chromium pins *.localhost to loopback whatever /etc/hosts says, so a
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,17 +32,5 @@ export const firstSuperuserPassword = getEnvVar("FIRST_SUPERUSER_PASSWORD")
|
||||
export const appUrl =
|
||||
process.env.PLAYWRIGHT_BASE_URL || "http://app.test.localhost"
|
||||
|
||||
export const apiUrl = process.env.PLAYWRIGHT_API_URL || apiOrigin(appUrl)
|
||||
|
||||
/** `app.<domain>` serves the SPA, `api.<domain>` serves its API. */
|
||||
function apiOrigin(app: string): string {
|
||||
const url = new URL(app)
|
||||
if (!url.hostname.startsWith("app.")) {
|
||||
throw new Error(
|
||||
`Cannot derive the API origin from PLAYWRIGHT_BASE_URL=${app}. ` +
|
||||
"Set PLAYWRIGHT_API_URL to name it.",
|
||||
)
|
||||
}
|
||||
url.hostname = `api.${url.hostname.slice("app.".length)}`
|
||||
return url.origin
|
||||
}
|
||||
/** The API is a path on the origin that serves the dashboard. */
|
||||
export const apiUrl = process.env.PLAYWRIGHT_API_URL || new URL(appUrl).origin
|
||||
|
||||
Reference in New Issue
Block a user