From cfceca5e5e4c32204489a164d116bc251218dcb5 Mon Sep 17 00:00:00 2001 From: lobo Date: Sun, 27 Sep 2026 10:27:01 +0200 Subject: [PATCH] feat(settings): show the build version of each service --- .gitea/workflows/docker-publish.yml | 4 ++ Dockerfile | 3 ++ README.md | 4 +- public/i18n/de.json | 4 +- public/i18n/en.json | 4 +- src/components/BuildVersionCard.jsx | 63 ++++++++++++++++++++++++ src/components/BuildVersionCard.test.jsx | 49 ++++++++++++++++++ src/pages/SettingsPage.jsx | 3 ++ src/utils/version.js | 38 ++++++++++++++ src/utils/version.test.js | 41 +++++++++++++++ vite.config.js | 3 ++ 11 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 src/components/BuildVersionCard.jsx create mode 100644 src/components/BuildVersionCard.test.jsx create mode 100644 src/utils/version.js create mode 100644 src/utils/version.test.js diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index f569cc8..d826b5a 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -56,6 +56,7 @@ jobs: BRANCH="$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]')" echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" # Convert repository owner to lowercase OWNER="${GITHUB_REPOSITORY_OWNER}" @@ -75,6 +76,9 @@ jobs: with: context: . push: true + build-args: | + GIT_SHA=${{ github.sha }} + BUILD_TIME=${{ steps.prep.outputs.build_time }} tags: | git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/tobeplayed-frontend:${{ steps.prep.outputs.branch }} git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/tobeplayed-frontend:${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }} diff --git a/Dockerfile b/Dockerfile index a110d40..c17e27a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,9 @@ WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . +# Passed by the CI workflow; vite.config.js puts them into the settings page +ARG GIT_SHA=dev +ARG BUILD_TIME= RUN npm run build # Serve the static files using a lightweight web server diff --git a/README.md b/README.md index 56bbd3a..c1e6c6c 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ To Be Played is split into three repositories: to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night; a repaired cover comes back as `imageUrl`. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout. +- **Build version** in the settings: commit and build time of Frontend, Auth and Core, each hash links to its + commit. CI passes them as Docker build args; a local build shows `dev`. - **Installable** as an app on phone and desktop (web app manifest, no service worker, so no offline mode). - **Session**: the tokens are HttpOnly cookies that no script can read. `apiFetch` renews an expired access token once and sends the request again; parallel requests and other tabs share one refresh. @@ -95,7 +97,7 @@ flowchart LR `X-TBP-Client` header. `apiFetch` answers a `401` with one `POST /api/auth/refresh` and sends the request again. Parallel requests and other tabs share this refresh (`navigator.locks`). When the second answer is still a `401`, the page calls `useSessionExpired`, which opens the login page. Public pages (landing page, public - profile) use plain `fetch`. + profile) and the public `GET /api/version` in the settings use plain `fetch`. - `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game detail page) to Core. - `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again. diff --git a/public/i18n/de.json b/public/i18n/de.json index adbe848..d37be52 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -104,7 +104,9 @@ "FAILED": "Steam konnte die Verknüpfung nicht bestätigen. Bitte versuch es noch einmal.", "ERROR": "Die Steam-Verknüpfung konnte nicht starten.", "UNLINKED": "Steam-Verknüpfung gelöst." - } + }, + "VERSION": "Version", + "VERSION_UNKNOWN": "unbekannt" }, "VALIDATION": { "EMAIL_REQUIRED": "Email ist erforderlich.", diff --git a/public/i18n/en.json b/public/i18n/en.json index 30b34aa..5f6041e 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -104,7 +104,9 @@ "FAILED": "Steam could not confirm the link. Please try again.", "ERROR": "Could not start the Steam link.", "UNLINKED": "Steam account unlinked." - } + }, + "VERSION": "Version", + "VERSION_UNKNOWN": "unknown" }, "VALIDATION": { "EMAIL_REQUIRED": "Email is required.", diff --git a/src/components/BuildVersionCard.jsx b/src/components/BuildVersionCard.jsx new file mode 100644 index 0000000..5f1a085 --- /dev/null +++ b/src/components/BuildVersionCard.jsx @@ -0,0 +1,63 @@ +import {useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {Info} from 'lucide-react'; + +import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import {commitUrl, fetchVersion, formatBuildTime, FRONTEND_VERSION, VERSION_SOURCES} from '../utils/version.js'; + +const SHORT_HASH = 7; + +function VersionValue({repo, version}) { + const {t, i18n} = useTranslation(); + if (version === undefined) return …; + if (version === null) return {t('SETTINGS.VERSION_UNKNOWN')}; + + const url = commitUrl(repo, version.commit); + const builtAt = formatBuildTime(version.builtAt, i18n.language); + return ( + + {url + ? {version.commit.slice(0, SHORT_HASH)} + : {version.commit}} + {builtAt && · {builtAt}} + + ); +} + +/** + * A backend version is undefined while loading and null when it is unknown. + */ +export default function BuildVersionCard() { + const {t} = useTranslation(); + const [versions, setVersions] = useState({Frontend: FRONTEND_VERSION}); + + useEffect(() => { + let cancelled = false; + VERSION_SOURCES.filter((source) => source.url).forEach(async (source) => { + const version = await fetchVersion(source.url()); + if (!cancelled) setVersions((current) => ({...current, [source.name]: version})); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( + + + {t('SETTINGS.VERSION')} + + +
    + {VERSION_SOURCES.map(({name, repo}) => ( +
  • + {name} + +
  • + ))} +
+
+
+ ); +} diff --git a/src/components/BuildVersionCard.test.jsx b/src/components/BuildVersionCard.test.jsx new file mode 100644 index 0000000..513becc --- /dev/null +++ b/src/components/BuildVersionCard.test.jsx @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render, screen, within} from '@testing-library/react'; +import BuildVersionCard from './BuildVersionCard.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +// vite.config.js reads GIT_SHA from the shell; the test must not depend on it. +vi.mock('../utils/version.js', async (importOriginal) => ({ + ...await importOriginal(), + FRONTEND_VERSION: {commit: 'dev', builtAt: null}, +})); + +const AUTH_SHA = '4ec47f1aa0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5'; + +const row = (name) => screen.getByText(name).closest('li'); + +describe('BuildVersionCard', () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('links the short hash of each backend and marks a service that does not answer', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => (url.startsWith('http://auth') + ? {ok: true, json: async () => ({commit: AUTH_SHA, builtAt: '2026-09-27T08:05:00Z'})} + : {ok: false, status: 502})); + vi.stubEnv('VITE_API_URL', 'http://auth'); + vi.stubEnv('VITE_CORE_API_URL', 'http://core'); + + render(); + + const link = await within(row('Auth')).findByRole('link', {name: '4ec47f1'}); + expect(link.getAttribute('href')).toBe(`https://git.andreas-dahm.eu/Lobo_Corp/ToBePlayed-Auth/commit/${AUTH_SHA}`); + expect(await within(row('Core')).findByText('SETTINGS.VERSION_UNKNOWN')).toBeTruthy(); + expect(fetch).toHaveBeenCalledWith('http://auth/api/version'); + expect(fetch).toHaveBeenCalledWith('http://core/api/version'); + }); + + it('shows a local build as dev without a link', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ok: true, json: async () => ({commit: 'dev', builtAt: null})}); + + render(); + + expect(within(row('Frontend')).getByText('dev')).toBeTruthy(); + expect(await within(row('Core')).findByText('dev')).toBeTruthy(); + expect(screen.queryByRole('link')).toBeNull(); + }); +}); diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index dec56bf..771fdc3 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -14,6 +14,7 @@ import {Label} from '@/components/ui/label'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Skeleton} from '@/components/ui/skeleton'; import {Switch} from '@/components/ui/switch'; +import BuildVersionCard from '../components/BuildVersionCard.jsx'; import FormField from '../components/FormField.jsx'; import SteamAccountCard from '../components/SteamAccountCard.jsx'; import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx'; @@ -463,6 +464,8 @@ export default function SettingsPage() { + + ); } diff --git a/src/utils/version.js b/src/utils/version.js new file mode 100644 index 0000000..b64fed1 --- /dev/null +++ b/src/utils/version.js @@ -0,0 +1,38 @@ +const REPO_URL = 'https://git.andreas-dahm.eu/Lobo_Corp'; + +// Set by vite.config.js from the Docker build args; "dev" and "" outside the CI build. +export const FRONTEND_VERSION = {commit: __BUILD_COMMIT__, builtAt: __BUILD_TIME__ || null}; + +export const VERSION_SOURCES = [ + {name: 'Frontend', repo: 'ToBePlayed-Frontend'}, + {name: 'Auth', repo: 'ToBePlayed-Auth', url: () => `${import.meta.env.VITE_API_URL}/api/version`}, + {name: 'Core', repo: 'ToBePlayed-Core', url: () => `${import.meta.env.VITE_CORE_API_URL}/api/version`}, +]; + +/** + * GET /api/version of one backend (public). Resolves to null when the service does not answer or runs a build + * without this endpoint. + */ +export async function fetchVersion(url) { + try { + const response = await fetch(url); + if (!response.ok) return null; + const version = await response.json(); + return typeof version?.commit === 'string' ? version : null; + } catch { + return null; + } +} + +/** + * The build time without seconds, e.g. "27.09.2026, 10:05"; null when missing or not a date. + */ +export const formatBuildTime = (builtAt, locale) => { + const date = new Date(builtAt ?? ''); + return Number.isNaN(date.getTime()) ? null + : date.toLocaleString(locale, {dateStyle: 'medium', timeStyle: 'short'}); +}; + +const GIT_HASH = /^[0-9a-f]{7,40}$/; + +export const commitUrl = (repo, commit) => (GIT_HASH.test(commit ?? '') ? `${REPO_URL}/${repo}/commit/${commit}` : null); diff --git a/src/utils/version.test.js b/src/utils/version.test.js new file mode 100644 index 0000000..51a1589 --- /dev/null +++ b/src/utils/version.test.js @@ -0,0 +1,41 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {commitUrl, fetchVersion, formatBuildTime} from './version.js'; + +describe('fetchVersion', () => { + afterEach(() => vi.restoreAllMocks()); + + it.each([ + ['an error status', {ok: false, status: 401}], + ['a body without a commit', {ok: true, json: async () => ({status: 'ok'})}], + ])('is null for %s', async (_, response) => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(response); + + expect(await fetchVersion('http://core/api/version')).toBeNull(); + }); + + it('is null when the service does not answer', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch')); + + expect(await fetchVersion('http://core/api/version')).toBeNull(); + }); +}); + +describe('formatBuildTime', () => { + it('shows minutes, not seconds', () => { + const text = formatBuildTime('2026-09-27T08:05:42Z', 'en'); + + expect(text).toMatch(/2026/); + expect(text).not.toMatch(/42/); + }); + + it.each([null, '', 'yesterday'])('is null for %s', (value) => { + expect(formatBuildTime(value, 'en')).toBeNull(); + }); +}); + +describe('commitUrl', () => { + it('links only a git hash', () => { + expect(commitUrl('ToBePlayed-Core', 'abc1234')).toBe('https://git.andreas-dahm.eu/Lobo_Corp/ToBePlayed-Core/commit/abc1234'); + expect(commitUrl('ToBePlayed-Core', 'dev')).toBeNull(); + }); +}); diff --git a/vite.config.js b/vite.config.js index f7e5743..7d15d4d 100644 --- a/vite.config.js +++ b/vite.config.js @@ -23,6 +23,9 @@ export default defineConfig({ ], define: { __TRANSLATIONS_VERSION__: JSON.stringify(translationsVersion()), + // Docker build args from the CI workflow, see Dockerfile + __BUILD_COMMIT__: JSON.stringify(process.env.GIT_SHA || 'dev'), + __BUILD_TIME__: JSON.stringify(process.env.BUILD_TIME || ''), }, resolve: { alias: {