diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 168c7c6..b0d6ccf 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -65,5 +65,15 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # Build identity stamped into the binary via -ldflags. These come from + # the same metadata-action step that names the image tag, so + # GET /api/v1/version matches the tag the image was published under: + # outputs.version is "1.2.3" for a v1.2.3 tag and "manual-" for a + # dispatch run. org.opencontainers.image.created is metadata-action's + # own build timestamp, reused so the label and the API agree. + build-args: | + VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ github.sha }} + BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/README.md b/README.md index 28f9faa..bc6c73c 100644 --- a/README.md +++ b/README.md @@ -574,12 +574,27 @@ npm run build # production build ## API Endpoints All routes are under `/api/v1` and require authentication (unless -`AUTH_DISABLED=true`), except `/health`. The web UI is the primary interface; -these are the main REST routes. +`AUTH_DISABLED=true`), except `/health` and `/api/v1/version`. The web UI is the +primary interface; these are the main REST routes. ### Health - `GET /health` — health check (restricted to localhost) +### Version +- `GET /api/v1/version` — build identity of the running binary. Public, so an + uptime check or a support conversation can answer "what is running here?" + without a session. + +```console +$ curl -s localhost:5001/api/v1/version +{"version":"0.9.0","commit":"6fb9879...","buildDate":"2026-07-31T09:12:44Z"} +``` + +The same values appear on the first startup log line and in the UI under +**Settings → About**. A published image reports the tag it was built under; a +local `docker build` with no `--build-arg` reports `version: dev`, which is the +honest answer rather than a blank. + ### Authentication - `POST /api/v1/auth/setup` — create the first admin (only when no user exists) - `POST /api/v1/auth/login` — log in diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0ffa167..fba3c23 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -24,6 +24,7 @@ import ( "github.com/thinkbig1979/capstan/backend/internal/logging" "github.com/thinkbig1979/capstan/backend/internal/middleware" "github.com/thinkbig1979/capstan/backend/internal/services" + "github.com/thinkbig1979/capstan/backend/internal/version" ) // buildConnectSrc constructs the CSP connect-src directive from the configured @@ -106,7 +107,13 @@ func main() { log.Fatal("Failed to configure logging:", err) } + // Build identity goes on the very first line, so it is present even if the + // process dies later in startup (agent-os-r7e). + build := version.Get() slog.Info("Starting Capstan backend", + "version", build.Version, + "commit", build.Commit, + "built", build.BuildDate, "log_level", cfg.LogLevel, "log_format", cfg.LogFormat, ) @@ -246,6 +253,11 @@ func main() { api := r.Group("/api/v1") + // Public by choice: build identity answers "what is running here?" for + // monitoring and support without a session. Mirrored in + // middleware.PublicPaths, which both auth and CSRF consult (agent-os-r7e). + handlers.NewVersionHandler().RegisterVersionRoutes(api) + authHandler := handlers.NewAuthHandler(db, cfg.JWTSecret, cfg.AuthDisabled) authGroup := api.Group("/auth") authHandler.RegisterPublicRoutes(authGroup) diff --git a/backend/internal/handlers/version.go b/backend/internal/handlers/version.go new file mode 100644 index 0000000..9309c5a --- /dev/null +++ b/backend/internal/handlers/version.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/thinkbig1979/capstan/backend/internal/version" +) + +// VersionHandler serves the build identity of the running binary. +// +// The route is deliberately public (see RegisterVersionRoutes). "What is +// actually running on this host?" is the first question of every incident, and +// an uptime check or a support conversation should be able to answer it without +// a session. The trade is that an unauthenticated caller learns the exact +// version; for a self-hosted admin tool on a trusted network that is worth the +// operability, and the same information is already visible in the image tag. +type VersionHandler struct{} + +func NewVersionHandler() *VersionHandler { + return &VersionHandler{} +} + +// RegisterVersionRoutes mounts GET /version on the given group. +// +// It must be mounted on the *unauthenticated* /api/v1 group, and the resulting +// path must appear in middleware.PublicPaths — that list is consulted by both +// the auth middleware and the CSRF middleware, so the two stay consistent if the +// route is ever moved under the protected group. +func (h *VersionHandler) RegisterVersionRoutes(group *gin.RouterGroup) { + group.GET("/version", h.Get) +} + +func (h *VersionHandler) Get(c *gin.Context) { + c.JSON(http.StatusOK, version.Get()) +} diff --git a/backend/internal/handlers/version_test.go b/backend/internal/handlers/version_test.go new file mode 100644 index 0000000..bbcd35d --- /dev/null +++ b/backend/internal/handlers/version_test.go @@ -0,0 +1,64 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/thinkbig1979/capstan/backend/internal/middleware" + "github.com/thinkbig1979/capstan/backend/internal/version" +) + +func versionRouter() *gin.Engine { + r := gin.New() + NewVersionHandler().RegisterVersionRoutes(r.Group("/api/v1")) + return r +} + +func TestVersionEndpointReturnsBuildIdentity(t *testing.T) { + origV, origC, origD := version.Version, version.Commit, version.BuildDate + t.Cleanup(func() { version.Version, version.Commit, version.BuildDate = origV, origC, origD }) + version.Version, version.Commit, version.BuildDate = "9.9.9", "deadbeef", "2026-07-31T00:00:00Z" + + w := httptest.NewRecorder() + versionRouter().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + var got version.Info + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decoding %s: %v", w.Body.String(), err) + } + if got.Version != "9.9.9" || got.Commit != "deadbeef" || got.BuildDate != "2026-07-31T00:00:00Z" { + t.Errorf("body = %+v, want the stamped build identity", got) + } +} + +// TestVersionEndpointServesUnstampedDefaults covers the local-build path: no +// ldflags, so the response must carry "dev" rather than an empty version string. +func TestVersionEndpointServesUnstampedDefaults(t *testing.T) { + w := httptest.NewRecorder() + versionRouter().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)) + + var got version.Info + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decoding %s: %v", w.Body.String(), err) + } + if got.Version != "dev" { + t.Errorf("version = %q, want %q", got.Version, "dev") + } +} + +// TestVersionPathIsPublic pins the deliberate public choice. middleware.PublicPaths +// is consulted by the auth middleware *and* the CSRF middleware, so a route that +// is mounted publicly but missing from the list would start 401ing the moment it +// is moved under the protected group. +func TestVersionPathIsPublic(t *testing.T) { + if !middleware.IsPublicPath("/api/v1/version") { + t.Error("/api/v1/version is served outside the protected group but is not in middleware.PublicPaths") + } +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index ef854db..945cdb4 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -16,6 +16,10 @@ var PublicPaths = []string{ "/api/v1/auth/login", "/api/v1/auth/setup", "/api/v1/auth/status", + // Build identity is deliberately public: an uptime check or a support + // conversation needs to answer "what is running here?" without a session. + // This list is consulted by the CSRF middleware too, so keep the two in step. + "/api/v1/version", "/health", } diff --git a/backend/internal/version/ldflags_test.go b/backend/internal/version/ldflags_test.go new file mode 100644 index 0000000..7e13e22 --- /dev/null +++ b/backend/internal/version/ldflags_test.go @@ -0,0 +1,89 @@ +package version + +import ( + "os" + "reflect" + "regexp" + "testing" +) + +// dockerfilePath is relative to this package directory (backend/internal/version). +const dockerfilePath = "../../../docker/Dockerfile" + +// ldflagsX matches a single `-X .=` entry. +var ldflagsX = regexp.MustCompile(`-X\s+'?([^\s'"=]+)\.([A-Za-z_][A-Za-z0-9_]*)=`) + +// TestDockerfileLdflagsResolveToThisPackage is the guard against the failure mode +// that makes this whole feature untrustworthy: a typo in an -X symbol path is +// accepted silently by the linker, the default value survives, and the build +// succeeds. Nothing at build time complains, so the image ships reporting "dev". +// +// The test pins the Dockerfile's -X paths to the symbols actually declared here. +// Renaming a symbol breaks compilation of the reference block below; moving the +// package or mistyping the path breaks this comparison. +func TestDockerfileLdflagsResolveToThisPackage(t *testing.T) { + // Compile-time proof that these symbols exist with these names and are + // settable strings. -X only works on string vars. + var _ string = Version + var _ string = Commit + var _ string = BuildDate + + pkgPath := reflect.TypeFor[Info]().PkgPath() + if pkgPath == "" { + t.Fatal("could not determine this package's import path") + } + + content, err := os.ReadFile(dockerfilePath) + if err != nil { + t.Fatalf("reading %s: %v", dockerfilePath, err) + } + + matches := ldflagsX.FindAllStringSubmatch(string(content), -1) + if len(matches) == 0 { + t.Fatalf("no -X ldflags entries found in %s; the build no longer stamps build identity", dockerfilePath) + } + + want := map[string]bool{"Version": false, "Commit": false, "BuildDate": false} + + for _, m := range matches { + gotPath, gotSymbol := m[1], m[2] + if gotPath != pkgPath { + t.Errorf("-X %s.%s targets package %q, but the version package is %q; "+ + "the linker accepts this silently and the default value survives", + gotPath, gotSymbol, gotPath, pkgPath) + continue + } + seen, known := want[gotSymbol] + if !known { + t.Errorf("-X %s.%s targets a symbol not declared in this package", gotPath, gotSymbol) + continue + } + if seen { + t.Errorf("-X %s.%s appears more than once", gotPath, gotSymbol) + } + want[gotSymbol] = true + } + + for symbol, seen := range want { + if !seen { + t.Errorf("%s is never stamped: no `-X %s.%s=` entry in %s", symbol, pkgPath, symbol, dockerfilePath) + } + } +} + +// TestDockerfileArgsDefaultToDev asserts the Dockerfile declares defaults for the +// build args, so a plain `docker build` with no --build-arg yields "dev" rather +// than an empty string. An undeclared-default ARG expands to "" and the -X then +// stamps an empty value, which reads as a bug in the UI and the logs. +func TestDockerfileArgsDefaultToDev(t *testing.T) { + content, err := os.ReadFile(dockerfilePath) + if err != nil { + t.Fatalf("reading %s: %v", dockerfilePath, err) + } + + for _, want := range []string{"ARG VERSION=dev", "ARG COMMIT=unknown", "ARG BUILD_DATE=unknown"} { + if !regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(want) + `\s*$`).Match(content) { + t.Errorf("%s not found in %s; a plain docker build would stamp an empty string", want, dockerfilePath) + } + } +} diff --git a/backend/internal/version/version.go b/backend/internal/version/version.go new file mode 100644 index 0000000..e76b561 --- /dev/null +++ b/backend/internal/version/version.go @@ -0,0 +1,35 @@ +// Package version carries the build identity stamped into the binary at link +// time. +// +// The values below are overridden by -ldflags "-X .Version=..." in +// docker/Dockerfile's backend build stage. The defaults are deliberately +// non-empty: an unstamped binary (a plain `go build`, or a local developer run) +// reports "dev" rather than an empty string, which reads as a bug rather than as +// "this was not built from a release tag". +// +// A typo in an -X symbol path fails silently — the linker neither errors nor +// warns, and the default survives. ldflags_test.go guards against that by +// asserting the Dockerfile's -X paths match the symbols declared here. +package version + +// These are var, not const, precisely so -X can rewrite them at link time. +var ( + // Version is the release version, e.g. "1.4.0". "dev" when unstamped. + Version = "dev" + // Commit is the full git SHA the image was built from. + Commit = "unknown" + // BuildDate is the RFC3339 build timestamp. + BuildDate = "unknown" +) + +// Info is the build identity as served by GET /api/v1/version. +type Info struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuildDate string `json:"buildDate"` +} + +// Get returns the current build identity. +func Get() Info { + return Info{Version: Version, Commit: Commit, BuildDate: BuildDate} +} diff --git a/backend/internal/version/version_test.go b/backend/internal/version/version_test.go new file mode 100644 index 0000000..d8ba411 --- /dev/null +++ b/backend/internal/version/version_test.go @@ -0,0 +1,31 @@ +package version + +import "testing" + +// TestDefaultsAreHonest guards the "empty string reads as a bug" requirement: an +// unstamped binary must say something, and that something must be "dev". +func TestDefaultsAreHonest(t *testing.T) { + info := Get() + + if info.Version != "dev" { + t.Errorf("Version = %q, want %q for an unstamped build", info.Version, "dev") + } + if info.Commit == "" { + t.Error("Commit is empty; an unstamped build must still report something") + } + if info.BuildDate == "" { + t.Error("BuildDate is empty; an unstamped build must still report something") + } +} + +func TestGetReflectsStampedValues(t *testing.T) { + origV, origC, origD := Version, Commit, BuildDate + t.Cleanup(func() { Version, Commit, BuildDate = origV, origC, origD }) + + Version, Commit, BuildDate = "1.2.3", "abc1234", "2026-07-31T12:00:00Z" + + info := Get() + if info.Version != "1.2.3" || info.Commit != "abc1234" || info.BuildDate != "2026-07-31T12:00:00Z" { + t.Errorf("Get() = %+v, want the stamped values", info) + } +} diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index 256cc25..391a786 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -2,7 +2,12 @@ services: app: container_name: capstan # Published by .github/workflows/docker-publish.yml. Pin to a version tag - # (e.g. :0.7.0) in production; :latest tracks the most recent release. + # (e.g. :0.9.0) in production; :latest tracks the most recent release. + # The default stays :latest deliberately — a pinned tag in a template file + # goes stale on every release and nothing in the repo keeps it current. Since + # :latest is mutable and the watchtower label below lets an agent move it, + # identify what is actually running with `curl localhost:5001/api/v1/version` + # or Settings → About. image: ghcr.io/thinkbig1979/capstan:latest restart: unless-stopped ports: diff --git a/docker/Dockerfile b/docker/Dockerfile index dab008b..85216cf 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -35,7 +35,30 @@ RUN go mod download COPY backend/ ./ ARG TARGETOS ARG TARGETARCH -RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -o server ./cmd/server + +# Build identity, stamped into internal/version at link time. The defaults keep +# a plain `docker build` (no --build-arg) honest: it reports "dev", not an empty +# string that reads as a bug. docker-publish.yml supplies the real values from +# the same docker/metadata-action output that names the image tag. +# +# A typo in an -X symbol path is accepted silently by the linker and leaves the +# default in place, so backend/internal/version/ldflags_test.go asserts these +# paths against the symbols actually declared in that package. Keep them in step. +ARG VERSION=dev +ARG COMMIT=unknown +ARG BUILD_DATE=unknown + +# The ${VAR:-default} rewrites cover the case the ARG defaults do not: an ARG +# default only applies when the arg is *absent*, so a --build-arg carrying an +# empty value (a CI expression that resolved to nothing) would stamp an empty +# string — the exact silent failure this feature exists to prevent. +RUN set -eu; \ + VERSION="${VERSION:-dev}"; \ + COMMIT="${COMMIT:-unknown}"; \ + BUILD_DATE="${BUILD_DATE:-unknown}"; \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a \ + -ldflags "-X github.com/thinkbig1979/capstan/backend/internal/version.Version=${VERSION} -X github.com/thinkbig1979/capstan/backend/internal/version.Commit=${COMMIT} -X github.com/thinkbig1979/capstan/backend/internal/version.BuildDate=${BUILD_DATE}" \ + -o server ./cmd/server # Backup-tools fetch stage — downloads restic and rclone static binaries for the # target architecture on the build host. Pinned to exact versions for diff --git a/frontend/src/components/settings/AboutContent.tsx b/frontend/src/components/settings/AboutContent.tsx new file mode 100644 index 0000000..99a1e66 --- /dev/null +++ b/frontend/src/components/settings/AboutContent.tsx @@ -0,0 +1,84 @@ +import { ExternalLink } from 'lucide-react' +import { LoadingSpinner } from '@/components/LoadingSkeleton' +import { formatDateFull } from '@/lib/format' +import { useVersion } from '@/hooks/useVersion' + +const RELEASES_BASE = 'https://github.com/thinkbig1979/capstan/releases/tag' + +/** Semver, as produced by docker/metadata-action from a `v1.2.3` git tag. A + * manual dispatch build is `manual-` and an unstamped build is `dev`; + * neither has a release page, so neither gets a link. */ +const SEMVER = /^\d+\.\d+\.\d+/ + +/** Values stamped at link time, so "unknown" means this build carried no + * build-arg — not that the value is missing. Say so rather than showing a bare + * placeholder that reads as an error. */ +function displayOrDash(value: string | undefined): string { + if (!value || value === 'unknown') return '—' + return value +} + +export function AboutContent() { + const { data, isLoading, isError } = useVersion() + + if (isLoading) { + return ( +
+ + Reading build identity... +
+ ) + } + + if (isError || !data) { + return ( +

+ Could not read the build identity from the server. +

+ ) + } + + const isRelease = SEMVER.test(data.version) + + return ( +
+
+
Version
+
+ {data.version} +
+ +
Commit
+
+ {displayOrDash(data.commit)} +
+ +
Built
+
+ {data.buildDate && data.buildDate !== 'unknown' + ? formatDateFull(data.buildDate) + : '—'} +
+
+ + {data.version === 'dev' && ( +

+ This binary was built without release metadata — a local build rather than a + published image. +

+ )} + + {isRelease && ( + + Release notes for v{data.version} + + + )} +
+ ) +} diff --git a/frontend/src/components/settings/__tests__/AboutContent.test.tsx b/frontend/src/components/settings/__tests__/AboutContent.test.tsx new file mode 100644 index 0000000..a0e9200 --- /dev/null +++ b/frontend/src/components/settings/__tests__/AboutContent.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { AboutContent } from '../AboutContent' + +// "What is actually running on this host?" had no answer in the UI at all +// before this (agent-os-r7e). + +const mockGetVersion = vi.fn() + +vi.mock('@/lib/api', () => ({ + versionApi: { + get: (...args: unknown[]) => mockGetVersion(...args), + }, +})) + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +function renderAbout() { + return render(, { wrapper: createWrapper() }) +} + +describe('AboutContent', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows the stamped version, commit and build date', async () => { + mockGetVersion.mockResolvedValue({ + version: '1.4.0', + commit: 'a1b2c3d4e5f6', + buildDate: '2026-07-31T09:00:00Z', + }) + + renderAbout() + + expect(await screen.findByTestId('about-version')).toHaveTextContent('1.4.0') + expect(screen.getByTestId('about-commit')).toHaveTextContent('a1b2c3d4e5f6') + // Rendered through the locale formatter, so assert on the year rather than + // on a fixed string that moves with the runner's timezone. + expect(screen.getByTestId('about-build-date')).toHaveTextContent('2026') + }) + + it('links to the release notes for a semver build', async () => { + mockGetVersion.mockResolvedValue({ + version: '1.4.0', + commit: 'a1b2c3d4e5f6', + buildDate: '2026-07-31T09:00:00Z', + }) + + renderAbout() + + const link = await screen.findByRole('link', { name: /release notes/i }) + expect(link).toHaveAttribute( + 'href', + 'https://github.com/thinkbig1979/capstan/releases/tag/v1.4.0', + ) + }) + + it('reports an unstamped local build as dev with no release link', async () => { + mockGetVersion.mockResolvedValue({ + version: 'dev', + commit: 'unknown', + buildDate: 'unknown', + }) + + renderAbout() + + expect(await screen.findByTestId('about-version')).toHaveTextContent('dev') + // "unknown" is a real answer from the server, but showing it verbatim reads + // as an error; it becomes a dash. + expect(screen.getByTestId('about-commit')).toHaveTextContent('—') + expect(screen.getByTestId('about-build-date')).toHaveTextContent('—') + expect(screen.queryByRole('link', { name: /release notes/i })).toBeNull() + }) + + it('offers no release link for a manual dispatch build', async () => { + mockGetVersion.mockResolvedValue({ + version: 'manual-9632a74', + commit: '9632a74abcdef', + buildDate: '2026-07-31T09:00:00Z', + }) + + renderAbout() + + expect(await screen.findByTestId('about-version')).toHaveTextContent('manual-9632a74') + expect(screen.queryByRole('link', { name: /release notes/i })).toBeNull() + }) + + it('says so when the endpoint fails rather than rendering blanks', async () => { + mockGetVersion.mockRejectedValue(new Error('network down')) + + renderAbout() + + // useVersion retries once, so allow for react-query's ~1s backoff before + // the query settles into isError. + expect( + await screen.findByText(/could not read the build identity/i, undefined, { timeout: 5000 }), + ).toBeInTheDocument() + expect(screen.queryByTestId('about-version')).toBeNull() + }) +}) diff --git a/frontend/src/hooks/useVersion.ts b/frontend/src/hooks/useVersion.ts new file mode 100644 index 0000000..55d7d4b --- /dev/null +++ b/frontend/src/hooks/useVersion.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query' +import { versionApi } from '@/lib/api' +import { queryKeys } from '@/lib/query-keys' + +/** + * Build identity of the running backend. + * + * The values are stamped into the binary at link time, so they cannot change + * while the process is up: staleTime is Infinity and there is no refetch. A + * reload after a `docker compose pull` picks up the new build. + */ +export function useVersion() { + return useQuery({ + queryKey: queryKeys.version(), + queryFn: () => versionApi.get(), + staleTime: Infinity, + refetchOnWindowFocus: false, + retry: 1, + }) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8df43d1..181516e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -29,6 +29,7 @@ import type { BackupSettings, BackupStatus, BackupOperationResult, + VersionInfo, } from '@/types' /** @@ -146,6 +147,14 @@ export const authApi = { }, } +/** Build identity of the running backend. Public endpoint — no session needed. */ +export const versionApi = { + get: async () => { + const response = await apiClient.get('/version') + return response.data + }, +} + export const settingsApi = { getGlobalEnv: async () => { const response = await apiClient.get<{ vars: Array<{ key: string; value: string }> }>('/settings/global-env') diff --git a/frontend/src/lib/query-keys.ts b/frontend/src/lib/query-keys.ts index 8ddcd91..0fbdebf 100644 --- a/frontend/src/lib/query-keys.ts +++ b/frontend/src/lib/query-keys.ts @@ -85,6 +85,9 @@ export const queryKeys = { retention: () => ['settings', 'retention'] as const, }, + /** Build identity of the running backend. Immutable for the process lifetime. */ + version: () => ['version'] as const, + /** Git state for a stack. `all` prefix-matches log and diff. */ git: { all: (stackId: string) => ['git', stackId] as const, diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index ac14718..915fec7 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -14,8 +14,9 @@ import { formatDateFull } from '@/lib/format' import { cn } from '@/lib/utils' import { Sun, Moon, Monitor, Shield, Palette, Clock, KeyRound, FolderCog, - ScrollText, Globe, HardDrive, Search, type LucideIcon, + ScrollText, Globe, HardDrive, Search, Info, type LucideIcon, } from 'lucide-react' +import { AboutContent } from '@/components/settings/AboutContent' import { BackupSettingsContent } from '@/components/settings/BackupSettingsContent' import { authApi } from '@/lib/api' import { @@ -89,6 +90,12 @@ const ALL_SECTIONS: SettingsSection[] = [ description: 'View a history of actions performed in the application', Icon: ScrollText, }, + { + id: 'about', + title: 'About', + description: 'Build identity of the running instance', + Icon: Info, + }, ] // Sidebar grouping. Each group lists section ids in display order. @@ -96,7 +103,7 @@ const GROUPS: { label: string; ids: string[] }[] = [ { label: 'Account', ids: ['account-security', 'appearance'] }, { label: 'Stacks', ids: ['directories', 'global-env', 'git'] }, { label: 'Automation', ids: ['update-schedule', 'backup'] }, - { label: 'System', ids: ['audit-log'] }, + { label: 'System', ids: ['audit-log', 'about'] }, ] const DEFAULT_SECTION = 'account-security' @@ -352,6 +359,8 @@ export function SettingsPage() { return case 'audit-log': return + case 'about': + return default: return null } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ee77bc1..4557b72 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -414,3 +414,10 @@ export interface BackupOperationResult { runId: string wsUrl: string } + +/** Build identity of the running backend, from GET /api/v1/version. */ +export interface VersionInfo { + version: string + commit: string + buildDate: string +}