add(ui/ux): show the running version and build stamp in the header
This commit is contained in:
+3
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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] == "-"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user