diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index be56403..97aeb11 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -68,6 +68,17 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=sha,format=short + # The version tpk reports at runtime (#85): the release tag without its + # "v", or dev- for branch / PR builds. + - name: tpk version + id: tpkver + run: | + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + else + echo "version=dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + fi + - name: Build and push uses: docker/build-push-action@v6 with: @@ -78,6 +89,9 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + TPK_VERSION=${{ steps.tpkver.outputs.version }} + TPK_COMMIT=${{ github.sha }} cache-from: type=gha,scope=app cache-to: type=gha,mode=max,scope=app @@ -124,6 +138,17 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=sha,format=short + # The version tpk reports at runtime (#85): the release tag without its + # "v", or dev- for branch / PR builds. + - name: tpk version + id: tpkver + run: | + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + else + echo "version=dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + fi + - name: Build and push uses: docker/build-push-action@v6 with: @@ -134,5 +159,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + TPK_VERSION=${{ steps.tpkver.outputs.version }} + TPK_COMMIT=${{ github.sha }} cache-from: type=gha,scope=allinone cache-to: type=gha,mode=max,scope=allinone diff --git a/README.md b/README.md index 4607620..2754e41 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ export the matching env var — whichever suits your deployment. Secrets are | `TIMEPLUS_PORT` | `[db].port` | `8123` | DB HTTP port | | `TIMEPLUS_DATABASE` | `[db].database` | `tpk` | Database all tpk streams live under | | `TPK_DB_BACKEND` | `[db].backend` | `timeplusd` | Stream-semantics mode (`timeplusd`\|`proton`) | -| `TPK_STREAM_PREFIX` | `[db].stream_prefix` | `` | Namespace prefix for all streams | +| `TPK_STREAM_PREFIX` | `[db].stream_prefix` | `` | Namespace prefix for all streams — honoured by every entry point (`serve`, `ingest`, `export`/`import`, `auth`, stdio MCP) | | `TPK_DB_WAIT_SECONDS` | `[db].wait_seconds` | `60` | `serve`: DB connect retry budget | | `TPK_AGENT_PROVIDER` | `[agent].provider` | auto | Chat LLM backend (`anthropic`\|`openai`) | | `TPK_AGENT_MODEL` | `[agent].model` | per-provider | Chat model override | @@ -356,7 +356,15 @@ counts being bit-for-bit reproducible across ingests of the same commit. /chat`, Server-Sent Events: each event is a `data: {...}\n\n` line with `type` one of `token` | `tool` | `done` | `error`) over the knowledge graph, plus the built React web UI at `/` (mounted from `web/dist` when present) and -`GET /healthz`. +`GET /healthz` — unauthenticated, returning `{"status": "ok", "version": +"0.0.5", "commit": "bc549ef"}`. + +**Which version is running?** The same string everywhere: `tpk --version`, +`/healthz`, the web UI (sidebar footer and login page), the `tpk serve` startup +line, and MCP `serverInfo.version`. Published images report their release tag +(the Docker workflow bakes `TPK_VERSION` / `TPK_COMMIT` in), a git checkout +reports `git describe` (e.g. `0.0.5-3-gbc549ef`), and a local `docker build` +without those build-args reports `dev`. The agent needs its own LLM configuration, separate from graphify's extraction backend — set in `.env` or the shell: diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index c74c3df..aa82435 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -52,10 +52,16 @@ RUN printf '#!/bin/sh\nPATH=/opt/tpk/.venv/bin:$PATH exec /opt/tpk/.venv/bin/tpk && chmod +x /usr/local/bin/tpk /usr/local/bin/tpk-mcp \ && mkdir -p /opt/tpk/.checkouts # TIMEPLUS_HOST is overridden at runtime to point at the db service. +# The running version (#85). The build context has no .git, so the image can't +# work it out: the publish workflow passes the release tag + commit in. +ARG TPK_VERSION=dev +ARG TPK_COMMIT= ENV TIMEPLUS_HOST=localhost \ TIMEPLUS_USER=default \ PYTHONUNBUFFERED=1 \ - TPK_CHECKOUT_DIR=/opt/tpk/.checkouts + TPK_CHECKOUT_DIR=/opt/tpk/.checkouts \ + TPK_VERSION=${TPK_VERSION} \ + TPK_COMMIT=${TPK_COMMIT} EXPOSE 8000 ENTRYPOINT ["tpk"] CMD ["serve", "--host", "0.0.0.0", "--port", "8000"] @@ -86,11 +92,15 @@ RUN chmod +x /usr/local/bin/render-users.sh /usr/local/bin/allinone-entrypoint.s && chmod +x /usr/local/bin/tpk /usr/local/bin/tpk-mcp \ && mkdir -p /opt/tpk/.checkouts \ && chown -R 101:101 /opt/tpk +ARG TPK_VERSION=dev +ARG TPK_COMMIT= ENV TIMEPLUS_HOST=localhost \ TIMEPLUS_USER=default \ TPK_DB_BACKEND=proton \ PYTHONUNBUFFERED=1 \ - TPK_CHECKOUT_DIR=/opt/tpk/.checkouts + TPK_CHECKOUT_DIR=/opt/tpk/.checkouts \ + TPK_VERSION=${TPK_VERSION} \ + TPK_COMMIT=${TPK_COMMIT} EXPOSE 8000 ENTRYPOINT ["/usr/local/bin/allinone-entrypoint.sh"] USER 101:101 diff --git a/pyproject.toml b/pyproject.toml index f77d4af..cce84dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,8 @@ [project] name = "tpk" -version = "0.1.0" +# Placeholder only -- hatchling needs a static value. The REAL version comes from +# the release tag (see src/tpk/version.py) and this number is reported nowhere. +version = "0.0.0" description = "Timeplus knowledge graph toolkit" authors = [ { email = "gang@timeplus.io" } diff --git a/src/tpk/__init__.py b/src/tpk/__init__.py index 3dc1f76..c2ad1cd 100644 --- a/src/tpk/__init__.py +++ b/src/tpk/__init__.py @@ -1 +1,5 @@ -__version__ = "0.1.0" +from tpk.version import get_version + +# Resolved from the release tag (image build env) or `git describe`; see +# tpk/version.py. Not a hand-edited constant (#85). +__version__ = get_version()[0] diff --git a/src/tpk/cli.py b/src/tpk/cli.py index e014c38..cfdd9c8 100644 --- a/src/tpk/cli.py +++ b/src/tpk/cli.py @@ -50,6 +50,24 @@ def _progress_line( app = typer.Typer(help="Timeplus knowledge graph toolkit") + +def _print_version(value: bool) -> None: + if value: + from tpk.version import version_string + + typer.echo(f"tpk {version_string()}") + raise typer.Exit() + + +@app.callback() +def _main( + version: bool = typer.Option( + False, "--version", callback=_print_version, is_eager=True, + help="Show the tpk version and exit", + ), +): + """Timeplus knowledge graph toolkit""" + REPOS_TOML = config_path() @@ -218,17 +236,25 @@ def serve( Settings.from_env(), timeout_s=setting("TPK_DB_WAIT_SECONDS", "db", "wait_seconds", 60.0, cast=float), ) - db.ensure_schema(client) + # The whole server runs on the configured stream prefix, like ingest / + # export / import / auth do (#84) -- otherwise a prefixed deployment would + # ingest into one set of streams and serve from another. + prefix = setting("TPK_STREAM_PREFIX", "db", "stream_prefix", "") + db.ensure_schema(client, prefix) if REPOS_TOML.exists(): - corpus.seed_from_toml(client, REPOS_TOML) + corpus.seed_from_toml(client, REPOS_TOML, prefix=prefix) # Eager bootstrap (issue #6): seed the admin user before uvicorn starts # serving. `serve` here always runs as a single uvicorn process (no # `workers=` argument), so the list_users-then-upsert_user race in # seed_admin() cannot happen between two processes of this command; and # even if it somehow raced, kg_users' PRIMARY KEY makes a double-seed of # the same "admin" row converge to one final row anyway. - auth_mod.seed_admin(client) - uvicorn.run(create_app(), host=host, port=port) + auth_mod.seed_admin(client, prefix) + from tpk.version import version_string + + typer.echo(f"tpk {version_string()} starting on {host}:{port}" + + (f" (stream prefix {prefix!r})" if prefix else "")) + uvicorn.run(create_app(stream_prefix=prefix), host=host, port=port) if __name__ == "__main__": diff --git a/src/tpk/mcp_server.py b/src/tpk/mcp_server.py index 10c93ba..50c9737 100644 --- a/src/tpk/mcp_server.py +++ b/src/tpk/mcp_server.py @@ -15,6 +15,7 @@ from tpk.config import Settings, config_path, load_repos from tpk.config import repo_paths as resolved_repo_paths from tpk.tools import KnowledgeGraph +from tpk.version import get_version REPOS_TOML = config_path() @@ -32,7 +33,7 @@ def build_server(kg, guard=None) -> FastMCP: mcp_http.make_guard(), which applies the caller's role scope and the source:view gate (#74).""" run = guard or _unguarded - server = FastMCP("timeplus-knowledge") + server = FastMCP("timeplus-knowledge", version=get_version()[0]) @server.tool() async def search_entities(ctx: Context, query: str, kinds: list[str] | None = None, @@ -90,7 +91,7 @@ def main() -> None: settings = Settings.from_env() try: client = db.get_client(settings) - db.ensure_schema(client) + db.ensure_schema(client, settings.stream_prefix) except Exception as exc: # An MCP client shows a crashed stdio server only as "Connection # closed" and hides the traceback -- so say why in ONE line (#77). @@ -107,9 +108,11 @@ def main() -> None: file=sys.stderr, ) sys.exit(1) - corpus.seed_from_toml(client, REPOS_TOML) + # Same stream prefix as `tpk serve` and the rest of the CLI (#84). + prefix = settings.stream_prefix + corpus.seed_from_toml(client, REPOS_TOML, prefix=prefix) repos = load_repos(REPOS_TOML) - kg = KnowledgeGraph(client, repo_paths=resolved_repo_paths(repos)) + kg = KnowledgeGraph(client, stream_prefix=prefix, repo_paths=resolved_repo_paths(repos)) build_server(kg).run() # stdio transport diff --git a/src/tpk/server.py b/src/tpk/server.py index 953dcb6..10ad964 100644 --- a/src/tpk/server.py +++ b/src/tpk/server.py @@ -101,7 +101,7 @@ def _usage_tokens(msg) -> int: return 0 -def _build_kg_and_repos(): +def _build_kg_and_repos(prefix: str = ""): """Production path: one shared `KnowledgeGraph` (+ the parsed repo config it needs) built up front in `create_app`, reused by both the lazily-built chat agent and the graph API router -- instead of each @@ -114,16 +114,17 @@ def _build_kg_and_repos(): repos = load_repos(REPOS_TOML) settings = Settings.from_env() client = db.get_client(settings) - db.ensure_schema(client) - corpus.seed_from_toml(client, REPOS_TOML) + db.ensure_schema(client, prefix) + corpus.seed_from_toml(client, REPOS_TOML, prefix=prefix) kg = KnowledgeGraph( client, + stream_prefix=prefix, repo_paths=resolved_repo_paths(repos), ) return kg, repos -def _build_production_agent(kg, repos): +def _build_production_agent(kg, repos, prefix: str = ""): from tpk import corpus, db from tpk.agent import build_agent from tpk.config import AgentConfig, Settings @@ -142,7 +143,7 @@ def _build_production_agent(kg, repos): def _live_corpus(): with provider_lock: - return [e for e in corpus.list_entries(provider_client) if e.enabled] + return [e for e in corpus.list_entries(provider_client, prefix=prefix) if e.enabled] return build_agent( kg, @@ -204,7 +205,7 @@ async def _lifespan(_app): # (fake/no-op) and, when it wants the graph router mounted, its own # `kg` built over the test stream prefix -- so this branch never # runs there and never touches the network in unit tests. - kg, repos_for_agent = _build_kg_and_repos() + kg, repos_for_agent = _build_kg_and_repos(stream_prefix) # Per-user daily token budget (#62). Always-on in production (not gated # by the audit toggle); a fresh client per read/write. Tests inject # their own `usage` (or leave it None to disable enforcement). @@ -219,12 +220,17 @@ async def _lifespan(_app): def _agent(): if state["agent"] is None: - state["agent"] = _build_production_agent(kg, repos_for_agent) + state["agent"] = _build_production_agent(kg, repos_for_agent, stream_prefix) return state["agent"] @app.get("/healthz") def healthz(): - return {"status": "ok"} + # Unauthenticated on purpose: a version string is not sensitive, and + # it makes "is the rollout live?" a one-line curl (#85). + from tpk.version import get_version + + version, commit = get_version() + return {"status": "ok", "version": version, "commit": commit} @app.get("/chat/model") def chat_model(user: User = Depends(auth.require_cap(auth_mod.CAP_CHAT))): diff --git a/src/tpk/version.py b/src/tpk/version.py new file mode 100644 index 0000000..4a3e004 --- /dev/null +++ b/src/tpk/version.py @@ -0,0 +1,43 @@ +"""The running tpk version (#85). One source of truth: the release tag. + +Resolution order: + 1. TPK_VERSION / TPK_COMMIT -- baked into the published images by the Docker + workflow from the git tag (the build context has no .git, so the image + cannot work it out for itself). + 2. `git describe` -- a dev checkout, e.g. `0.0.5-3-gbc549ef-dirty`. + 3. `dev` -- no metadata at all. Never a stale hand-edited constant: the + `version` in pyproject.toml is a placeholder and is not reported anywhere. +""" + +import os +import subprocess +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _git(*args: str) -> str: + try: + out = subprocess.run( + ["git", "-C", str(_REPO_ROOT), *args], + capture_output=True, text=True, timeout=2, check=False, + ) + except (OSError, subprocess.SubprocessError): + return "" + return out.stdout.strip() if out.returncode == 0 else "" + + +def get_version() -> tuple[str, str]: + """(version, short commit) -- either may be "" / "dev" when unknown.""" + version = os.environ.get("TPK_VERSION", "").strip() + commit = os.environ.get("TPK_COMMIT", "").strip() + if not version: + version = _git("describe", "--tags", "--always", "--dirty") + commit = commit or _git("rev-parse", "HEAD") + return (version.removeprefix("v") or "dev", commit[:7]) + + +def version_string() -> str: + """`0.0.5 (bc549ef)`, or just the version when the commit is unknown.""" + version, commit = get_version() + return f"{version} ({commit})" if commit else version diff --git a/tests/test_serve_prefix.py b/tests/test_serve_prefix.py new file mode 100644 index 0000000..c8331b2 --- /dev/null +++ b/tests/test_serve_prefix.py @@ -0,0 +1,83 @@ +"""`tpk serve` and the stdio MCP server must honour TPK_STREAM_PREFIX like the +rest of the CLI (#84) -- otherwise they silently run on the REAL streams.""" +import time +from datetime import datetime, timezone + +from fastapi.testclient import TestClient +from typer.testing import CliRunner + +from conftest import requires_timeplus +from tpk import auth +from tpk import cli as cli_mod +from tpk.cli import app +from tpk.ingest import upsert_graph +from tpk.model import Node + +pytestmark = requires_timeplus + + +def _eventually(fn, predicate=bool, timeout=5.0, interval=0.1): + deadline = time.monotonic() + timeout + result = fn() + while not predicate(result) and time.monotonic() < deadline: + time.sleep(interval) + result = fn() + return result + + +def test_serve_runs_entirely_on_the_prefixed_streams(tp, monkeypatch, tmp_path): + client, prefix = tp + monkeypatch.setenv("TPK_STREAM_PREFIX", prefix) + toml = tmp_path / "repos.toml" + (tmp_path / "checkout").mkdir() + toml.write_text(f'[repos.prefixrepo]\npath = "{tmp_path / "checkout"}"\nvisibility = "internal"\n') + monkeypatch.setattr(cli_mod, "REPOS_TOML", toml) + import tpk.server as server_mod + monkeypatch.setattr(server_mod, "REPOS_TOML", toml) + + # Things that exist ONLY under the prefix: a user and a graph node. + auth.upsert_user(client, auth.User("prefix-only", auth.hash_password("password-1"), + auth.ROLE_ADMIN), prefix=prefix) + upsert_graph(client, prefix, [ + Node(id="pfx1", repo="prefixrepo", kind="function", name="OnlyUnderPrefix", + qualified_name="p.OnlyUnderPrefix", file_path="p.py", line_start=1, line_end=2, + summary="exists only in the prefixed streams", community="0", visibility="internal"), + ], [], datetime.now(timezone.utc)) + + served = {} + import uvicorn + monkeypatch.setattr(uvicorn, "run", lambda a, **kw: served.update(app=a)) + result = CliRunner().invoke(app, ["serve"]) + assert result.exit_code == 0, result.output + + # corpus seeding went to the prefixed registry + from tpk import corpus + assert _eventually(lambda: [e.name for e in corpus.list_entries(client, prefix=prefix)]) == ["prefixrepo"] + + c = TestClient(served["app"]) + login = lambda: c.post("/auth/login", json={"username": "prefix-only", "password": "password-1"}) + assert _eventually(login, lambda r: r.status_code == 200).status_code == 200 # auth store + hdr = {"Authorization": f"Bearer {login().json()['token']}"} + hits = _eventually(lambda: c.get("/api/graph/search", params={"q": "OnlyUnderPrefix"}, + headers=hdr).json()["results"]) + assert [h["id"] for h in hits] == ["pfx1"] # graph + + +def test_stdio_mcp_server_uses_the_prefix(tp, monkeypatch, tmp_path): + from tpk import mcp_server + + client, prefix = tp + monkeypatch.setenv("TPK_STREAM_PREFIX", prefix) + toml = tmp_path / "repos.toml" + toml.write_text("[repos]\n") + monkeypatch.setattr(mcp_server, "REPOS_TOML", toml) + + built = {} + + class _Server: + def run(self): + pass + + monkeypatch.setattr(mcp_server, "build_server", lambda kg, guard=None: built.update(kg=kg) or _Server()) + mcp_server.main() + assert built["kg"].prefix == prefix diff --git a/tests/test_server.py b/tests/test_server.py index 4d72f50..5aa0778 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -109,8 +109,12 @@ def record(self, username, tokens): def test_healthz(): + # Unauthenticated liveness; the version fields are covered in test_version. client = TestClient(create_app(agent=FakeAgent([]), auth=_StubAuth())) - assert client.get("/healthz").json() == {"status": "ok"} + resp = client.get("/healthz") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + assert set(resp.json()) == {"status", "version", "commit"} def test_chat_over_daily_budget_returns_429(monkeypatch): diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..77c3e5b --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,81 @@ +"""The running version (#85): one source of truth, shown everywhere.""" +import asyncio +import subprocess + +from fastapi.testclient import TestClient +from typer.testing import CliRunner + +from tpk import version as version_mod + + +class _NoAgent: + async def astream_events(self, *a, **k): + yield # pragma: no cover + + +def _no_git(monkeypatch): + def boom(*a, **k): + raise FileNotFoundError("git") + monkeypatch.setattr(version_mod.subprocess, "run", boom) + + +def test_image_version_comes_from_the_build_env(monkeypatch): + monkeypatch.setenv("TPK_VERSION", "1.2.3") + monkeypatch.setenv("TPK_COMMIT", "abcdef1234567890") + assert version_mod.get_version() == ("1.2.3", "abcdef1") + assert version_mod.version_string() == "1.2.3 (abcdef1)" + + +def test_a_leading_v_is_dropped(monkeypatch): + monkeypatch.setenv("TPK_VERSION", "v1.2.3") + monkeypatch.delenv("TPK_COMMIT", raising=False) + _no_git(monkeypatch) + assert version_mod.get_version() == ("1.2.3", "") + assert version_mod.version_string() == "1.2.3" + + +def test_dev_checkout_uses_git_describe(monkeypatch): + monkeypatch.delenv("TPK_VERSION", raising=False) + monkeypatch.delenv("TPK_COMMIT", raising=False) + + def fake_run(cmd, **kw): + out = "v0.0.5-3-gbc549ef-dirty\n" if "describe" in cmd else "bc549ef1234\n" + return subprocess.CompletedProcess(cmd, 0, stdout=out, stderr="") + + monkeypatch.setattr(version_mod.subprocess, "run", fake_run) + assert version_mod.get_version() == ("0.0.5-3-gbc549ef-dirty", "bc549ef") + + +def test_no_metadata_at_all_is_dev_not_a_stale_constant(monkeypatch): + monkeypatch.delenv("TPK_VERSION", raising=False) + monkeypatch.delenv("TPK_COMMIT", raising=False) + _no_git(monkeypatch) + assert version_mod.get_version() == ("dev", "") + + +def test_cli_version_flag(monkeypatch): + from tpk.cli import app + + monkeypatch.setenv("TPK_VERSION", "1.2.3") + monkeypatch.setenv("TPK_COMMIT", "abcdef1234") + result = CliRunner().invoke(app, ["--version"]) + assert result.exit_code == 0 + assert result.output.strip() == "tpk 1.2.3 (abcdef1)" + + +def test_healthz_reports_the_version(monkeypatch): + from tpk.server import create_app + + monkeypatch.setenv("TPK_VERSION", "1.2.3") + monkeypatch.setenv("TPK_COMMIT", "abcdef1234") + body = TestClient(create_app(agent=_NoAgent())).get("/healthz").json() + assert body == {"status": "ok", "version": "1.2.3", "commit": "abcdef1"} + + +def test_mcp_server_info_carries_the_version(monkeypatch): + from tpk.mcp_server import build_server + + monkeypatch.setenv("TPK_VERSION", "1.2.3") + server = build_server(object()) + assert server._lowlevel_server.version == "1.2.3" + assert asyncio.run(server.list_tools()) # still a working server diff --git a/uv.lock b/uv.lock index d826e51..7bcacb4 100644 --- a/uv.lock +++ b/uv.lock @@ -2065,7 +2065,7 @@ wheels = [ [[package]] name = "tpk" -version = "0.1.0" +version = "0.0.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, diff --git a/web/src/Login.tsx b/web/src/Login.tsx index 1c67067..cd8f6dc 100644 --- a/web/src/Login.tsx +++ b/web/src/Login.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { apiFetch, setToken } from "./api"; +import { useServerVersion, versionLabel } from "./version"; type Me = { username: string; role: string; capabilities: string[] }; @@ -29,6 +30,7 @@ export default function Login({ initialMode?: "login" | "change"; initialUsername?: string; }) { + const serverVersion = useServerVersion(); const [mode, setMode] = useState<"login" | "change">(initialMode); const [username, setUsername] = useState(initialUsername); const [password, setPassword] = useState(""); @@ -162,6 +164,12 @@ export default function Login({ )} + {serverVersion && ( +
+ {versionLabel(serverVersion)} +
+ )} ); } diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index ca3a55a..d40624c 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useServerVersion, versionLabel } from "./version"; import { CAP, type Capability, hasCap } from "./capabilities"; // The app-wide sidebar shell. Two widths (mockup t6): expanded (220px, icon + @@ -78,6 +79,7 @@ export default function Shell({ onChangePassword: () => void; children: ReactNode; }) { + const serverVersion = useServerVersion(); // Collapsed state persists across navigation and reloads. const [collapsed, setCollapsed] = useState(() => { try { return localStorage.getItem(COLLAPSE_KEY) === "1"; } catch { return false; } @@ -142,6 +144,12 @@ export default function Shell({ Collapse + {serverVersion && ( +
+ {versionLabel(serverVersion)} +
+ )}