From 803802a5b6032ba4d0c91b2ac9d0f29ddf59b77e Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 2 Aug 2026 02:13:27 +0200 Subject: [PATCH] feat: show live build version in web UI - /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 --- .gitea/workflows/build.yaml | 8 +- Dockerfile | 7 ++ artifacts/api-server/src/routes/health.ts | 10 +++ artifacts/toolrate/src/components/layout.tsx | 22 +++++ artifacts/toolrate/src/pages/admin.tsx | 57 ++++++++++++- .../src/generated/api.schemas.ts | 9 +++ lib/api-client-react/src/generated/api.ts | 81 ++++++++++++++++++- lib/api-spec/openapi.yaml | 27 +++++++ lib/api-zod/src/generated/api.ts | 12 +++ lib/api-zod/src/generated/types/index.ts | 1 + .../src/generated/types/versionInfo.ts | 16 ++++ 11 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 lib/api-zod/src/generated/types/versionInfo.ts diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index b53ee84..54719b0 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -27,8 +27,14 @@ jobs: - name: Build and push run: | SHA=$(git rev-parse --short HEAD) + FULL_SHA=$(git rev-parse HEAD) 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}:latest" diff --git a/Dockerfile b/Dockerfile index fe4ddab..f93d394 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,13 @@ COPY --from=builder /app/artifacts/toolrate/dist artifacts/toolrate/dist ENV NODE_ENV=production 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 CMD ["sh", "-c", "pnpm --filter @workspace/db run push-force && node --enable-source-maps artifacts/api-server/dist/index.mjs"] diff --git a/artifacts/api-server/src/routes/health.ts b/artifacts/api-server/src/routes/health.ts index c0a1446..00b380b 100644 --- a/artifacts/api-server/src/routes/health.ts +++ b/artifacts/api-server/src/routes/health.ts @@ -8,4 +8,14 @@ router.get("/healthz", (_req, res) => { 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; diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx index 3a1b175..254ee10 100644 --- a/artifacts/toolrate/src/components/layout.tsx +++ b/artifacts/toolrate/src/components/layout.tsx @@ -1,6 +1,7 @@ import { Link, useLocation } from "wouter"; import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle, Trash2 } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { ThemeToggle } from "@/components/theme-toggle"; @@ -8,6 +9,9 @@ import { ThemeToggle } from "@/components/theme-toggle"; export function Layout({ children }: { children: React.ReactNode }) { const [location] = useLocation(); const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, hasFeature, login, logout } = useAuth(); + const { data: version } = useGetVersion({ + query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false }, + }); const links = [ { href: "/", label: "Dashboard", icon: LayoutDashboard }, @@ -95,6 +99,24 @@ export function Layout({ children }: { children: React.ReactNode }) { )} +
+ toolr + {version?.commitSha ? ( + + {version.version && version.version !== "dev" + ? version.version + : `sha-${version.commitSha.slice(0, 7)}`} + + ) : version?.version && version.version !== "dev" ? ( + {version.version} + ) : null} +
diff --git a/artifacts/toolrate/src/pages/admin.tsx b/artifacts/toolrate/src/pages/admin.tsx index 808f4d5..0cd3f10 100644 --- a/artifacts/toolrate/src/pages/admin.tsx +++ b/artifacts/toolrate/src/pages/admin.tsx @@ -6,8 +6,10 @@ import { useUpdateUser, useDeleteUser, useListAuditLogs, + useGetVersion, getListUsersQueryKey, getListAuditLogsQueryKey, + getGetVersionQueryKey, } from "@workspace/api-client-react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; @@ -22,7 +24,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Skeleton } from "@/components/ui/skeleton"; import { useToast } from "@/hooks/use-toast"; 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 { AdminToolsTab } from "@/components/admin-tools-tab"; @@ -49,6 +51,9 @@ export default function Admin() { { limit: 100 }, { query: { queryKey: getListAuditLogsQueryKey({ limit: 100 }), enabled: isAdmin } }, ); + const { data: versionInfo } = useGetVersion({ + query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false, enabled: isAdmin }, + }); const createUser = useCreateUser(); const updateUser = useUpdateUser(); @@ -154,6 +159,9 @@ export default function Admin() { Audit Log + + System + @@ -274,6 +282,53 @@ export default function Admin() { + + + + + System + Build information of the currently live deployment. + + +
+
+ Version + {versionInfo?.version || "dev"} +
+
+ Commit + {versionInfo?.commitSha ? ( + + {versionInfo.commitSha.slice(0, 7)} + + ) : ( + + )} +
+
+ Build date + + {versionInfo?.buildDate ? format(new Date(versionInfo.buildDate), "dd.MM.yyyy HH:mm") : "—"} + +
+
+ Trash retention + + {(versionInfo?.trashRetentionDays ?? 0) > 0 + ? `${versionInfo?.trashRetentionDays} days` + : "Keep forever"} + +
+
+
+
+
diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 290c94b..134a95a 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -9,6 +9,15 @@ export interface HealthStatus { 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]; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 9632ed6..2f8b7c2 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -47,7 +47,8 @@ import type { TrashToolsInput, User, UserCreateInput, - UserRoleUpdate + UserRoleUpdate, + VersionInfo } from './api.schemas'; import { customFetch } from '../custom-fetch'; @@ -140,6 +141,84 @@ export function useHealthCheck>, +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 => { + + return customFetch(getGetVersionUrl(), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getGetVersionQueryKey = () => { + return [ + `/api/version` + ] as const; + } + + +export const getGetVersionQueryOptions = >, TError = ErrorType>( options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetVersionQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getVersion({ signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: QueryKey } +} + +export type GetVersionQueryResult = NonNullable>> +export type GetVersionQueryError = ErrorType + + +/** + * @summary Build version information + */ + +export function useGetVersion>, TError = ErrorType>( + options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} + + ): UseQueryResult & { queryKey: QueryKey } { + + const queryOptions = getGetVersionQueryOptions(options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: QueryKey }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + + export const getListToolsUrl = (params?: ListToolsParams,) => { const normalizedParams = new URLSearchParams(); diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index d8f4eb2..fad5a1b 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -37,6 +37,20 @@ paths: schema: $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: get: operationId: listTools @@ -660,6 +674,19 @@ components: required: - status + VersionInfo: + type: object + required: [version] + properties: + version: + type: string + commitSha: + type: ["string", "null"] + buildDate: + type: ["string", "null"] + trashRetentionDays: + type: integer + AuthMode: type: object required: [mode] diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index d1a9c50..e8e5aba 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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 */ diff --git a/lib/api-zod/src/generated/types/index.ts b/lib/api-zod/src/generated/types/index.ts index ad49d0a..2b8b151 100644 --- a/lib/api-zod/src/generated/types/index.ts +++ b/lib/api-zod/src/generated/types/index.ts @@ -46,3 +46,4 @@ export * from './userRoleUpdate'; export * from './userRoleUpdateRole'; export * from './userRoleUpdateTier'; export * from './userTier'; +export * from './versionInfo'; diff --git a/lib/api-zod/src/generated/types/versionInfo.ts b/lib/api-zod/src/generated/types/versionInfo.ts new file mode 100644 index 0000000..154bed0 --- /dev/null +++ b/lib/api-zod/src/generated/types/versionInfo.ts @@ -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; +}