Public Access
feat(settings): show the build version of each service
This commit is contained in:
1 parent
9d1c7bb40d
commit
cfceca5e5e
11 files changed
+213
-3
No files matched your search
@@ -56,6 +56,7 @@ jobs:
|
|||||||
BRANCH="$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]')"
|
BRANCH="$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]')"
|
||||||
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||||
echo "sha=${GITHUB_SHA::12}" >> "$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
|
# Convert repository owner to lowercase
|
||||||
OWNER="${GITHUB_REPOSITORY_OWNER}"
|
OWNER="${GITHUB_REPOSITORY_OWNER}"
|
||||||
@@ -75,6 +76,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
GIT_SHA=${{ github.sha }}
|
||||||
|
BUILD_TIME=${{ steps.prep.outputs.build_time }}
|
||||||
tags: |
|
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 }}
|
||||||
git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/tobeplayed-frontend:${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }}
|
git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/tobeplayed-frontend:${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ WORKDIR /app
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
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
|
RUN npm run build
|
||||||
|
|
||||||
# Serve the static files using a lightweight web server
|
# Serve the static files using a lightweight web server
|
||||||
|
|||||||
@@ -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;
|
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`.
|
a repaired cover comes back as `imageUrl`.
|
||||||
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
|
- 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).
|
- **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
|
- **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.
|
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.
|
`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
|
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
|
`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
|
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
|
||||||
detail page) to Core.
|
detail page) to Core.
|
||||||
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
||||||
|
|||||||
+3
-1
@@ -104,7 +104,9 @@
|
|||||||
"FAILED": "Steam konnte die Verknüpfung nicht bestätigen. Bitte versuch es noch einmal.",
|
"FAILED": "Steam konnte die Verknüpfung nicht bestätigen. Bitte versuch es noch einmal.",
|
||||||
"ERROR": "Die Steam-Verknüpfung konnte nicht starten.",
|
"ERROR": "Die Steam-Verknüpfung konnte nicht starten.",
|
||||||
"UNLINKED": "Steam-Verknüpfung gelöst."
|
"UNLINKED": "Steam-Verknüpfung gelöst."
|
||||||
}
|
},
|
||||||
|
"VERSION": "Version",
|
||||||
|
"VERSION_UNKNOWN": "unbekannt"
|
||||||
},
|
},
|
||||||
"VALIDATION": {
|
"VALIDATION": {
|
||||||
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
||||||
|
|||||||
+3
-1
@@ -104,7 +104,9 @@
|
|||||||
"FAILED": "Steam could not confirm the link. Please try again.",
|
"FAILED": "Steam could not confirm the link. Please try again.",
|
||||||
"ERROR": "Could not start the Steam link.",
|
"ERROR": "Could not start the Steam link.",
|
||||||
"UNLINKED": "Steam account unlinked."
|
"UNLINKED": "Steam account unlinked."
|
||||||
}
|
},
|
||||||
|
"VERSION": "Version",
|
||||||
|
"VERSION_UNKNOWN": "unknown"
|
||||||
},
|
},
|
||||||
"VALIDATION": {
|
"VALIDATION": {
|
||||||
"EMAIL_REQUIRED": "Email is required.",
|
"EMAIL_REQUIRED": "Email is required.",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import {Label} from '@/components/ui/label';
|
|||||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||||
import {Skeleton} from '@/components/ui/skeleton';
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
import {Switch} from '@/components/ui/switch';
|
import {Switch} from '@/components/ui/switch';
|
||||||
|
import BuildVersionCard from '../components/BuildVersionCard.jsx';
|
||||||
import FormField from '../components/FormField.jsx';
|
import FormField from '../components/FormField.jsx';
|
||||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||||
import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx';
|
import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx';
|
||||||
@@ -463,6 +464,8 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<BuildVersionCard/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,9 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
define: {
|
define: {
|
||||||
__TRANSLATIONS_VERSION__: JSON.stringify(translationsVersion()),
|
__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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in new issue
Block a user