From 0e70eaa410a1cd8d58986289f9176707b858fb2c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 13:38:26 +0000 Subject: [PATCH] Show build version and commit in the UI and logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-in users can answer which build they are running. One helper reads the package version, the image commit and the build date. The startup log, the footer and Settings → Administration all use it. The public health route is unchanged. Refs https://github.com/HelpCode-ai/anythingmcp/issues/1000 Co-authored-by: Lakshitha Deshan (ReadY) --- .github/workflows/docker-publish.yml | 2 + Dockerfile | 12 ++- docs/deployment.md | 15 +++ docs/operations/observability.md | 6 +- .../backend/src/common/app-version.spec.ts | 101 ++++++++++++++++++ packages/backend/src/common/app-version.ts | 72 +++++++++++++ .../backend/src/health/health.controller.ts | 5 + packages/backend/src/health/health.module.ts | 3 +- .../src/health/version.controller.spec.ts | 23 ++++ .../backend/src/health/version.controller.ts | 36 +++++++ packages/backend/src/instrument.ts | 3 +- packages/backend/src/main.ts | 15 +++ .../src/mcp-server/mcp-endpoint.controller.ts | 35 +----- packages/backend/src/tracing.ts | 6 +- .../frontend/src/app/settings/admin/page.tsx | 64 ++++++++++- packages/frontend/src/app/settings/layout.tsx | 2 +- packages/frontend/src/components/footer.tsx | 54 +++++++++- packages/frontend/src/lib/api.ts | 10 ++ packages/frontend/src/lib/instance-version.ts | 24 +++++ 19 files changed, 442 insertions(+), 46 deletions(-) create mode 100644 packages/backend/src/common/app-version.spec.ts create mode 100644 packages/backend/src/common/app-version.ts create mode 100644 packages/backend/src/health/version.controller.spec.ts create mode 100644 packages/backend/src/health/version.controller.ts create mode 100644 packages/frontend/src/lib/instance-version.ts diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f2486244..b435eba7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -69,6 +69,8 @@ jobs: push: true build-args: | SENTRY_RELEASE=${{ github.sha }} + APP_COMMIT=${{ github.sha }} + APP_BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} # Only the source map upload uses it; nothing Sentry-related that # decides where an instance reports is baked into the public image. secrets: | diff --git a/Dockerfile b/Dockerfile index e503ca13..a94fddd7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -120,6 +120,14 @@ ENV NEXT_TELEMETRY_DISABLED=1 # Release reported by backend and frontend when an operator sets SENTRY_DSN. ARG SENTRY_RELEASE= ENV SENTRY_RELEASE=$SENTRY_RELEASE +# Commit and build date shown in the footer and the startup log. A local +# build leaves them empty; the process then reports commit "dev". +ARG APP_COMMIT= +ENV APP_COMMIT=$APP_COMMIT +ARG APP_BUILD_DATE= +ENV APP_BUILD_DATE=$APP_BUILD_DATE +# OCI version label only. The process reads its version from package.json. +ARG APP_VERSION= RUN addgroup --system --gid 1001 appuser && \ adduser --system --uid 1001 appuser @@ -159,7 +167,9 @@ LABEL org.opencontainers.image.title="AnythingMCP" \ org.opencontainers.image.source="https://github.com/HelpCode-ai/anythingmcp" \ org.opencontainers.image.documentation="https://github.com/HelpCode-ai/anythingmcp#readme" \ org.opencontainers.image.vendor="helpcode.ai GmbH" \ - org.opencontainers.image.licenses="AGPL-3.0-only" + org.opencontainers.image.licenses="AGPL-3.0-only" \ + org.opencontainers.image.revision="${APP_COMMIT}" \ + org.opencontainers.image.version="${APP_VERSION}" USER appuser EXPOSE 3000 4000 diff --git a/docs/deployment.md b/docs/deployment.md index fafbdd84..3cc8e3d1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -178,6 +178,21 @@ curl http://localhost:4000/health The container runs `prisma migrate deploy` on startup, then launches both backend and frontend. +### Build provenance + +The running process reads its version from `packages/backend/package.json`. The git commit and the build date come from image build arguments. A local `docker compose build` passes neither, so the footer and the startup log show commit `dev` and `built unknown`. + +Self-hosted and Cloud both show the same values. Cloud runs the published image, so the commit is the one that image was built from. Either way, signed-in users see them in the footer and admins can copy them from Settings → Administration. `GET /api/version` requires a JWT. The public `/health/server-info` route does not include the version. + +```bash +docker build \ + --build-arg APP_COMMIT=$(git rev-parse HEAD) \ + --build-arg APP_BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ + . +``` + +`APP_COMMIT` has to be the full 40-character SHA. The footer links that SHA to the commit and shows the first seven characters; `dev` stays plain text. `APP_VERSION` is optional and only fills the `org.opencontainers.image.version` label. The publish workflow sets `APP_COMMIT` and `APP_BUILD_DATE`. + ### Without Docker ```bash diff --git a/docs/operations/observability.md b/docs/operations/observability.md index fbfb44c9..4563d240 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -39,7 +39,7 @@ What leaves the process is an allowlist (`packages/backend/src/common/sentry-scr |---|---|---| | `SENTRY_DSN` | unset | enables reporting for the process that reads it | | `SENTRY_ENVIRONMENT` | `NODE_ENV` | environment tag | -| `SENTRY_RELEASE` | image build | release tag; the published image sets it to the commit | +| `SENTRY_RELEASE` | image build | release tag; the published image sets it to the commit. Unset, the backend reports `APP_VERSION` | | `SENTRY_TRACES_SAMPLE_RATE` | 0 | backend (or Next.js server) transactions | | `SENTRY_PROFILES_SAMPLE_RATE` | 0 | backend CPU profiling | | `SENTRY_BROWSER_TRACES_SAMPLE_RATE` | 0 | browser transactions (frontend container) | @@ -64,9 +64,11 @@ Service identity: | Env | Default | |---|---| | `OTEL_SERVICE_NAME` | `anythingmcp-backend` | -| `OTEL_SERVICE_VERSION` | `npm_package_version` | +| `OTEL_SERVICE_VERSION` | `APP_VERSION` | | `OTEL_DEPLOYMENT_ENVIRONMENT` | `NODE_ENV` | +`service.version` is `OTEL_SERVICE_VERSION` when that is set, and otherwise `APP_VERSION` (the version in the backend `package.json`). Inside the image `npm_package_version` is unset, because the backend starts with `node dist/src/main.js`. + Auth: standard `OTEL_EXPORTER_OTLP_HEADERS` (e.g. `authorization=Bearer …`). Suggested collectors: diff --git a/packages/backend/src/common/app-version.spec.ts b/packages/backend/src/common/app-version.spec.ts new file mode 100644 index 00000000..a67937ae --- /dev/null +++ b/packages/backend/src/common/app-version.spec.ts @@ -0,0 +1,101 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + APP_BUILD_DATE, + APP_COMMIT, + APP_VERSION, + formatVersionLogLine, + readAppBuildDate, + readAppCommit, + readAppVersion, + shortCommit, +} from './app-version'; + +const FULL_SHA = '3bd9f8b0a1c2d3e4f567890abcdef1234567890a'; + +describe('app-version', () => { + it('reads the backend package.json version', () => { + const pkg = JSON.parse( + readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'), + ) as { version: string }; + expect(APP_VERSION).toBe(pkg.version); + expect(readAppVersion()).toBe(pkg.version); + }); + + it('falls back to 0.0.0 when no package.json is found', () => { + expect(readAppVersion('/definitely-missing-amcp/a/b')).toBe('0.0.0'); + }); + + describe('readAppCommit', () => { + it('falls back to dev when unset', () => { + expect(readAppCommit({})).toBe('dev'); + }); + + it('prefers APP_COMMIT over SENTRY_RELEASE', () => { + expect(readAppCommit({ APP_COMMIT: FULL_SHA, SENTRY_RELEASE: 'other' })).toBe(FULL_SHA); + }); + + it('uses SENTRY_RELEASE when APP_COMMIT is unset', () => { + expect(readAppCommit({ SENTRY_RELEASE: FULL_SHA })).toBe(FULL_SHA); + }); + + it('treats an empty APP_COMMIT as unset', () => { + expect(readAppCommit({ APP_COMMIT: '', SENTRY_RELEASE: FULL_SHA })).toBe(FULL_SHA); + expect(readAppCommit({ APP_COMMIT: '' })).toBe('dev'); + }); + }); + + describe('readAppBuildDate', () => { + it('defaults to null', () => { + expect(readAppBuildDate({})).toBeNull(); + }); + + it('reads APP_BUILD_DATE', () => { + expect(readAppBuildDate({ APP_BUILD_DATE: '2026-10-04' })).toBe('2026-10-04'); + }); + + it('treats an empty value as unset', () => { + expect(readAppBuildDate({ APP_BUILD_DATE: '' })).toBeNull(); + }); + }); + + it('exports the values read from the process environment', () => { + expect(APP_COMMIT).toBe(readAppCommit()); + expect(APP_BUILD_DATE).toBe(readAppBuildDate()); + }); + + describe('shortCommit', () => { + it('shortens a 40-hex SHA to seven characters', () => { + expect(shortCommit(FULL_SHA)).toBe('3bd9f8b'); + }); + + it('leaves dev and an already-short value unchanged', () => { + expect(shortCommit('dev')).toBe('dev'); + expect(shortCommit('3bd9f8b')).toBe('3bd9f8b'); + }); + }); + + describe('formatVersionLogLine', () => { + it('prints the short SHA and the build date', () => { + expect( + formatVersionLogLine({ + version: '0.18.0', + commit: FULL_SHA, + buildDate: '2026-10-04', + deploymentMode: 'self-hosted', + }), + ).toBe('AnythingMCP v0.18.0 (commit 3bd9f8b, built 2026-10-04, mode self-hosted)'); + }); + + it('prints dev and built unknown when the image has no provenance', () => { + expect( + formatVersionLogLine({ + version: '0.24.0', + commit: 'dev', + buildDate: null, + deploymentMode: 'cloud', + }), + ).toBe('AnythingMCP v0.24.0 (commit dev, built unknown, mode cloud)'); + }); + }); +}); diff --git a/packages/backend/src/common/app-version.ts b/packages/backend/src/common/app-version.ts new file mode 100644 index 00000000..5a665f28 --- /dev/null +++ b/packages/backend/src/common/app-version.ts @@ -0,0 +1,72 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +/** + * Backend version, reported as the demo server's version (was a hardcoded 1.0.0). + * + * Resolved by walking up from this file rather than with a fixed relative path: + * tsc emits to `dist/src/...`, so the package.json sits one level further up in + * the build than in the source tree, and the runtime image moves it again + * (`/app/backend/package.json` against `/app/backend/dist/src/common/`). + * A hardcoded `../../package.json` is correct in exactly one of those three + * layouts, and being wrong throws MODULE_NOT_FOUND at import time — which takes + * the whole backend down for a string that appears in one handshake field. + * Hence also the try/catch: this can degrade, it cannot fail to boot. + */ +export function readAppVersion(startDir: string = __dirname): string { + let dir = startDir; + for (let i = 0; i < 6; i++) { + try { + const pkg = JSON.parse( + readFileSync(join(dir, 'package.json'), 'utf8'), + ) as { name?: string; version?: string }; + if (pkg.name && pkg.version) return pkg.version; + } catch { + /* keep walking */ + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return '0.0.0'; +} + +/** + * Commit baked into the image. `APP_COMMIT` wins; published images that only + * set `SENTRY_RELEASE` still report that sha. Source checkouts and local + * image builds have neither, and show `dev`. + */ +export function readAppCommit(env: NodeJS.ProcessEnv = process.env): string { + return env.APP_COMMIT || env.SENTRY_RELEASE || 'dev'; +} + +/** Set by the image build. Local builds leave it unset. */ +export function readAppBuildDate(env: NodeJS.ProcessEnv = process.env): string | null { + return env.APP_BUILD_DATE || null; +} + +export const APP_VERSION: string = readAppVersion(); +export const APP_COMMIT: string = readAppCommit(); +export const APP_BUILD_DATE: string | null = readAppBuildDate(); + +/** Seven characters of a 40-hex SHA. `dev` and an already-short value stay as written. */ +export function shortCommit(commit: string): string { + return /^[0-9a-f]{40}$/.test(commit) ? commit.slice(0, 7) : commit; +} + +/** + * Startup line. The commit is shortened for the log; `GET /api/version` keeps + * the full value so the footer can link to it. + */ +export function formatVersionLogLine(input: { + version: string; + commit: string; + buildDate: string | null; + deploymentMode: string; +}): string { + const built = input.buildDate || 'unknown'; + return ( + `AnythingMCP v${input.version} ` + + `(commit ${shortCommit(input.commit)}, built ${built}, mode ${input.deploymentMode})` + ); +} diff --git a/packages/backend/src/health/health.controller.ts b/packages/backend/src/health/health.controller.ts index 56d57735..04b857f5 100644 --- a/packages/backend/src/health/health.controller.ts +++ b/packages/backend/src/health/health.controller.ts @@ -27,6 +27,11 @@ export class HealthController { private readonly deployment: DeploymentService, ) {} + /** + * Public. Version and commit are intentionally absent: a scanner should + * not learn the build from an unauthenticated probe. Signed-in clients + * use `GET /api/version`. + */ @Get('server-info') async getServerInfo() { const authMode = this.configService.get('MCP_AUTH_MODE') || 'none'; diff --git a/packages/backend/src/health/health.module.ts b/packages/backend/src/health/health.module.ts index 42c720a2..98b541de 100644 --- a/packages/backend/src/health/health.module.ts +++ b/packages/backend/src/health/health.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { TerminusModule } from '@nestjs/terminus'; import { HealthController } from './health.controller'; import { SentryVerifyController } from './sentry-verify.controller'; +import { VersionController } from './version.controller'; import { UsersModule } from '../users/users.module'; import { ProcessVitalsService } from '../common/process-vitals.service'; @Module({ imports: [TerminusModule, UsersModule], - controllers: [HealthController, SentryVerifyController], + controllers: [HealthController, SentryVerifyController, VersionController], providers: [ProcessVitalsService], }) export class HealthModule {} diff --git a/packages/backend/src/health/version.controller.spec.ts b/packages/backend/src/health/version.controller.spec.ts new file mode 100644 index 00000000..19a59245 --- /dev/null +++ b/packages/backend/src/health/version.controller.spec.ts @@ -0,0 +1,23 @@ +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { AuthGuard } from '@nestjs/passport'; +import { APP_BUILD_DATE, APP_COMMIT, APP_VERSION } from '../common/app-version'; +import { DeploymentService } from '../common/deployment.service'; +import { VersionController } from './version.controller'; + +describe('VersionController', () => { + it('requires a JWT', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, VersionController); + expect(guards).toEqual([AuthGuard('jwt')]); + }); + + it('returns the full commit and the deployment mode', () => { + const deployment = { mode: 'self-hosted' } as DeploymentService; + const body = new VersionController(deployment).getVersion(); + expect(body).toEqual({ + version: APP_VERSION, + commit: APP_COMMIT, + buildDate: APP_BUILD_DATE, + deploymentMode: 'self-hosted', + }); + }); +}); diff --git a/packages/backend/src/health/version.controller.ts b/packages/backend/src/health/version.controller.ts new file mode 100644 index 00000000..04ea35de --- /dev/null +++ b/packages/backend/src/health/version.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { APP_BUILD_DATE, APP_COMMIT, APP_VERSION } from '../common/app-version'; +import { DeploymentService } from '../common/deployment.service'; + +/** + * Build identity for signed-in users. + * + * Kept off `/health/server-info`. That route is unauthenticated, and a + * version there is a free fingerprint for vulnerability scanners. + */ +@ApiTags('Version') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller('api') +export class VersionController { + constructor(private readonly deployment: DeploymentService) {} + + @Get('version') + @ApiOperation({ summary: 'Version, commit and deployment mode of this instance' }) + getVersion(): { + version: string; + commit: string; + buildDate: string | null; + deploymentMode: string; + } { + return { + version: APP_VERSION, + // Full SHA. The startup log and the footer shorten it for display. + commit: APP_COMMIT, + buildDate: APP_BUILD_DATE, + deploymentMode: this.deployment.mode, + }; + } +} diff --git a/packages/backend/src/instrument.ts b/packages/backend/src/instrument.ts index 3c478c57..d18334c8 100644 --- a/packages/backend/src/instrument.ts +++ b/packages/backend/src/instrument.ts @@ -11,6 +11,7 @@ * ./common/sentry-scrub.ts for the exact rules. */ import * as Sentry from '@sentry/nestjs'; +import { APP_VERSION } from './common/app-version'; import { scrubBreadcrumb, scrubEvent } from './common/sentry-scrub'; import { sampleRateFor } from './common/sentry-sampler'; @@ -28,7 +29,7 @@ if (dsn) { process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'development', - release: process.env.SENTRY_RELEASE || process.env.npm_package_version, + release: process.env.SENTRY_RELEASE || APP_VERSION, // Tracing is opt-in on top of error reporting because it adds overhead. // SENTRY_TRACES_SAMPLE_RATE applies to HTTP requests; MCP calls take diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index d0dfbe49..0fddcd1e 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -28,6 +28,13 @@ import helmet from 'helmet'; import { AppModule } from './app.module'; import { mcpStrategy } from './mcp-server/mcp-strategy'; import { McpAuthExceptionFilter } from './auth/mcp-auth-exception.filter'; +import { + APP_BUILD_DATE, + APP_COMMIT, + APP_VERSION, + formatVersionLogLine, +} from './common/app-version'; +import { DeploymentService } from './common/deployment.service'; import { validateRequiredSecretsAtStartup } from './common/secrets.util'; async function bootstrap() { @@ -206,6 +213,14 @@ async function bootstrap() { }); } + logger.log( + formatVersionLogLine({ + version: APP_VERSION, + commit: APP_COMMIT, + buildDate: APP_BUILD_DATE, + deploymentMode: app.get(DeploymentService).mode, + }), + ); logger.log(`AnythingMCP backend running on: http://localhost:${port}`); logger.log(`Swagger docs: http://localhost:${port}/api/docs`); logger.log(`MCP endpoint (global): http://localhost:${port}/mcp`); diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index df070ae3..eff17990 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -27,6 +27,7 @@ import { toolVisibilityRole } from './mcp-server.service'; import { McpServersService } from '../mcp-servers/mcp-servers.service'; import { McpSessionManager } from '../mcp-servers/mcp-session.manager'; import { processGauges } from '../common/process-vitals'; +import { APP_VERSION } from '../common/app-version'; import { ToolRegistry, RegisteredTool, isListable } from './tool-registry'; import { SharedSetupRegistry } from './shared-setup'; import { @@ -77,40 +78,6 @@ import { planServerResources, } from './mcp-resources'; import { jsonSchemaToZodShape, stripEnvVarParams } from './tool-schema.util'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; - -/** - * Backend version, reported as the demo server's version (was a hardcoded 1.0.0). - * - * Resolved by walking up from this file rather than with a fixed relative path: - * tsc emits to `dist/src/...`, so the package.json sits one level further up in - * the build than in the source tree, and the runtime image moves it again - * (`/app/backend/package.json` against `/app/backend/dist/src/mcp-server/`). - * A hardcoded `../../package.json` is correct in exactly one of those three - * layouts, and being wrong throws MODULE_NOT_FOUND at import time — which takes - * the whole backend down for a string that appears in one handshake field. - * Hence also the try/catch: this can degrade, it cannot fail to boot. - */ -function readAppVersion(): string { - let dir = __dirname; - for (let i = 0; i < 6; i++) { - try { - const pkg = JSON.parse( - readFileSync(join(dir, 'package.json'), 'utf8'), - ) as { name?: string; version?: string }; - if (pkg.name && pkg.version) return pkg.version; - } catch { - /* keep walking */ - } - const parent = dirname(dir); - if (parent === dir) break; - dir = parent; - } - return '0.0.0'; -} - -const APP_VERSION: string = readAppVersion(); function trimSlash(url: string | undefined): string { return (url || '').trim().replace(/\/+$/, ''); diff --git a/packages/backend/src/tracing.ts b/packages/backend/src/tracing.ts index e0a11fa5..33d66cf9 100644 --- a/packages/backend/src/tracing.ts +++ b/packages/backend/src/tracing.ts @@ -16,6 +16,7 @@ import { NodeSDK } from '@opentelemetry/sdk-node'; import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { resourceFromAttributes } from '@opentelemetry/resources'; +import { APP_VERSION } from './common/app-version'; // Use string keys directly so we don't need the /incubating subpath, which // requires nodenext module resolution. These constants are stable in the // OTel semantic conventions and aren't going to be renamed. @@ -30,10 +31,7 @@ export function startTracing(): void { const sdk = new NodeSDK({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'anythingmcp-backend', - [ATTR_SERVICE_VERSION]: - process.env.OTEL_SERVICE_VERSION || - process.env.npm_package_version || - '0.0.0', + [ATTR_SERVICE_VERSION]: process.env.OTEL_SERVICE_VERSION || APP_VERSION, [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: process.env.OTEL_DEPLOYMENT_ENVIRONMENT || process.env.NODE_ENV || diff --git a/packages/frontend/src/app/settings/admin/page.tsx b/packages/frontend/src/app/settings/admin/page.tsx index e947259c..2a36c469 100644 --- a/packages/frontend/src/app/settings/admin/page.tsx +++ b/packages/frontend/src/app/settings/admin/page.tsx @@ -3,11 +3,12 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useAuth } from '@/lib/auth-context'; -import { adminSettings, type AlertWebhookInput } from '@/lib/api'; +import { adminSettings, versionApi, type AlertWebhookInput, type InstanceVersion } from '@/lib/api'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { AppSelect } from '@/components/ui/select'; import { copyText } from '@/lib/clipboard'; +import { commitUrl, shortCommit, versionInfoText } from '@/lib/instance-version'; const ALERT_WEBHOOK_TYPE_OPTIONS = [ { value: 'json', label: 'Generic JSON webhook' }, @@ -50,6 +51,10 @@ export default function SettingsAdminPage() { // The URL the backend has, so a test is never sent to an unsaved edit. const [savedAlertUrl, setSavedAlertUrl] = useState(''); + const [versionInfo, setVersionInfo] = useState(null); + const [versionUnavailable, setVersionUnavailable] = useState(false); + const [versionCopied, setVersionCopied] = useState(false); + useEffect(() => { if (!token) return; @@ -64,6 +69,14 @@ export default function SettingsAdminPage() { adminSettings.getFooterLinks(token).then(setFooterLinks).catch(() => {}); + versionApi.get(token).then((info) => { + setVersionInfo(info); + setVersionUnavailable(false); + }).catch(() => { + setVersionInfo(null); + setVersionUnavailable(true); + }); + adminSettings.getAlertWebhook(token).then((data) => { if (!('url' in data)) return; setAlertConfigured(true); @@ -203,6 +216,15 @@ export default function SettingsAdminPage() { } }; + const handleCopyVersion = async () => { + if (!versionInfo) return; + const ok = await copyText(versionInfoText(versionInfo)); + if (ok) { + setVersionCopied(true); + setTimeout(() => setVersionCopied(false), 2000); + } + }; + const handleSaveFooterLinks = async () => { if (!token) return; try { @@ -230,8 +252,48 @@ export default function SettingsAdminPage() { 'w-full h-9 rounded-[9px] border border-[var(--border)] bg-[var(--surface)] px-3 text-sm text-[var(--text)] outline-none focus:border-[var(--brand)]'; const labelClass = 'block text-[12.5px] font-medium text-[var(--text-2)] mb-1'; + const shaUrl = versionInfo ? commitUrl(versionInfo.commit) : null; + return (
+ +

About this instance

+

+ Version and commit to quote in a bug report. The same values appear in the footer and in the backend startup log. +

+ {versionUnavailable && ( +

Version info unavailable

+ )} + {!versionInfo && !versionUnavailable && ( +

Loading…

+ )} + {versionInfo && ( +
+
+
Version
+
v{versionInfo.version}
+
Commit
+
+ {shaUrl ? ( + + {shortCommit(versionInfo.commit)} + + ) : ( + shortCommit(versionInfo.commit) + )} +
+
Built
+
{versionInfo.buildDate || 'unknown'}
+
Mode
+
{versionInfo.deploymentMode}
+
+ +
+ )} +
+ {/* SMTP Configuration */}

Email / SMTP Configuration

diff --git a/packages/frontend/src/app/settings/layout.tsx b/packages/frontend/src/app/settings/layout.tsx index 71b84652..208a5ae1 100644 --- a/packages/frontend/src/app/settings/layout.tsx +++ b/packages/frontend/src/app/settings/layout.tsx @@ -43,7 +43,7 @@ const SIDEBAR_SECTIONS: SidebarSection[] = [ // showing the entry there would lead an admin to a page that 404s. { href: '/settings/identity-providers', label: 'Single sign-on', description: 'Microsoft, Google, Okta', icon: FingerprintIcon, adminOnly: true, selfHostedOnly: true }, { href: '/settings/license', label: 'License', description: 'Plan, billing, subscription', icon: KeyIcon, adminOnly: true }, - { href: '/settings/admin', label: 'Administration', description: 'SMTP, alerts, footer links', icon: WrenchIcon, adminOnly: true }, + { href: '/settings/admin', label: 'Administration', description: 'Version, SMTP, alerts, links', icon: WrenchIcon, adminOnly: true }, ], }, ]; diff --git a/packages/frontend/src/components/footer.tsx b/packages/frontend/src/components/footer.tsx index db94c3fb..1e3d58e4 100644 --- a/packages/frontend/src/components/footer.tsx +++ b/packages/frontend/src/components/footer.tsx @@ -2,7 +2,9 @@ import { useState, useEffect } from 'react'; import { useTheme } from '@/lib/theme-context'; -import { siteSettings } from '@/lib/api'; +import { useAuth } from '@/lib/auth-context'; +import { siteSettings, versionApi, type InstanceVersion } from '@/lib/api'; +import { commitUrl, shortCommit } from '@/lib/instance-version'; function SunIcon({ size = 16 }: { size?: number }) { return ( @@ -37,19 +39,49 @@ interface FooterLink { url: string; } +// One request per session. A failed call is not cached, so the next mount +// retries (a token that expires at that moment should not hide the version). +let versionPromise: Promise | null = null; + +function getVersionOnce(token: string): Promise { + if (!versionPromise) { + versionPromise = versionApi.get(token).catch((err) => { + versionPromise = null; + throw err; + }); + } + return versionPromise; +} + export function Footer() { const { theme, setTheme } = useTheme(); + const { token } = useAuth(); const [links, setLinks] = useState([]); + const [version, setVersion] = useState(null); useEffect(() => { siteSettings.footerLinks().then(setLinks).catch(() => {}); }, []); + useEffect(() => { + if (!token) return; + let cancelled = false; + getVersionOnce(token) + .then((info) => { + if (!cancelled) setVersion(info); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [token]); + const themeOptions = [ { value: 'light' as const, label: 'Light', icon: SunIcon }, { value: 'dark' as const, label: 'Dark', icon: MoonIcon }, { value: 'system' as const, label: 'System', icon: MonitorIcon }, ]; + const shaUrl = version ? commitUrl(version.commit) : null; return (
@@ -76,6 +108,26 @@ export function Footer() { ))} )} + {version && ( + <> + · + v{version.version} + · + {shaUrl ? ( + + {shortCommit(version.commit)} + + ) : ( + {shortCommit(version.commit)} + )} + + )}
{/* Right: Theme toggle */} diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index ab2b4244..54d002df 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -1,4 +1,7 @@ import type { ClickIds, SignupAttribution } from './attribution'; +import type { InstanceVersion } from './instance-version'; + +export type { InstanceVersion }; // Use relative paths so requests go to the same origin. // In production (Docker / Railway) Next.js rewrites proxy /api/* to the backend. @@ -978,6 +981,13 @@ export interface KgSkill { status: string; } +// Build identity. Signed-in only: `/api/version` answers 401 without a JWT. +// A 401 here must not log the user out; the footer treats it as "try again". +export const versionApi = { + get: (token: string) => + request('/api/version', { token, skipAutoLogout: true }), +}; + // Server settings (public) export const server = { info: () => diff --git a/packages/frontend/src/lib/instance-version.ts b/packages/frontend/src/lib/instance-version.ts new file mode 100644 index 00000000..42028ef0 --- /dev/null +++ b/packages/frontend/src/lib/instance-version.ts @@ -0,0 +1,24 @@ +export interface InstanceVersion { + version: string; + commit: string; + buildDate: string | null; + deploymentMode: string; +} + +/** Seven characters of a 40-hex SHA. `dev` stays plain text. Same rule as the backend startup log. */ +export function shortCommit(commit: string): string { + return /^[0-9a-f]{40}$/.test(commit) ? commit.slice(0, 7) : commit; +} + +/** Link target for a full git SHA. `dev` and a short value have none. */ +export function commitUrl(commit: string): string | null { + return /^[0-9a-f]{40}$/.test(commit) + ? `https://github.com/HelpCode-ai/anythingmcp/commit/${commit}` + : null; +} + +/** Text copied from Settings → Administration. The commit is the full value. */ +export function versionInfoText(info: InstanceVersion): string { + const built = info.buildDate || 'unknown'; + return `AnythingMCP v${info.version} (commit ${info.commit}, built ${built}, mode ${info.deploymentMode})`; +}