feat: show live build version in web UI
Build & Push Docker Image / build (push) Successful in 2m36s
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:
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user