feat(admin): display running version in footer and /health

Inline package.json version at bundle time via src/config/version.ts
(resolveJsonModule), surface it in the shared admin/status footer and
add it to the /health JSON so self-hosters can tell which build runs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-25 15:43:05 +02:00
parent 70552e5fa6
commit 7086526670
7 changed files with 36 additions and 2 deletions
+1 -1
View File
@@ -192,7 +192,7 @@ Breakdown of the _"Per-feed favicon from the last sender's domain"_ item above (
Self-host operational quality-of-life: knowing which version you run, when to update, and how many people run KTN.
- [ ] `P3·S` **Display the running version** **[table-stakes, easy]** — surface the deployed app version (from `package.json` `version`, currently `0.2.1`) somewhere visible: the admin UI footer and/or the public status page (`src/routes/home.tsx`), and ideally the `/health` JSON. Bundle the version at build time (inline the `package.json` version into the Worker, since there's no filesystem at runtime) and render it. Foundation for the update-notification item below. — _origin: internal_
- [x] `P3·S` **Display the running version** **[table-stakes, easy]** — surface the deployed app version (from `package.json` `version`, currently `0.2.1`) somewhere visible: the admin UI footer and/or the public status page (`src/routes/home.tsx`), and ideally the `/health` JSON. Bundle the version at build time (inline the `package.json` version into the Worker, since there's no filesystem at runtime) and render it. Foundation for the update-notification item below. — **Shipped:** `package.json` version is inlined at bundle time via `src/config/version.ts` (`import pkg from "../../package.json"`, `resolveJsonModule`), exposed as `APP_VERSION`; rendered in the shared admin/status footer (`src/routes/admin/ui.tsx` Layout, so both the status page and admin show it) and added to the `/health` JSON. — _origin: internal_
- [ ] `P3·M` **Notify when an update is available** **[differentiating for self-hosters]** — compare the running version against the latest GitHub Release tag and show a discreet "update available → vX.Y.Z" banner in the admin UI when behind. Fetch `https://api.github.com/repos/<owner>/<repo>/releases/latest` (cache aggressively — Cache API / KV with a long TTL — to respect GitHub rate limits and avoid a call per page load), compare semver against the bundled version. Depends on the "display version" item. Keep it opt-out-able (it makes one outbound call). — _origin: internal_
+8
View File
@@ -0,0 +1,8 @@
import pkg from "../../package.json";
/**
* The running app version, inlined from package.json at bundle time (the Worker
* has no filesystem at runtime). Surfaced in the admin/status footer and the
* /health JSON so a self-hoster can tell which build is deployed.
*/
export const APP_VERSION: string = pkg.version;
+12
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import worker from "./index";
import { APP_VERSION } from "./config/version";
import { createMockEnv } from "./test/setup";
import { createFeedRecord } from "./application/feed-service";
import { FeedRepository } from "./infrastructure/feed-repository";
@@ -97,6 +98,17 @@ describe("scheduled (cron) TTL cleanup", () => {
});
});
describe("GET /health", () => {
it("reports status ok and the bundled app version", async () => {
const res = await worker.fetch(req("/health"), env as unknown as Env);
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string; version: string };
expect(body.status).toBe("ok");
expect(body.version).toBe(APP_VERSION);
expect(body.version).toMatch(/^\d+\.\d+\.\d+/);
});
});
describe("GET /robots.txt", () => {
it("returns 200 and disallows the private feed/entry paths", async () => {
const res = await worker.fetch(req("/robots.txt"), env as unknown as Env);
+4 -1
View File
@@ -13,6 +13,7 @@ import { hubRouter } from "./routes/hub";
import { apiApp } from "./routes/api";
import { handleCloudflareEmail } from "./infrastructure/cloudflare-email";
import { Env } from "./types";
import { APP_VERSION } from "./config/version";
import { logger } from "./infrastructure/logger";
import { FeedRepository } from "./infrastructure/feed-repository";
import { purgeExpiredFeeds } from "./application/feed-cleanup";
@@ -185,7 +186,9 @@ app.get("/favicon.ico", handleFavicon); // readers/browsers that hardcode .ico
app.get("/favicon/:feedId", handleFeedFavicon);
// Health check endpoint for monitoring
app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
app.get("/health", (c) =>
c.json({ status: "ok", version: APP_VERSION, timestamp: Date.now() }),
);
// Public status page (counters + link to admin)
app.get("/", handleHome);
+5
View File
@@ -3,6 +3,7 @@ import layoutCss from "../../styles/layout.css";
import componentsCss from "../../styles/components.css";
import utilitiesCss from "../../styles/utilities.css";
import { interactiveScripts } from "../../scripts/index";
import { APP_VERSION } from "../../config/version";
import { FAVICON_PATH } from "../favicon";
import { Env } from "../../types";
import {
@@ -77,6 +78,10 @@ export const Layout = ({ title, label = "admin", children }: LayoutProps) => {
>
Sponsor
</a>
<span class="site-footer-sep" aria-hidden="true">
·
</span>
<span class="site-footer-version">v{APP_VERSION}</span>
</footer>
</body>
</html>
+5
View File
@@ -247,3 +247,8 @@
.site-footer-sponsor:hover {
color: #db61a2 !important;
}
.site-footer-version {
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
+1
View File
@@ -4,6 +4,7 @@
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": true,
"lib": ["ES2021"],
"types": ["@cloudflare/workers-types"],