feat(settings): show the build version of each service
Build, Test & Push Frontend / quality-check (push) Successful in 15s
Build, Test & Push Frontend / docker (push) Successful in 27s

This commit is contained in:
lobo committed 2026-09-27 10:27:01 +02:00
1 parent 9d1c7bb40d
commit cfceca5e5e
11 files changed
+213 -3

No files matched your search

+63
View File
@@ -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 <span className="text-muted-foreground">…</span>;
if (version === null) return <span className="text-muted-foreground">{t('SETTINGS.VERSION_UNKNOWN')}</span>;
const url = commitUrl(repo, version.commit);
const builtAt = formatBuildTime(version.builtAt, i18n.language);
return (
<span className="flex min-w-0 items-center gap-2">
{url
? <a href={url} target="_blank" rel="noopener noreferrer"
className="font-mono text-primary hover:underline">{version.commit.slice(0, SHORT_HASH)}</a>
: <span className="font-mono">{version.commit}</span>}
{builtAt && <span className="truncate text-muted-foreground">· {builtAt}</span>}
</span>
);
}
/**
* 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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Info className="size-4 text-primary"/>{t('SETTINGS.VERSION')}</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{VERSION_SOURCES.map(({name, repo}) => (
<li key={name} className="flex items-center justify-between gap-4">
<span>{name}</span>
<VersionValue repo={repo} version={versions[name]}/>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
+49
View File
@@ -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(<BuildVersionCard/>);
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(<BuildVersionCard/>);
expect(within(row('Frontend')).getByText('dev')).toBeTruthy();
expect(await within(row('Core')).findByText('dev')).toBeTruthy();
expect(screen.queryByRole('link')).toBeNull();
});
});
+3
View File
@@ -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() {
</div>
</CardContent>
</Card>
<BuildVersionCard/>
</div>
);
}
+38
View File
@@ -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);
+41
View File
@@ -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();
});
});