From 2bb03633ff8769e41c7059badc8120971d65bb08 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 29 Jun 2026 21:00:18 +0300 Subject: [PATCH] backend: detect updates from Docker Hub + add a Check for updates button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/version now reads the latest version from Docker Hub image tags (the actual update channel clinics pull), falling back to the GitHub release if Docker Hub is unreachable, with a shorter 1h cache and a `?refresh=1` bypass. Settings → About & updates gains a "Check for updates" button that forces a fresh, cache-bypassing lookup. Co-Authored-By: Claude Opus 4.8 --- backend/src/routes/version.ts | 82 +++++++++++++++---- .../components/settings/settings-version.tsx | 30 ++++++- frontend/lib/version.ts | 4 +- 3 files changed, 95 insertions(+), 21 deletions(-) diff --git a/backend/src/routes/version.ts b/backend/src/routes/version.ts index 16e6ece..7d511bf 100644 --- a/backend/src/routes/version.ts +++ b/backend/src/routes/version.ts @@ -1,6 +1,8 @@ -// GET /api/version — reports the running version and whether a newer release -// exists on GitHub. Public (no PHI); the frontend uses it for the Settings -// "About & Updates" panel and the optional update banner. +// GET /api/version — reports the running version and whether a newer image is +// available. The latest version is read from Docker Hub (the actual update +// channel: clinics run `docker compose pull`), falling back to the GitHub +// release if Docker Hub's API is unreachable. Public (no PHI); the frontend uses +// it for the Settings "About & Updates" panel and the optional update banner. import { createRequire } from "node:module"; import { Router } from "express"; @@ -13,16 +15,24 @@ const require = createRequire(import.meta.url); const pkg = require("../../package.json") as { version?: string }; const CURRENT = env.APP_VERSION ?? pkg.version ?? "0.0.0"; -const LATEST_RELEASE_URL = +// The published image whose tags reflect what `docker compose pull` would fetch. +const DOCKERHUB_TAGS_URL = + "https://hub.docker.com/v2/repositories/khalidxv/temetro-backend/tags?page_size=100"; +// GitHub release of a given version — used for the human-readable "what's new" +// link, and as a fallback source for the latest version. +const GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/temetro/temetro/releases/latest"; -const CACHE_TTL = 6 * 60 * 60 * 1000; // 6h — releases are infrequent. +const releaseUrlFor = (version: string) => + `https://github.com/temetro/temetro/releases/tag/v${version}`; + +const CACHE_TTL = 60 * 60 * 1000; // 1h — surface a new release reasonably fast. const ERROR_TTL = 10 * 60 * 1000; // back off ~10m after a failed lookup. type LatestInfo = { latest: string | null; releaseUrl: string | null }; let cache: { at: number; ttl: number; info: LatestInfo } | null = null; function parseSemver(v: string): [number, number, number] | null { - const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/); + const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)$/); return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; } @@ -38,18 +48,52 @@ function isNewer(latest: string, current: string): boolean { return false; } -async function fetchLatest(): Promise { - if (cache && Date.now() - cache.at < cache.ttl) return cache.info; +// Highest strict X.Y.Z tag in the list (ignores `latest` and any non-semver). +function maxSemver(versions: string[]): string | null { + let best: string | null = null; + for (const v of versions) { + if (parseSemver(v) && (best === null || isNewer(v, best))) best = v; + } + return best; +} + +// Primary source: the published Docker Hub image tags. +async function fetchFromDockerHub(): Promise { + const res = await fetch(DOCKERHUB_TAGS_URL, { + headers: { Accept: "application/json", "User-Agent": "temetro" }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`Docker Hub responded ${res.status}`); + const body = (await res.json()) as { results?: Array<{ name?: string }> }; + const names = (body.results ?? []) + .map((r) => r.name) + .filter((n): n is string => typeof n === "string"); + return maxSemver(names); +} + +// Fallback source: the latest GitHub release tag (used if Docker Hub is blocked). +async function fetchFromGitHub(): Promise { + const res = await fetch(GITHUB_LATEST_RELEASE_URL, { + headers: { Accept: "application/vnd.github+json", "User-Agent": "temetro" }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`GitHub responded ${res.status}`); + const body = (await res.json()) as { tag_name?: string }; + return body.tag_name ? body.tag_name.replace(/^v/, "") : null; +} + +async function fetchLatest(force = false): Promise { + if (!force && cache && Date.now() - cache.at < cache.ttl) return cache.info; try { - const res = await fetch(LATEST_RELEASE_URL, { - headers: { Accept: "application/vnd.github+json", "User-Agent": "temetro" }, - signal: AbortSignal.timeout(5000), - }); - if (!res.ok) throw new Error(`GitHub responded ${res.status}`); - const body = (await res.json()) as { tag_name?: string; html_url?: string }; + let latest: string | null = null; + try { + latest = await fetchFromDockerHub(); + } catch { + latest = await fetchFromGitHub(); + } const info: LatestInfo = { - latest: body.tag_name ? body.tag_name.replace(/^v/, "") : null, - releaseUrl: body.html_url ?? null, + latest, + releaseUrl: latest ? releaseUrlFor(latest) : null, }; cache = { at: Date.now(), ttl: CACHE_TTL, info }; return info; @@ -63,8 +107,10 @@ async function fetchLatest(): Promise { const router = Router(); -router.get("/", async (_req, res) => { - const { latest, releaseUrl } = await fetchLatest(); +router.get("/", async (req, res) => { + // `?refresh=1` powers the "Check for updates" button — bypass the cache. + const force = req.query.refresh === "1" || req.query.refresh === "true"; + const { latest, releaseUrl } = await fetchLatest(force); res.json({ current: CURRENT, latest, diff --git a/frontend/components/settings/settings-version.tsx b/frontend/components/settings/settings-version.tsx index 205fd7e..5ee5c0e 100644 --- a/frontend/components/settings/settings-version.tsx +++ b/frontend/components/settings/settings-version.tsx @@ -1,5 +1,6 @@ "use client"; +import { RefreshCw } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -8,6 +9,7 @@ import { SettingsCard, SettingsSection, } from "@/components/settings/settings-parts"; +import { Button } from "@/components/ui/button"; import { getNetworkInfo, getVersionInfo, type VersionInfo } from "@/lib/version"; import { cn } from "@/lib/utils"; @@ -45,6 +47,7 @@ export function VersionPanel() { const { t } = useTranslation(); const [info, setInfo] = useState(null); const [loading, setLoading] = useState(true); + const [checking, setChecking] = useState(false); const [networkUrls, setNetworkUrls] = useState([]); useEffect(() => { @@ -54,6 +57,17 @@ export function VersionPanel() { .finally(() => setLoading(false)); }, []); + // "Check for updates" — force a fresh, cache-bypassing lookup on the backend. + const checkForUpdates = () => { + setChecking(true); + getVersionInfo(true) + .then(setInfo) + .catch(() => { + /* keep the last known info on a failed manual check */ + }) + .finally(() => setChecking(false)); + }; + // The most reliable shareable URL is the one the browser is already using — // unless that's localhost, in which case we fall back to the backend's // detected LAN addresses (accurate when not running in Docker's bridge net). @@ -72,7 +86,7 @@ export function VersionPanel() { .catch(() => setNetworkUrls([])); }, [localShareUrl]); - const statusBadge = loading ? ( + const statusBadge = loading || checking ? ( {t("settings.version.checking")} ) : !info || info.latest === null ? ( {t("settings.version.offline")} @@ -122,6 +136,20 @@ export function VersionPanel() { ) : null} +
+ +
diff --git a/frontend/lib/version.ts b/frontend/lib/version.ts index e14de82..1c190e4 100644 --- a/frontend/lib/version.ts +++ b/frontend/lib/version.ts @@ -14,8 +14,8 @@ export type NetworkInfo = { urls: string[]; }; -export function getVersionInfo(): Promise { - return apiFetch("/api/version"); +export function getVersionInfo(force = false): Promise { + return apiFetch(`/api/version${force ? "?refresh=1" : ""}`); } export function getNetworkInfo(): Promise {