feat: show live build version in web UI
Build & Push Docker Image / build (push) Successful in 2m36s

- /api/version returns version, commitSha, buildDate, trashRetentionDays
- CI passes COMMIT_SHA (full), BUILD_DATE and VERSION (git tag) as
  docker build args; Dockerfile bakes them as env
- sidebar footer shows version/sha linked to the Gitea commit
- admin System tab shows version, commit link, build date, trash retention
This commit is contained in:
opencode
2026-08-02 02:13:27 +02:00
parent 2f20b1e5c2
commit 803802a5b6
11 changed files with 247 additions and 3 deletions
+7 -1
View File
@@ -27,8 +27,14 @@ jobs:
- name: Build and push - name: Build and push
run: | run: |
SHA=$(git rev-parse --short HEAD) SHA=$(git rev-parse --short HEAD)
FULL_SHA=$(git rev-parse HEAD)
IMAGE="git.kubebase.de/${{ gitea.repository }}" IMAGE="git.kubebase.de/${{ gitea.repository }}"
docker build --no-cache -t "${IMAGE}:sha-${SHA}" -t "${IMAGE}:latest" . if [ "${{ gitea.ref_type }}" = "tag" ]; then VERSION="${{ gitea.ref_name }}"; else VERSION=""; fi
docker build --no-cache \
--build-arg COMMIT_SHA="$FULL_SHA" \
--build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--build-arg VERSION="$VERSION" \
-t "${IMAGE}:sha-${SHA}" -t "${IMAGE}:latest" .
docker push "${IMAGE}:sha-${SHA}" docker push "${IMAGE}:sha-${SHA}"
docker push "${IMAGE}:latest" docker push "${IMAGE}:latest"
+7
View File
@@ -43,6 +43,13 @@ COPY --from=builder /app/artifacts/toolrate/dist artifacts/toolrate/dist
ENV NODE_ENV=production ENV NODE_ENV=production
ENV STATIC_DIR=/app/artifacts/toolrate/dist/public ENV STATIC_DIR=/app/artifacts/toolrate/dist/public
ARG COMMIT_SHA=""
ARG BUILD_DATE=""
ARG VERSION=""
ENV COMMIT_SHA=${COMMIT_SHA}
ENV BUILD_DATE=${BUILD_DATE}
ENV VERSION=${VERSION}
EXPOSE 8080 EXPOSE 8080
CMD ["sh", "-c", "pnpm --filter @workspace/db run push-force && node --enable-source-maps artifacts/api-server/dist/index.mjs"] CMD ["sh", "-c", "pnpm --filter @workspace/db run push-force && node --enable-source-maps artifacts/api-server/dist/index.mjs"]
+10
View File
@@ -8,4 +8,14 @@ router.get("/healthz", (_req, res) => {
res.json(data); res.json(data);
}); });
router.get("/version", (_req, res) => {
const retention = Number(process.env["TRASH_RETENTION_DAYS"] ?? "0");
res.json({
version: process.env["VERSION"] || "dev",
commitSha: process.env["COMMIT_SHA"] || null,
buildDate: process.env["BUILD_DATE"] || null,
trashRetentionDays: Number.isFinite(retention) ? retention : 0,
});
});
export default router; export default router;
@@ -1,6 +1,7 @@
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle, Trash2 } from "lucide-react"; import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle, Trash2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
@@ -8,6 +9,9 @@ import { ThemeToggle } from "@/components/theme-toggle";
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
const [location] = useLocation(); const [location] = useLocation();
const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, hasFeature, login, logout } = useAuth(); const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, hasFeature, login, logout } = useAuth();
const { data: version } = useGetVersion({
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
});
const links = [ const links = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard }, { href: "/", label: "Dashboard", icon: LayoutDashboard },
@@ -95,6 +99,24 @@ export function Layout({ children }: { children: React.ReactNode }) {
</Button> </Button>
)} )}
</div> </div>
<div className="px-4 py-2.5 border-t flex items-center justify-between text-[11px] text-muted-foreground">
<span className="font-medium">toolr</span>
{version?.commitSha ? (
<a
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${version.commitSha}`}
target="_blank"
rel="noreferrer"
className="font-mono hover:underline"
title={version.commitSha}
>
{version.version && version.version !== "dev"
? version.version
: `sha-${version.commitSha.slice(0, 7)}`}
</a>
) : version?.version && version.version !== "dev" ? (
<span className="font-mono">{version.version}</span>
) : null}
</div>
</aside> </aside>
<main className="flex-1 flex flex-col min-w-0"> <main className="flex-1 flex flex-col min-w-0">
+56 -1
View File
@@ -6,8 +6,10 @@ import {
useUpdateUser, useUpdateUser,
useDeleteUser, useDeleteUser,
useListAuditLogs, useListAuditLogs,
useGetVersion,
getListUsersQueryKey, getListUsersQueryKey,
getListAuditLogsQueryKey, getListAuditLogsQueryKey,
getGetVersionQueryKey,
} from "@workspace/api-client-react"; } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
@@ -22,7 +24,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock, AlertTriangle, Wrench } from "lucide-react"; import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock, AlertTriangle, Wrench, Server } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { AdminToolsTab } from "@/components/admin-tools-tab"; import { AdminToolsTab } from "@/components/admin-tools-tab";
@@ -49,6 +51,9 @@ export default function Admin() {
{ limit: 100 }, { limit: 100 },
{ query: { queryKey: getListAuditLogsQueryKey({ limit: 100 }), enabled: isAdmin } }, { query: { queryKey: getListAuditLogsQueryKey({ limit: 100 }), enabled: isAdmin } },
); );
const { data: versionInfo } = useGetVersion({
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false, enabled: isAdmin },
});
const createUser = useCreateUser(); const createUser = useCreateUser();
const updateUser = useUpdateUser(); const updateUser = useUpdateUser();
@@ -154,6 +159,9 @@ export default function Admin() {
<TabsTrigger value="audit" className="gap-2"> <TabsTrigger value="audit" className="gap-2">
<ScrollText className="w-4 h-4" /> Audit Log <ScrollText className="w-4 h-4" /> Audit Log
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="system" className="gap-2">
<Server className="w-4 h-4" /> System
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="users"> <TabsContent value="users">
@@ -274,6 +282,53 @@ export default function Admin() {
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="system">
<Card>
<CardHeader>
<CardTitle>System</CardTitle>
<CardDescription>Build information of the currently live deployment.</CardDescription>
</CardHeader>
<CardContent>
<div className="divide-y">
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Version</span>
<span className="text-sm font-medium">{versionInfo?.version || "dev"}</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Commit</span>
{versionInfo?.commitSha ? (
<a
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${versionInfo.commitSha}`}
target="_blank"
rel="noreferrer"
className="text-sm font-mono text-primary hover:underline"
title={versionInfo.commitSha}
>
{versionInfo.commitSha.slice(0, 7)}
</a>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Build date</span>
<span className="text-sm">
{versionInfo?.buildDate ? format(new Date(versionInfo.buildDate), "dd.MM.yyyy HH:mm") : "—"}
</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Trash retention</span>
<span className="text-sm">
{(versionInfo?.trashRetentionDays ?? 0) > 0
? `${versionInfo?.trashRetentionDays} days`
: "Keep forever"}
</span>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs> </Tabs>
</div> </div>
@@ -9,6 +9,15 @@ export interface HealthStatus {
status: string; status: string;
} }
export interface VersionInfo {
version: string;
/** @nullable */
commitSha?: string | null;
/** @nullable */
buildDate?: string | null;
trashRetentionDays?: number;
}
export type AuthModeMode = typeof AuthModeMode[keyof typeof AuthModeMode]; export type AuthModeMode = typeof AuthModeMode[keyof typeof AuthModeMode];
+80 -1
View File
@@ -47,7 +47,8 @@ import type {
TrashToolsInput, TrashToolsInput,
User, User,
UserCreateInput, UserCreateInput,
UserRoleUpdate UserRoleUpdate,
VersionInfo
} from './api.schemas'; } from './api.schemas';
import { customFetch } from '../custom-fetch'; import { customFetch } from '../custom-fetch';
@@ -140,6 +141,84 @@ export function useHealthCheck<TData = Awaited<ReturnType<typeof healthCheck>>,
export const getGetVersionUrl = () => {
return `/api/version`
}
/**
* Returns the running build version, commit SHA and build date
* @summary Build version information
*/
export const getVersion = async ( options?: RequestInit): Promise<VersionInfo> => {
return customFetch<VersionInfo>(getGetVersionUrl(),
{
...options,
method: 'GET'
}
);}
export const getGetVersionQueryKey = () => {
return [
`/api/version`
] as const;
}
export const getGetVersionQueryOptions = <TData = Awaited<ReturnType<typeof getVersion>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getVersion>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetVersionQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getVersion>>> = ({ signal }) => getVersion({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getVersion>>, TError, TData> & { queryKey: QueryKey }
}
export type GetVersionQueryResult = NonNullable<Awaited<ReturnType<typeof getVersion>>>
export type GetVersionQueryError = ErrorType<unknown>
/**
* @summary Build version information
*/
export function useGetVersion<TData = Awaited<ReturnType<typeof getVersion>>, TError = ErrorType<unknown>>(
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getVersion>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetVersionQueryOptions(options)
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey };
}
export const getListToolsUrl = (params?: ListToolsParams,) => { export const getListToolsUrl = (params?: ListToolsParams,) => {
const normalizedParams = new URLSearchParams(); const normalizedParams = new URLSearchParams();
+27
View File
@@ -37,6 +37,20 @@ paths:
schema: schema:
$ref: "#/components/schemas/HealthStatus" $ref: "#/components/schemas/HealthStatus"
/version:
get:
operationId: getVersion
tags: [health]
summary: Build version information
description: Returns the running build version, commit SHA and build date
responses:
"200":
description: Version information
content:
application/json:
schema:
$ref: "#/components/schemas/VersionInfo"
/tools: /tools:
get: get:
operationId: listTools operationId: listTools
@@ -660,6 +674,19 @@ components:
required: required:
- status - status
VersionInfo:
type: object
required: [version]
properties:
version:
type: string
commitSha:
type: ["string", "null"]
buildDate:
type: ["string", "null"]
trashRetentionDays:
type: integer
AuthMode: AuthMode:
type: object type: object
required: [mode] required: [mode]
+12
View File
@@ -17,6 +17,18 @@ export const HealthCheckResponse = zod.object({
}) })
/**
* Returns the running build version, commit SHA and build date
* @summary Build version information
*/
export const GetVersionResponse = zod.object({
"version": zod.string(),
"commitSha": zod.string().nullish(),
"buildDate": zod.string().nullish(),
"trashRetentionDays": zod.number().optional()
})
/** /**
* @summary List all tools * @summary List all tools
*/ */
+1
View File
@@ -46,3 +46,4 @@ export * from './userRoleUpdate';
export * from './userRoleUpdateRole'; export * from './userRoleUpdateRole';
export * from './userRoleUpdateTier'; export * from './userRoleUpdateTier';
export * from './userTier'; export * from './userTier';
export * from './versionInfo';
@@ -0,0 +1,16 @@
/**
* Generated by orval v8.9.1 🍺
* Do not edit manually.
* Api
* ToolRate API — Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
export interface VersionInfo {
version: string;
/** @nullable */
commitSha?: string | null;
/** @nullable */
buildDate?: string | null;
trashRetentionDays?: number;
}