feat(server,web): expose server version via /healthz + display in Settings

Came up debugging the in-app update flow — wasn't obvious which image
the container was actually running without exec'ing in. New flow:

### Server
- internal/server/version.go: new `var ServerVersion = "dev"`,
  overridden via -ldflags at build time.
- /healthz response gains a "version" key alongside the existing
  "status" + "min_client_version". Backward-compat: existing clients
  ignore unknown JSON fields. Endpoint stays unauthenticated.

### Build
- Dockerfile: new `ARG MINSTREL_VERSION=dev`, threaded into the
  go build -ldflags so the binary's ServerVersion is stamped at
  link time. Default "dev" preserves local `docker build` ergonomics.
- .forgejo/workflows/release.yml: tags step also emits a `version`
  output (the git tag for tag pushes, "main" for branch pushes);
  build step passes it as `--build-arg MINSTREL_VERSION=...`.

### Web
- web/src/lib/components/ServerVersion.svelte: small understated
  text ("Server v2026.05.10.2") that fetches /healthz on mount.
  Renders nothing on parse failure or pre-version images.
- Mounted at the bottom of Settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 23:13:19 -04:00
parent e16bdd9546
commit 4c4399c9bb
6 changed files with 66 additions and 2 deletions
@@ -0,0 +1,37 @@
<script lang="ts">
import { onMount } from 'svelte';
// Reads /healthz on mount and surfaces the deployed server version
// as understated text. Useful when verifying which image a container
// is actually running (came up debugging the in-app update flow when
// it wasn't obvious whether v2026.05.10.0 or .1 was deployed).
//
// /healthz is unauthenticated, so the bare fetch works without
// credentials. Renders nothing on parse failure or pre-version
// images that don't include the field — graceful degradation.
type Health = { status: string; version?: string };
let version = $state<string | null>(null);
onMount(async () => {
try {
const res = await fetch('/healthz');
if (!res.ok) return;
const body = (await res.json()) as Partial<Health>;
if (body.version && body.version !== 'dev') {
version = body.version;
} else if (body.version === 'dev') {
// Local dev images report "dev" — show it so the operator
// can tell they're not on a release tag.
version = 'dev';
}
} catch {
// network / parse error — silent.
}
});
</script>
{#if version}
<p class="text-xs text-text-secondary">Server {version}</p>
{/if}
+5
View File
@@ -18,6 +18,7 @@
import { errCode } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
import ServerVersion from '$lib/components/ServerVersion.svelte';
const queryClient = useQueryClient();
@@ -326,4 +327,8 @@
</section>
<MobileAppDownload />
<div class="pt-2 text-center">
<ServerVersion />
</div>
</div>