diff --git a/.github/workflows/release-reusable.yaml b/.github/workflows/release-reusable.yaml index 723e47c..8d8859d 100644 --- a/.github/workflows/release-reusable.yaml +++ b/.github/workflows/release-reusable.yaml @@ -50,6 +50,7 @@ jobs: with: ref: ${{ inputs.checkout-ref }} submodules: 'true' + fetch-depth: 0 - name: login to github if: inputs.publish @@ -75,6 +76,15 @@ jobs: env: BUILDX_BUILDER: ${{ steps.buildx.outputs.name }} + - name: Resolve frontend build metadata + id: build_metadata + env: + REPOSITORY_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + echo "commit=$(git rev-parse HEAD | cut -c1-12)" >> "$GITHUB_OUTPUT" + echo "branch=$(scripts/ci/resolve-build-branch.sh)" >> "$GITHUB_OUTPUT" + echo "time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + - name: Build and publish Docker image uses: docker/build-push-action@v7 with: @@ -83,6 +93,10 @@ jobs: platforms: linux/amd64,linux/arm64 push: ${{ inputs.publish }} tags: ${{ env.IMAGE_NAME }}:${{ inputs.release-tag }} + build-args: | + VITE_BUILD_COMMIT=${{ steps.build_metadata.outputs.commit }} + VITE_BUILD_BRANCH=${{ steps.build_metadata.outputs.branch }} + VITE_BUILD_TIME=${{ steps.build_metadata.outputs.time }} release_security: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index 04e4a88..b5377e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,13 @@ RUN corepack enable pnpm WORKDIR /app +ARG VITE_BUILD_COMMIT +ARG VITE_BUILD_BRANCH +ARG VITE_BUILD_TIME +ENV VITE_BUILD_COMMIT=$VITE_BUILD_COMMIT +ENV VITE_BUILD_BRANCH=$VITE_BUILD_BRANCH +ENV VITE_BUILD_TIME=$VITE_BUILD_TIME + # Cache dependencies COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile diff --git a/README.md b/README.md index 6175f00..1bb459a 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,29 @@ pnpm dev pnpm dev --mode live ``` +### Build metadata + +The application footer displays the UI build version and 12-character commit alongside the metadata returned by `GET /system/status`. The System Status page shows version, commit, branch, and build time for both ZenBPM and the UI. Frontend metadata is embedded when Vite starts or builds; the browser never invokes Git. + +The UI version comes from `info.version` in `openapi/api.yaml`. By default, Vite reads the commit and branch from Git, shortens the commit to 12 characters, and records the current UTC time. Builds outside a Git checkout can supply the metadata explicitly: + +```bash +VITE_BUILD_COMMIT=7af392e12345 \ +VITE_BUILD_BRANCH=main \ +VITE_BUILD_TIME=2026-08-10T08:00:00Z \ +pnpm build +``` + +Docker accepts the same values as build arguments. The release workflow resolves and supplies all three automatically: + +```bash +docker build \ + --build-arg VITE_BUILD_COMMIT=7af392e12345 \ + --build-arg VITE_BUILD_BRANCH=main \ + --build-arg VITE_BUILD_TIME=2026-08-10T08:00:00Z \ + -t zenbpm-ui . +``` + ## Quality Checks Before submitting a PR, ensure all quality checks pass: diff --git a/e2e/core/build-metadata-footer.spec.ts b/e2e/core/build-metadata-footer.spec.ts new file mode 100644 index 0000000..0942337 --- /dev/null +++ b/e2e/core/build-metadata-footer.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; + +const frontendMetadata = '1.5.0 (abcdef012345)'; +const backendMetadata = 'v1.5.0 (abcdef012345)'; + +test.describe('Build metadata footer', () => { + test('displays embedded frontend metadata', async ({ page }) => { + await page.goto('/'); + + const footer = page.getByTestId('build-metadata-footer'); + await expect(footer).toContainText(`UI: ${frontendMetadata}`); + await expect(footer).toContainText(`ZenBPM: ${backendMetadata}`); + await expect(footer.locator('p').first()).toHaveClass(/MuiTypography-captionNormal/); + await expect(footer.locator('p').first()).toHaveCSS('text-transform', 'none'); + await expect(footer.locator('p').last()).toHaveClass(/MuiTypography-captionNormal/); + await expect(footer.locator('p').last()).toHaveCSS('text-transform', 'none'); + + const footerText = await footer.textContent(); + expect(footerText?.indexOf('ZenBPM')).toBeLessThan(footerText?.indexOf('UI')); + }); + + test('fetches backend metadata from system status without calling the removed info endpoint', async ({ page }) => { + const requestedSystemEndpoints: string[] = []; + page.on('request', (request) => { + const pathname = new URL(request.url()).pathname; + if (pathname.startsWith('/system/')) { + requestedSystemEndpoints.push(pathname); + } + }); + + await page.goto('/'); + + await expect(page.getByTestId('build-metadata-footer')).toContainText(`ZenBPM: ${backendMetadata}`); + expect(requestedSystemEndpoints).toContain('/system/status'); + expect(requestedSystemEndpoints).not.toContain('/system/info'); + }); + + test('shows a loading backend state while the status request is pending', async ({ page }) => { + await page.goto('/?systemStatusScenario=loading'); + + await expect(page.getByTestId('build-metadata-footer')).toContainText('ZenBPM: loading'); + }); + + test('shows an unavailable backend state when the status request fails', async ({ page }) => { + await page.goto('/?systemStatusScenario=error'); + + await expect(page.getByTestId('build-metadata-footer')).toContainText('ZenBPM: unavailable'); + }); + + test('hides the status indicator when frontend and backend metadata match', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByTestId('build-metadata-status')).not.toBeVisible(); + }); + + test('marks differing metadata, including a plus suffix, as mismatching', async ({ page }) => { + await page.goto('/?systemStatusScenario=mismatch'); + + const status = page.getByTestId('build-metadata-status'); + await expect(status).toHaveText('Build metadata mismatch'); + await expect(status.locator('.MuiTypography-root')).toHaveClass(/MuiTypography-captionNormal/); + await expect(status.locator('.MuiTypography-root')).toHaveCSS('text-transform', 'none'); + await expect(page.getByTestId('build-metadata-footer')).toContainText('ZenBPM: v1.5.0+ (abcdef012345)'); + }); + + test('treats a backend release candidate as matching the same UI version', async ({ page }) => { + await page.goto('/?systemStatusScenario=release-candidate'); + + await expect(page.getByTestId('build-metadata-footer')).toContainText('ZenBPM: v1.5.0-rc1 (abcdef012345)'); + await expect(page.getByTestId('build-metadata-status')).not.toBeVisible(); + }); + + test('treats differing frontend and backend commits as matching when versions match', async ({ page }) => { + await page.goto('/?systemStatusScenario=commit-difference'); + + await expect(page.getByTestId('build-metadata-footer')).toContainText('ZenBPM: v1.5.0 (123456789abc)'); + await expect(page.getByTestId('build-metadata-status')).not.toBeVisible(); + }); +}); diff --git a/e2e/core/system-status.spec.ts b/e2e/core/system-status.spec.ts new file mode 100644 index 0000000..f59f5f7 --- /dev/null +++ b/e2e/core/system-status.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; + +test.describe('System status', () => { + test('displays backend and frontend build information', async ({ page }) => { + await page.goto('/'); + await page.getByRole('link', { name: 'System Status' }).click(); + await expect(page).toHaveURL(/\/system-status$/); + + const buildInformation = page.getByTestId('system-build-information'); + const backend = buildInformation.getByTestId('backend-build-information'); + const frontend = buildInformation.getByTestId('frontend-build-information'); + + await expect(buildInformation).toContainText('Build Information'); + + const sectionOrder = await page + .locator('[data-testid="system-cluster-topology"], [data-testid="system-build-information"]') + .evaluateAll((sections) => sections.map((section) => section.getAttribute('data-testid'))); + expect(sectionOrder).toEqual(['system-cluster-topology', 'system-build-information']); + await expect(page.getByTestId('system-status-page').locator(':scope > :last-child')) + .toHaveAttribute('data-testid', 'system-build-information'); + await expect(frontend).toHaveCSS('border-left-color', 'rgb(240, 240, 240)'); + await page.setViewportSize({ width: 600, height: 900 }); + await expect(frontend).toHaveCSS('border-top-color', 'rgb(240, 240, 240)'); + + await expect(backend).toContainText('ZenBPM'); + await expect(backend).toContainText(/Version\s*v1\.5\.0/); + await expect(backend).toContainText(/Build Time\s*2026-08-10T07:33:20Z/); + await expect(backend).toContainText(/Branch\s*main/); + await expect(backend).toContainText(/Commit ID\s*abcdef012345/); + + await expect(frontend).toContainText('UI'); + await expect(frontend).toContainText(/Version\s*1\.5\.0/); + await expect(frontend).toContainText(/Build Time\s*2026-08-10T08:00:00Z/); + await expect(frontend).toContainText(/Branch\s*feat\/system-status/); + await expect(frontend).toContainText(/Commit ID\s*abcdef012345/); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 4218a3e..5b2f56e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,9 +19,15 @@ export default defineConfig({ }, ], webServer: { - command: 'pnpm dev --mode mocks', + command: 'pnpm dev --mode mocks --port 3000 --strictPort', + env: { + VITE_BUILD_COMMIT: 'abcdef0123456789abcdef0123456789abcdef01', + VITE_BUILD_BRANCH: 'feat/system-status', + VITE_BUILD_TIME: '2026-08-10T08:00:00Z', + VITE_E2E_TEST: 'true', + }, url: 'http://localhost:3000', - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, timeout: 120 * 1000, }, }); diff --git a/scripts/ci/resolve-build-branch.sh b/scripts/ci/resolve-build-branch.sh new file mode 100755 index 0000000..4c4d390 --- /dev/null +++ b/scripts/ci/resolve-build-branch.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)} + +current_branch=$(git -C "$repo_root" branch --show-current) +if [ -n "$current_branch" ]; then + printf '%s\n' "$current_branch" + exit 0 +fi + +exact_tag=$(git -C "$repo_root" describe --tags --exact-match HEAD 2>/dev/null || true) +if [ -n "$exact_tag" ]; then + release_branch="release/${exact_tag#v}" + if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/$release_branch" && + git -C "$repo_root" merge-base --is-ancestor HEAD "refs/remotes/origin/$release_branch"; then + printf '%s\n' "$release_branch" + exit 0 + fi +fi + +default_branch=${REPOSITORY_DEFAULT_BRANCH:-} +if [ -z "$default_branch" ]; then + origin_head=$(git -C "$repo_root" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) + default_branch=${origin_head#origin/} +fi +if [ -n "$default_branch" ] && + git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/$default_branch" && + git -C "$repo_root" merge-base --is-ancestor HEAD "refs/remotes/origin/$default_branch"; then + printf '%s\n' "$default_branch" + exit 0 +fi + +head_commit=$(git -C "$repo_root" rev-parse HEAD) +echo "Unable to determine build branch for commit $head_commit: neither a matching release branch nor the repository default branch contains it" >&2 +exit 1 diff --git a/src/base/buildMetadata.ts b/src/base/buildMetadata.ts new file mode 100644 index 0000000..1217194 --- /dev/null +++ b/src/base/buildMetadata.ts @@ -0,0 +1,32 @@ +export interface BuildMetadata { + git: { + branch: string; + commitId: string; + }; + build: { + version: string; + time: string; + }; +} + +export const frontendBuildMetadata: BuildMetadata = { + git: { + branch: __BUILD_BRANCH__, + commitId: __BUILD_COMMIT__, + }, + build: { + version: __BUILD_VERSION__, + time: __BUILD_TIME__, + }, +}; + +const normalizeVersion = (version: string): string => + version + .replace(/^v(?=\d)/i, '') + .replace(/-rc\d+(?=\+?$)/i, ''); + +export const isBuildMetadataMatch = ( + frontend: BuildMetadata, + backend: BuildMetadata +): boolean => + normalizeVersion(frontend.build.version) === normalizeVersion(backend.build.version) diff --git a/src/base/i18n/locales/en/common.json b/src/base/i18n/locales/en/common.json index a1c8dff..8791583 100644 --- a/src/base/i18n/locales/en/common.json +++ b/src/base/i18n/locales/en/common.json @@ -13,11 +13,23 @@ "instances": "Instances" } }, + "buildMetadata": { + "ui": "UI", + "zenbpm": "ZenBPM", + "loading": "loading", + "unavailable": "unavailable", + "mismatch": "Build metadata mismatch" + }, "systemStatus": { "title": "System Status", "description": "Live overview of the ZenBPM engine cluster — nodes, partitions and configuration.", "link": "View system status", "lastUpdated": "Updated at {{time}}", + "buildInformation": "Build Information", + "version": "Version", + "buildTime": "Build Time", + "branch": "Branch", + "commitId": "Commit ID", "topology": "Cluster Topology", "clusterConfig": "Cluster Configuration", "nodes": "Nodes", diff --git a/src/base/theme/index.ts b/src/base/theme/index.ts index 49a119d..c40d0be 100644 --- a/src/base/theme/index.ts +++ b/src/base/theme/index.ts @@ -1,5 +1,22 @@ +import type { CSSProperties } from 'react'; import { createTheme, alpha } from '@mui/material/styles'; +declare module '@mui/material/styles' { + interface TypographyVariants { + captionNormal: CSSProperties; + } + + interface TypographyVariantsOptions { + captionNormal?: CSSProperties; + } +} + +declare module '@mui/material/Typography' { + interface TypographyPropsVariantOverrides { + captionNormal: true; + } +} + // Design 3 - "Clean White" color palette const colors = { // Primary green accent (from 4bpm.eu) @@ -270,6 +287,13 @@ export const theme = createTheme({ textTransform: 'uppercase', letterSpacing: '0.5px', }, + captionNormal: { + fontSize: '0.6875rem', + fontWeight: 600, + color: colors.textMuted, + textTransform: 'none', + letterSpacing: '0.5px', + }, button: { fontWeight: 500, fontSize: '0.875rem', diff --git a/src/components/BuildMetadataFooter/BuildMetadataFooter.stories.tsx b/src/components/BuildMetadataFooter/BuildMetadataFooter.stories.tsx new file mode 100644 index 0000000..b144d06 --- /dev/null +++ b/src/components/BuildMetadataFooter/BuildMetadataFooter.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { frontendBuildMetadata } from '@base/buildMetadata'; +import { BuildMetadataFooter } from './BuildMetadataFooter'; + +const meta: Meta = { + title: 'Components/BuildMetadataFooter', + component: BuildMetadataFooter, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); +queryClient.setQueryData(['systemStatus', 'buildMetadata'], frontendBuildMetadata); + +export const MatchingBuilds: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/src/components/BuildMetadataFooter/BuildMetadataFooter.tsx b/src/components/BuildMetadataFooter/BuildMetadataFooter.tsx new file mode 100644 index 0000000..8a0c3e2 --- /dev/null +++ b/src/components/BuildMetadataFooter/BuildMetadataFooter.tsx @@ -0,0 +1,96 @@ +import { useQuery } from '@tanstack/react-query'; +import { Box, Typography } from '@mui/material'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import axios from 'axios'; +import { useTranslation } from 'react-i18next'; +import { ns } from '@base/i18n'; +import { + frontendBuildMetadata, + isBuildMetadataMatch, + type BuildMetadata, +} from '@base/buildMetadata'; + +const fetchSystemStatus = (): Promise => + axios.get('/system/status').then(({ data }) => data); + +const formatMetadata = ({ git, build }: BuildMetadata): string => + `${build.version} (${git.commitId})`; + +export const BuildMetadataFooter = () => { + const { t } = useTranslation([ns.common]); + const { data: backendBuildMetadata, isError, isLoading } = useQuery({ + queryKey: ['systemStatus', 'buildMetadata'], + queryFn: fetchSystemStatus, + retry: false, + staleTime: Infinity, + }); + + const isMatching = backendBuildMetadata + ? isBuildMetadataMatch(frontendBuildMetadata, backendBuildMetadata) + : false; + const showStatus = !isLoading && !isError && !isMatching; + const statusLabel = isLoading + ? t('buildMetadata.loading') + : isError + ? t('buildMetadata.unavailable') + : t('buildMetadata.mismatch'); + const StatusIcon = isLoading || isError + ? InfoOutlinedIcon + : ErrorOutlineIcon; + const statusColor = isLoading || isError ? 'text.secondary' : 'warning.dark'; + + return ( + + + + + {t('buildMetadata.zenbpm')}: {isLoading + ? t('buildMetadata.loading') + : isError || !backendBuildMetadata + ? t('buildMetadata.unavailable') + : formatMetadata(backendBuildMetadata)} + + + {t('buildMetadata.ui')}: {formatMetadata(frontendBuildMetadata)} + + + {showStatus && ( + + + )} + + + ); +}; diff --git a/src/components/BuildMetadataFooter/index.ts b/src/components/BuildMetadataFooter/index.ts new file mode 100644 index 0000000..404c1e7 --- /dev/null +++ b/src/components/BuildMetadataFooter/index.ts @@ -0,0 +1 @@ +export { BuildMetadataFooter } from './BuildMetadataFooter'; diff --git a/src/components/layouts/MainLayout.tsx b/src/components/layouts/MainLayout.tsx index 6b729e3..96ce8c1 100644 --- a/src/components/layouts/MainLayout.tsx +++ b/src/components/layouts/MainLayout.tsx @@ -28,6 +28,7 @@ import MenuIcon from '@mui/icons-material/Menu'; import AccountTreeIcon from '@mui/icons-material/AccountTree'; import RuleIcon from '@mui/icons-material/Rule'; import LogoutIcon from '@mui/icons-material/Logout'; +import { BuildMetadataFooter } from '@components/BuildMetadataFooter'; const navItems = [ { @@ -369,7 +370,6 @@ export const MainLayout = () => { sx={{ flexGrow: 1, bgcolor: 'background.default', - minHeight: 'calc(100vh - 64px)', }} > { + + ); }; diff --git a/src/mocks/handlers/index.ts b/src/mocks/handlers/index.ts index a66567e..fce7f40 100644 --- a/src/mocks/handlers/index.ts +++ b/src/mocks/handlers/index.ts @@ -7,6 +7,7 @@ import { decisionDefinitionHandlers } from './decisionDefinitions'; import { decisionInstanceHandlers } from './decisionInstances'; import { messageHandlers } from './messages'; import { clusterHandlers } from './cluster'; +import { systemStatusHandlers } from './systemStatus'; export const handlers = [ ...processDefinitionHandlers, @@ -17,4 +18,5 @@ export const handlers = [ ...decisionInstanceHandlers, ...messageHandlers, ...clusterHandlers, + ...systemStatusHandlers, ]; diff --git a/src/mocks/handlers/systemStatus.ts b/src/mocks/handlers/systemStatus.ts new file mode 100644 index 0000000..bcf745b --- /dev/null +++ b/src/mocks/handlers/systemStatus.ts @@ -0,0 +1,52 @@ +import { delay, http, HttpResponse } from 'msw'; +import { frontendBuildMetadata } from '@base/buildMetadata'; + +const getScenario = (request: Request): string | null => { + if (import.meta.env.VITE_E2E_TEST !== 'true' || !request.referrer) { + return null; + } + + return new URL(request.referrer).searchParams.get('systemStatusScenario'); +}; + +const createSystemStatus = (version: string, commitId: string) => ({ + git: { + branch: 'main', + commitId, + }, + build: { + version: `v${version}`, + time: '2026-08-10T07:33:20Z', + }, + clusterConfig: { desiredPartitions: 3 }, + partitions: {}, + nodes: {}, +}); + +export const systemStatusHandlers = [ + http.get('/system/status', async ({ request }) => { + const scenario = getScenario(request); + + if (scenario === 'loading') { + await delay(10_000); + } + + if (scenario === 'error') { + return HttpResponse.json(null, { status: 503 }); + } + + if (scenario === 'mismatch') { + return HttpResponse.json(createSystemStatus(`${frontendBuildMetadata.build.version}+`, frontendBuildMetadata.git.commitId)); + } + + if (scenario === 'release-candidate') { + return HttpResponse.json(createSystemStatus(`${frontendBuildMetadata.build.version}-rc1`, frontendBuildMetadata.git.commitId)); + } + + if (scenario === 'commit-difference') { + return HttpResponse.json(createSystemStatus(frontendBuildMetadata.build.version, '123456789abc')); + } + + return HttpResponse.json(createSystemStatus(frontendBuildMetadata.build.version, frontendBuildMetadata.git.commitId)); + }), +]; diff --git a/src/pages/SystemStatus/SystemStatusPage.stories.tsx b/src/pages/SystemStatus/SystemStatusPage.stories.tsx index 1c9791f..ebdb6b2 100644 --- a/src/pages/SystemStatus/SystemStatusPage.stories.tsx +++ b/src/pages/SystemStatus/SystemStatusPage.stories.tsx @@ -21,6 +21,14 @@ type Story = StoryObj; /** Mock data: 3 nodes, each leading a distinct partition, all partition state Initialized. */ const mockData = { + git: { + branch: 'main', + commitId: 'ac80841035e9', + }, + build: { + version: 'v1.5.0', + time: '2026-08-10T07:33:20Z', + }, clusterConfig: { desiredPartitions: 3 }, partitions: { '1': { id: 1, leaderId: 'node-1' }, diff --git a/src/pages/SystemStatus/SystemStatusPage.tsx b/src/pages/SystemStatus/SystemStatusPage.tsx index eeaf52d..207c343 100644 --- a/src/pages/SystemStatus/SystemStatusPage.tsx +++ b/src/pages/SystemStatus/SystemStatusPage.tsx @@ -1,3 +1,4 @@ +import { Fragment } from 'react'; import { useTranslation } from 'react-i18next'; import { ns } from '@base/i18n'; import { @@ -22,6 +23,7 @@ import RefreshIcon from '@mui/icons-material/Refresh'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; import { themeColors } from '@base/theme'; +import { frontendBuildMetadata, type BuildMetadata } from '@base/buildMetadata'; // ── Types (mirroring /internal/cluster/state/state.go) ─────────────────────── @@ -45,7 +47,7 @@ interface ClusterNode { partitions: Record; } -interface ClusterStatus { +interface ClusterStatus extends BuildMetadata { clusterConfig: { desiredPartitions: number }; partitions: Record; nodes: Record; @@ -111,18 +113,18 @@ const PartitionCell = ({ np, leaderId, nodeId }: { np: NodePartition | undefined sx={ isLeader ? { - bgcolor: themeColors.primaryBg, - color: themeColors.primaryDark, - fontWeight: 700, - fontSize: '0.7rem', - height: 20, - } + bgcolor: themeColors.primaryBg, + color: themeColors.primaryDark, + fontWeight: 700, + fontSize: '0.7rem', + height: 20, + } : { - fontSize: '0.7rem', - height: 20, - color: themeColors.textSecondary, - borderColor: themeColors.borderMedium, - } + fontSize: '0.7rem', + height: 20, + color: themeColors.textSecondary, + borderColor: themeColors.borderMedium, + } } /> {!isHealthy && ( @@ -146,6 +148,54 @@ const Stat = ({ label, value }: { label: string; value: React.ReactNode }) => ( ); +interface BuildInformationColumnProps { + metadata?: BuildMetadata; + testId: string; + title: string; + loading?: boolean; +} + +const BuildInformationColumn = ({ metadata, testId, title, loading = false }: BuildInformationColumnProps) => { + const { t } = useTranslation([ns.common]); + const fields = [ + { label: t('common:systemStatus.version'), value: metadata?.build.version }, + { label: t('common:systemStatus.buildTime'), value: metadata?.build.time }, + { label: t('common:systemStatus.branch'), value: metadata?.git.branch }, + { label: t('common:systemStatus.commitId'), value: metadata?.git.commitId }, + ]; + + return ( + + + {title} + + + {fields.map(({ label, value }) => ( + + + {label} + + {loading ? ( + + ) : ( + + {value || 'unknown'} + + )} + + ))} + + + ); +}; + // ── Page ────────────────────────────────────────────────────────────────────── export const SystemStatusPage = () => { @@ -246,7 +296,9 @@ export const SystemStatusPage = () => { {/* ── Cluster topology matrix ── */} { borderLeft: `1px solid ${themeColors.borderLight}`, bgcolor: node.partitions?.[String(pid)]?.role === 2 && - data?.partitions?.[String(pid)]?.leaderId === node.id + data?.partitions?.[String(pid)]?.leaderId === node.id ? alpha(themeColors.primaryBg, 0.4) // faint green tint for leader cells : undefined, }} @@ -405,6 +457,44 @@ export const SystemStatusPage = () => { )} + + {/* ── Build information ── */} + + + + {t('common:systemStatus.buildInformation')} + + + :not(:first-of-type)': { + borderLeft: { md: `1px solid ${themeColors.borderLight}` }, + borderTop: { xs: `1px solid ${themeColors.borderLight}`, md: 0 }, + }, + }} + > + + + + ); }; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index d29f36b..bd3c5b7 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,10 @@ /// +declare const __BUILD_VERSION__: string; +declare const __BUILD_COMMIT__: string; +declare const __BUILD_BRANCH__: string; +declare const __BUILD_TIME__: string; + interface ImportMetaEnv { /** * Backend API Strategy @@ -32,6 +37,18 @@ interface ImportMetaEnv { */ readonly VITE_LIVE_ENDPOINTS?: string; + /** Overrides the Git commit embedded in frontend build metadata. */ + readonly VITE_BUILD_COMMIT?: string; + + /** Overrides the Git branch embedded in frontend build metadata. */ + readonly VITE_BUILD_BRANCH?: string; + + /** Overrides the UTC timestamp embedded in frontend build metadata. */ + readonly VITE_BUILD_TIME?: string; + + /** Enables test-only mock scenarios in the Playwright web server. */ + readonly VITE_E2E_TEST?: string; + /** Enable OIDC authentication ('true' to enable) */ readonly VITE_AUTH_ENABLED?: string; diff --git a/vite.config.ts b/vite.config.ts index cea0824..eed9fbb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,63 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' import i18nTypesPlugin from './scripts/generate-i18n-types.mjs' +interface BuildMetadata { + version: string + commit: string + branch: string + time: string +} + +const unknownBuildMetadata = 'unknown' + +const runGit = (args: string[]): string | undefined => { + try { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined + } catch { + return undefined + } +} + +const getApiVersion = (): string => { + try { + const apiDefinition = readFileSync(path.resolve(__dirname, 'openapi/api.yaml'), 'utf8') + const infoSection = apiDefinition.match(/^info:\s*$([\s\S]*?)(?=^\S)/m)?.[1] + return infoSection?.match(/^\s+version:\s*["']?([^\s"'#]+)["']?/m)?.[1] ?? 'unknown' + } catch { + return 'unknown' + } +} + +const getBuildCommit = (): string => { + const commit = process.env.VITE_BUILD_COMMIT?.trim() || runGit(['rev-parse', 'HEAD']) + return commit?.slice(0, 12) || unknownBuildMetadata +} + +const getBuildBranch = (): string => + process.env.VITE_BUILD_BRANCH?.trim() || runGit(['branch', '--show-current']) || unknownBuildMetadata + +const getBuildTime = (): string => + process.env.VITE_BUILD_TIME?.trim() || new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') + +const buildMetadata: BuildMetadata = { + version: getApiVersion(), + commit: getBuildCommit(), + branch: getBuildBranch(), + time: getBuildTime(), +} + // https://vite.dev/config/ export default defineConfig({ + define: { + __BUILD_VERSION__: JSON.stringify(buildMetadata.version), + __BUILD_COMMIT__: JSON.stringify(buildMetadata.commit), + __BUILD_BRANCH__: JSON.stringify(buildMetadata.branch), + __BUILD_TIME__: JSON.stringify(buildMetadata.time), + }, plugins: [react(), i18nTypesPlugin()], resolve: { alias: {