From 642c5f4f70e091f3a445b72f0e89cac45693286e Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 30 Aug 2026 19:01:15 +0200 Subject: [PATCH] feat(setup): show running build version in the Setup tab bar CI already computes - for image tags but never surfaced it anywhere reachable from the app, so there was no way to tell what's actually deployed on a booth without cross-referencing komodo/resources.toml's TAG by hand. Thread it through: CI passes BUILD_VERSION as a Docker build-arg, the Dockerfile captures it as a runtime env var, GET /api/version (gated by the existing site:read permission) exposes it, and the Setup page's tab bar shows it right-aligned, muted, absent entirely on a local/dev build with no CI-supplied value. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU --- .gitea/workflows/build-images.yml | 2 ++ apps/server/Dockerfile | 5 +++++ apps/server/src/routes/routes.test.ts | 31 +++++++++++++++++++++++++++ apps/server/src/routes/site.ts | 9 ++++++++ apps/web/src/api.ts | 9 ++++++++ apps/web/src/router.tsx | 13 +++++++++++ 6 files changed, 69 insertions(+) diff --git a/.gitea/workflows/build-images.yml b/.gitea/workflows/build-images.yml index 75adfd1..424dcff 100644 --- a/.gitea/workflows/build-images.yml +++ b/.gitea/workflows/build-images.yml @@ -92,6 +92,8 @@ jobs: context: . file: apps/server/Dockerfile push: true + build-args: | + BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }} tags: | ${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }} ${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }} diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index b570014..c4c9869 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -47,6 +47,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ # ---- runtime: slim, non-root ---- FROM node:22-alpine AS runtime WORKDIR /app +# Set by CI to "-" (e.g. "stage-28bd838"), matching the same string used +# as the Komodo Stack's TAG (komodo/resources.toml) — so the version shown in the app is the +# same string an admin would look up there. Empty/absent on a local `docker build` (dev only). +ARG BUILD_VERSION="" +ENV BUILD_VERSION=$BUILD_VERSION ENV NODE_ENV=production RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime RUN addgroup -S app && adduser -S -G app app diff --git a/apps/server/src/routes/routes.test.ts b/apps/server/src/routes/routes.test.ts index 70f5a53..0793f3d 100644 --- a/apps/server/src/routes/routes.test.ts +++ b/apps/server/src/routes/routes.test.ts @@ -56,6 +56,37 @@ describe("auth guard — no token", () => { }); }); +describe("GET /api/version", () => { + it("without a session is 401", async () => { + const res = await app.inject({ method: "GET", url: "/api/version" }); + expect(res.statusCode).toBe(401); + }); + + it("a site:read user gets the BUILD_VERSION env var, null when unset", async () => { + const { username, password } = await seedUser(db, { + username: "viewer2", roleId: "viewer2", permissions: ["site:read"], + }); + const { cookie } = await login(app, username, password); + const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ buildVersion: null }); // no BUILD_VERSION set in the test env + }); + + it("reflects a real BUILD_VERSION when the env var is set", async () => { + process.env.BUILD_VERSION = "stage-abc1234"; + try { + const { username, password } = await seedUser(db, { + username: "viewer3", roleId: "viewer3", permissions: ["site:read"], + }); + const { cookie } = await login(app, username, password); + const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } }); + expect(res.json()).toEqual({ buildVersion: "stage-abc1234" }); + } finally { + delete process.env.BUILD_VERSION; + } + }); +}); + describe("RBAC permission gate", () => { it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => { const { username, password } = await seedUser(db, { diff --git a/apps/server/src/routes/site.ts b/apps/server/src/routes/site.ts index 0e46452..43b804e 100644 --- a/apps/server/src/routes/site.ts +++ b/apps/server/src/routes/site.ts @@ -78,6 +78,15 @@ export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventL // Live occupancy: cars inside, capacity, free, full. Any signed-in role. app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db)); + // Running build version ("-", matching the Komodo Stack's TAG in + // komodo/resources.toml) — baked in at image build time (apps/server/Dockerfile + // BUILD_VERSION ARG), read here from the running process env. null on a local/dev + // build with no CI-supplied value. Purely informational (Setup nav display); not + // site config, so it isn't stored in site_config. + app.get("/api/version", { preHandler: readGuard }, async () => ({ + buildVersion: process.env.BUILD_VERSION?.trim() || null, + })); + // Read site config (capacity + park metadata). app.get("/api/site-config", { preHandler: readGuard }, async () => { const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 3257473..eb5ecfb 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -253,6 +253,15 @@ export async function fetchBackupStatus(): Promise { return apiFetch("/api/backup/status"); } +export interface VersionInfo { + /** "-" baked in at image build time; null on a local/dev build. */ + buildVersion: string | null; +} + +export async function fetchVersion(): Promise { + return apiFetch("/api/version"); +} + export interface BackupConfigPatch { /** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */ targetDir?: string | null; diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 0c99fa4..1a87f7f 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -14,6 +14,7 @@ import { can, closeShift, fetchShiftReport, + fetchVersion, logout, openShift, setLanguagePref, @@ -94,6 +95,17 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa ); } +/** The running deploy's "-" (matches the Komodo Stack's TAG in + * komodo/resources.toml), gated the same as the "Park" tab (site:read) since it's the + * same kind of read-only app metadata. Renders nothing if the value isn't known (e.g. a + * local/dev build with no CI-supplied BUILD_VERSION) rather than showing an empty badge. */ +function VersionBadge() { + const q = useQuery({ queryKey: ["version"], queryFn: fetchVersion, staleTime: Infinity }); + const version = q.data?.buildVersion; + if (!version) return null; + return {version}; +} + /** Setup layout — the config hub. Renders a permission-gated tab bar and the active * tab's screen via . Each tab is a child route (its own URL + guard), so * deep links and the back button work and a denied tab redirects to the booth. */ @@ -112,6 +124,7 @@ function SetupLayout() { {show("recyclebin:read") && } {show("log:read") && } {show("backup:read") && } + {show("site:read") && }