add(ui/ux): show the running version and build stamp in the header

This commit is contained in:
Steppenstreuner
2026-09-01 17:50:50 +02:00
parent d9b4c95ca6
commit 8e7be708b2
10 changed files with 73 additions and 4 deletions
+3 -1
View File
@@ -6,8 +6,10 @@ COPY wordarr ./wordarr
COPY static ./static COPY static ./static
RUN pip install --no-cache-dir . RUN pip install --no-cache-dir .
ARG WORDARR_BUILD=""
ENV WORDARR_CONFIG_DIR=/config \ ENV WORDARR_CONFIG_DIR=/config \
WORDARR_DOWNLOAD_DIR=/mnt/downloads WORDARR_DOWNLOAD_DIR=/mnt/downloads \
WORDARR_BUILD=$WORDARR_BUILD
VOLUME /config VOLUME /config
EXPOSE 8787 EXPOSE 8787
+7
View File
@@ -25,6 +25,13 @@ the same mount, otherwise moving turns into copy + delete.
| `WORDARR_EXTRA_EBOOK_EXTENSIONS` | | extra ebook formats the scan should offer, e.g. `.kepub,.lit,.rtf` | | `WORDARR_EXTRA_EBOOK_EXTENSIONS` | | extra ebook formats the scan should offer, e.g. `.kepub,.lit,.rtf` |
| `WORDARR_HARDCOVER_TOKEN` | | Hardcover API token, the best ebook source; without it Hardcover is skipped | | `WORDARR_HARDCOVER_TOKEN` | | Hardcover API token, the best ebook source; without it Hardcover is skipped |
| `WORDARR_GOOGLE_BOOKS_KEY` | | optional, only needed when the keyless Google Books quota runs dry | | `WORDARR_GOOGLE_BOOKS_KEY` | | optional, only needed when the keyless Google Books quota runs dry |
| `WORDARR_BUILD` | | shown next to the version, e.g. the git sha; also a build arg of the image |
The header shows the running version and, behind it, the build stamp (the newest
source file, or `WORDARR_BUILD` when the image was built with it) - the quickest
way to see whether a redeploy actually arrived. `GET /api/version` returns the
same three values. Build with the git sha via
`WORDARR_BUILD=$(git rev-parse --short HEAD) docker compose up -d --build`.
The UI speaks German and English; the button next to the navigation switches The UI speaks German and English; the button next to the navigation switches
between them and remembers the choice. Without one, the browser language decides. between them and remembers the choice. Without one, the browser language decides.
+5 -1
View File
@@ -1,6 +1,10 @@
services: services:
wordarr: wordarr:
build: . build:
context: .
args:
# e.g. WORDARR_BUILD=$(git rev-parse --short HEAD) docker compose up -d --build
WORDARR_BUILD: ${WORDARR_BUILD:-}
container_name: wordarr container_name: wordarr
ports: ports:
- "8787:8787" - "8787:8787"
+12
View File
@@ -1619,9 +1619,21 @@ function onLangChange() {
if (scanItems.length) renderImportTable(); if (scanItems.length) renderImportTable();
} }
// ---- version ----
// the build stamp is the quickest answer to "did the redeploy arrive?"
async function showVersion() {
try {
const v = await api("/api/version");
// the version alone rarely moves, so the stamp is what tells deploys apart
$("#app-version").textContent = `v${v.version} · ${v.build || v.built_at}`;
$("#app-version").title = `${v.built_at} UTC${v.build ? " · " + v.build : ""}`;
} catch {}
}
// ---- init ---- // ---- init ----
applyI18n(); applyI18n();
updateLangButton(); updateLangButton();
endLibraryEdit(); endLibraryEdit();
loadLibraries(); loadLibraries();
refreshMissingBadge(); refreshMissingBadge();
showVersion();
+1 -1
View File
@@ -11,7 +11,7 @@
</head> </head>
<body> <body>
<header> <header>
<h1>📚 wordarr</h1> <h1>📚 wordarr <span id="app-version"></span></h1>
<nav> <nav>
<button data-view="search" class="active" data-i18n="nav.search"></button> <button data-view="search" class="active" data-i18n="nav.search"></button>
<button data-view="missing"> <button data-view="missing">
+1
View File
@@ -10,6 +10,7 @@ header {
padding: 0.8rem; background: var(--panel); border-bottom: 1px solid var(--border); padding: 0.8rem; background: var(--panel); border-bottom: 1px solid var(--border);
} }
h1 { font-size: 1.2rem; } h1 { font-size: 1.2rem; }
#app-version { color: var(--muted); font-size: 0.72rem; font-weight: 400; font-variant-numeric: tabular-nums; }
nav { display: flex; gap: 0.4rem; flex-wrap: wrap; } nav { display: flex; gap: 0.4rem; flex-wrap: wrap; }
nav button { background: none; border: none; color: var(--muted); padding: 0.5rem 0.9rem; cursor: pointer; border-radius: 4px; font-size: 0.95rem; } nav button { background: none; border: none; color: var(--muted); padding: 0.5rem 0.9rem; cursor: pointer; border-radius: 4px; font-size: 0.95rem; }
nav button.active, nav button:hover { color: var(--text); background: var(--bg); } nav button.active, nav button:hover { color: var(--text); background: var(--bg); }
+7
View File
@@ -1135,3 +1135,10 @@ def test_extra_ebook_extensions_are_configurable(client, monkeypatch):
finally: finally:
monkeypatch.delenv("WORDARR_EXTRA_EBOOK_EXTENSIONS") monkeypatch.delenv("WORDARR_EXTRA_EBOOK_EXTENSIONS")
importlib.reload(config_module) importlib.reload(config_module)
def test_version_endpoint_reports_a_build_stamp(client):
data = client.get("/api/version").json()
assert data["version"]
# "2026-09-01 15:43" - the newest source file, so a stale deploy stands out
assert len(data["built_at"]) == 16 and data["built_at"][4] == "-"
+6
View File
@@ -153,3 +153,9 @@ def test_english_reaches_the_rendered_rows(page):
page.click("#lang-toggle") page.click("#lang-toggle")
assert "— überspringen —" in page.locator("#import-table tbody select").first.text_content() assert "— überspringen —" in page.locator("#import-table tbody select").first.text_content()
assert "2,0 KB" in " ".join(page.locator("#import-table .tag.fmt").all_text_contents()) assert "2,0 KB" in " ".join(page.locator("#import-table .tag.fmt").all_text_contents())
def test_the_header_shows_the_running_version(page):
stamp = page.locator("#app-version").text_content()
assert stamp.startswith("v")
assert len(stamp) > 3 # version plus the build stamp behind it
+7 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import db from . import db, version
from .api import fs, imports, libraries, requests, search from .api import fs, imports, libraries, requests, search
@@ -23,6 +23,12 @@ app.include_router(imports.router)
app.include_router(fs.router) app.include_router(fs.router)
@app.get("/api/version")
def read_version():
return {"version": version.VERSION, "build": version.BUILD,
"built_at": version.BUILT_AT}
class RevalidatingStatics(StaticFiles): class RevalidatingStatics(StaticFiles):
"""Without a Cache-Control header the browser caches heuristically and does """Without a Cache-Control header the browser caches heuristically and does
not ask again, so a redeployed app.js kept rendering the old UI. no-cache not ask again, so a redeployed app.js kept rendering the old UI. no-cache
+24
View File
@@ -0,0 +1,24 @@
"""Version and build stamp, so the UI can say what is actually deployed."""
import os
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as package_version
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def _built_at() -> str:
"""Newest source file - a redeploy that did not take keeps the old stamp."""
files = [*(ROOT / "wordarr").rglob("*.py"), *(ROOT / "static").glob("*")]
newest = max((f.stat().st_mtime for f in files), default=0.0)
return datetime.fromtimestamp(newest, timezone.utc).strftime("%Y-%m-%d %H:%M")
try:
VERSION = package_version("wordarr")
except PackageNotFoundError: # running from a checkout without an install
VERSION = "0.0.0+src"
BUILD = os.environ.get("WORDARR_BUILD", "").strip() # git sha, set by the image build
BUILT_AT = _built_at()