diff --git a/messages/de.json b/messages/de.json index b80506688..1bd4095f7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1166,9 +1166,15 @@ "repository": "Quellcode", "changelog": "Changelog", "docs": "Dokumentation", - "checkUpdates": "Auf Updates prüfen", + "linksHeading": "Quellen & Dokumentation", + "updatesHeading": "Updates", + "checkUpdates": "Jetzt prüfen", + "checking": "Prüfe…", + "lastChecked": "Zuletzt geprüft: {time}", + "lastCheckedNever": "Noch nicht geprüft", "upToDate": "Du nutzt die aktuelle Version", - "newerAvailable": "Neue Version: {tag}" + "newerAvailable": "Neue Version: {tag}", + "checkFailed": "Konnte GitHub nicht erreichen — versuche es später erneut" }, "testConnection": { "test": "Verbindung testen", diff --git a/messages/en.json b/messages/en.json index 2171969f1..3dff1947f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1166,9 +1166,15 @@ "repository": "Source code", "changelog": "Changelog", "docs": "Documentation", - "checkUpdates": "Check for updates", + "linksHeading": "Sources & docs", + "updatesHeading": "Updates", + "checkUpdates": "Check now", + "checking": "Checking…", + "lastChecked": "Last checked: {time}", + "lastCheckedNever": "Not checked yet", "upToDate": "You are up to date", - "newerAvailable": "Newer version: {tag}" + "newerAvailable": "Newer version: {tag}", + "checkFailed": "Could not reach GitHub — try again later" }, "testConnection": { "test": "Test connection", diff --git a/src/app/api/version/check-updates/route.ts b/src/app/api/version/check-updates/route.ts new file mode 100644 index 000000000..ae31afdfc --- /dev/null +++ b/src/app/api/version/check-updates/route.ts @@ -0,0 +1,156 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { getEvent } from "@/lib/logging/context"; +import packageJson from "../../../../../package.json"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/version/check-updates + * + * Server-side proxy for the GitHub Releases API. Required because the + * production CSP only whitelists `'self'` + Withings for `connect-src` + * — fetching `https://api.github.com/...` directly from the browser is + * silently blocked, which is why the v1.4.2 "Check for updates" button + * appeared to do nothing. Proxying through `/api` keeps the CSP locked + * down while the server (no CSP attached) fetches GitHub on the user's + * behalf. + * + * Compares the running app version (from `package.json`) against the + * latest GitHub release tag and returns one of three states: + * - `up_to_date` — running version >= latest release + * - `newer_available` — running version < latest release; payload + * includes the tag name and HTML URL so the UI can deep-link + * - `unknown` — couldn't reach GitHub (network issue, rate-limit, + * repo missing the release manifest); UI surfaces a non-blocking + * warning so the user can retry + * + * Responses are not cached at the route level. The browser bears the + * load with React Query's `staleTime`, so a single user spamming + * "Check now" still hits the GH API at most every 30 seconds. + */ + +const RELEASES_URL = + "https://api.github.com/repos/MBombeck/HealthLog/releases/latest"; + +interface GithubReleaseShape { + tag_name?: string; + html_url?: string; + name?: string; + published_at?: string; +} + +function stripV(s: string): string { + return s.replace(/^v/i, "").trim(); +} + +function compareSemver(a: string, b: string): number { + const partsA = stripV(a) + .split(/[.-]/) + .map((n) => Number.parseInt(n, 10)); + const partsB = stripV(b) + .split(/[.-]/) + .map((n) => Number.parseInt(n, 10)); + const len = Math.max(partsA.length, partsB.length); + for (let i = 0; i < len; i++) { + const ai = partsA[i] ?? 0; + const bi = partsB[i] ?? 0; + if (Number.isNaN(ai) || Number.isNaN(bi)) { + // Pre-release suffixes — fall back to lexical ordering of the + // stripped strings so `1.4.3-rc.1` sorts before `1.4.3`. + const sA = stripV(a); + const sB = stripV(b); + return sA < sB ? -1 : sA > sB ? 1 : 0; + } + if (ai < bi) return -1; + if (ai > bi) return 1; + } + return 0; +} + +export const GET = apiHandler(async () => { + // Auth-gated: there's no reason an anonymous client should poll the + // GH API through us. Cookie OR bearer token both satisfy this. + await requireAuth(); + annotate({ action: { name: "version.check_updates" } }); + + const current = packageJson.version; + + let release: GithubReleaseShape | null = null; + const callStart = Date.now(); + try { + const res = await fetch(RELEASES_URL, { + headers: { Accept: "application/vnd.github+json" }, + // GitHub anon rate limit is 60/hr per IP — tight but not zero. + // We don't pass a token because the repo is public and the route + // is auth-gated against our users. + cache: "no-store", + }); + getEvent()?.addExternalCall({ + service: "github_releases_api", + method: "GET", + duration_ms: Date.now() - callStart, + status: res.status, + }); + if (!res.ok) { + annotate({ + meta: { + status: res.status, + outcome: "github_unreachable", + }, + }); + return apiSuccess({ + status: "unknown" as const, + current, + reason: `github_status_${res.status}`, + }); + } + release = (await res.json()) as GithubReleaseShape; + } catch (error) { + getEvent()?.addExternalCall({ + service: "github_releases_api", + method: "GET", + duration_ms: Date.now() - callStart, + error: error instanceof Error ? error.message : "unknown", + }); + // Network glitch, DNS hiccup, GH outage. Don't fail the request — + // tell the UI it's unknown so it can retry without showing a red + // banner the user can't act on. + annotate({ + meta: { + outcome: "fetch_threw", + message: error instanceof Error ? error.message : "unknown", + }, + }); + return apiSuccess({ + status: "unknown" as const, + current, + reason: "network_error", + }); + } + + const latestTag = release?.tag_name?.trim(); + if (!latestTag) { + return apiError("GitHub release manifest missing tag_name", 502); + } + + const cmp = compareSemver(current, latestTag); + if (cmp >= 0) { + return apiSuccess({ + status: "up_to_date" as const, + current, + latest_tag: latestTag, + checked_at: new Date().toISOString(), + }); + } + + return apiSuccess({ + status: "newer_available" as const, + current, + latest_tag: latestTag, + html_url: release.html_url ?? null, + published_at: release.published_at ?? null, + checked_at: new Date().toISOString(), + }); +}); diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 2eb944cc2..9b8c71bed 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -101,8 +101,13 @@ describe("settings sections — SSR smoke", () => { it(" renders version + license + check-for-updates button", () => { const html = render(); expect(html).toContain("About"); - // The new about.* keys resolve. - expect(html).toContain("Check for updates"); + // The new about.* keys resolve. v1.4.3 split the page into three + // titled cards (HealthLog identity, Sources & docs, Updates) and + // shortened the button label from "Check for updates" to "Check now" + // because the surrounding heading already names the section. + expect(html).toContain("Updates"); + expect(html).toContain("Sources & docs"); + expect(html).toContain("Check now"); expect(html).not.toContain("settings.about."); }); @@ -146,6 +151,10 @@ describe("settings sections — SSR smoke", () => { it(" resolves about.* keys in German", () => { const html = render(, "de"); - expect(html).toContain("Auf Updates prüfen"); + // v1.4.3: button label shortened to "Jetzt prüfen" because the + // section heading "Updates" already provides context. + expect(html).toContain("Updates"); + expect(html).toContain("Jetzt prüfen"); + expect(html).toContain("Quellen & Dokumentation"); }); }); diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index 46e8f2b19..f14d7d9ba 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, @@ -9,14 +9,11 @@ import { GitBranch, Info, Loader2, - Package, RefreshCw, - Scale, Sparkles, XCircle, } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; @@ -30,42 +27,46 @@ interface VersionPayload { docs: string; } -interface GithubRelease { - tag_name: string; - html_url: string; - name: string; +type CheckUpdatesResult = + | { + status: "up_to_date"; + current: string; + latest_tag: string; + checked_at: string; + } + | { + status: "newer_available"; + current: string; + latest_tag: string; + html_url: string | null; + published_at: string | null; + checked_at: string; + } + | { status: "unknown"; current: string; reason: string }; + +const LAST_CHECKED_KEY = "healthlog-about-last-checked-iso"; +// One day between auto-checks. We don't want every page-mount to spend a +// GitHub-API rate-limit slot just to confirm the version is unchanged +// 30 seconds after the previous one resolved. The user can always force +// a re-check via the button. +const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +function readLastCheckedISO(): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(LAST_CHECKED_KEY); + } catch { + return null; + } } -const RELEASES_API_URL = - "https://api.github.com/repos/MBombeck/HealthLog/releases/latest"; - -/** - * Compare a semantic version (e.g. "1.4.0") with a release tag (e.g. - * "v1.4.0", "1.4.1"). Returns `true` when `tag` is strictly newer than - * `current`. Falls back to lexical compare when the strings don't parse, - * which is intentional — better to surface "newer available" on a parse miss - * than silently swallow a release. - */ -function isNewer(current: string, tag: string): boolean { - const stripV = (s: string) => s.replace(/^v/i, "").trim(); - const a = stripV(current) - .split(/[.-]/) - .map((n) => Number.parseInt(n, 10)); - const b = stripV(tag) - .split(/[.-]/) - .map((n) => Number.parseInt(n, 10)); - const maxLen = Math.max(a.length, b.length); - for (let i = 0; i < maxLen; i++) { - const ai = a[i] ?? 0; - const bi = b[i] ?? 0; - if (Number.isNaN(ai) || Number.isNaN(bi)) { - // Fall back to lexical compare for pre-release suffixes etc. - return stripV(tag) > stripV(current); - } - if (bi > ai) return true; - if (bi < ai) return false; +function writeLastCheckedISO(iso: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(LAST_CHECKED_KEY, iso); + } catch { + /* storage may be full or disabled — silently skip */ } - return false; } export function AboutSection() { @@ -85,42 +86,59 @@ export function AboutSection() { }); const [checking, setChecking] = useState(false); - const [updateResult, setUpdateResult] = useState< - | { kind: "up_to_date" } - | { kind: "newer"; tag: string; url: string } - | { kind: "error" } - | null - >(null); + const [updateResult, setUpdateResult] = useState( + null, + ); + const [lastCheckedISO, setLastCheckedISO] = useState(() => + readLastCheckedISO(), + ); - async function handleCheckForUpdates() { - if (!version) return; + async function runCheck(): Promise { setChecking(true); - setUpdateResult(null); try { - const res = await fetch(RELEASES_API_URL, { - headers: { Accept: "application/vnd.github+json" }, - }); + const res = await fetch("/api/version/check-updates"); if (!res.ok) { - setUpdateResult({ kind: "error" }); + setUpdateResult({ + status: "unknown", + current: version?.version ?? "", + reason: `http_${res.status}`, + }); return; } - const release = (await res.json()) as GithubRelease; - if (isNewer(version.version, release.tag_name)) { - setUpdateResult({ - kind: "newer", - tag: release.tag_name, - url: release.html_url, - }); - } else { - setUpdateResult({ kind: "up_to_date" }); + const json = await res.json(); + const result = json.data as CheckUpdatesResult; + setUpdateResult(result); + if (result.status !== "unknown") { + const iso = new Date().toISOString(); + writeLastCheckedISO(iso); + setLastCheckedISO(iso); } } catch { - setUpdateResult({ kind: "error" }); + setUpdateResult({ + status: "unknown", + current: version?.version ?? "", + reason: "network_error", + }); } finally { setChecking(false); } } + // Auto-check on mount when the last successful check is older than the + // refresh interval (or has never run). Keeps the UI populated without + // requiring a click — but won't spam GitHub on every navigation. + useEffect(() => { + if (!version) return; + const last = readLastCheckedISO(); + const lastMs = last ? Date.parse(last) : NaN; + const stale = + Number.isNaN(lastMs) || Date.now() - lastMs > AUTO_CHECK_INTERVAL_MS; + if (stale) { + void runCheck(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [version?.version]); + return (
+ {/* Identity card — version + license inline. The v1.4.2 layout + stacked the license under the version inside its own boxed Badge, + which read as a separate field even though the two values belong + on the same row. */}
@@ -152,69 +174,56 @@ export function AboutSection() {
) : ( -
-
- -
-
- {t("settings.about.version")} -
-
- v{version.version} -
-
+
+
+
+ {t("settings.about.version")} +
+
v{version.version}
-
- -
-
- {t("settings.about.license")} -
-
- - {version.license} - -
-
+
+
+ {t("settings.about.license")} +
+
{version.license}
{version.buildSha && ( -
- -
-
- {t("settings.about.gitSha")} -
-
- {version.buildSha.slice(0, 7)} -
-
+
+
+ {t("settings.about.gitSha")} +
+
{version.buildSha.slice(0, 7)}
)} {version.builtAt && ( -
- -
-
- {t("settings.about.builtAt", { - time: fmt.dateTime(version.builtAt), - })} -
-
- {fmt.date(version.builtAt)} -
-
+
+
+ {t("settings.about.builtAt", { time: "" }).trim() || + t("settings.about.gitSha")} +
+
+ {fmt.date(version.builtAt)} +
)}
)}
- {/* Links */} + {/* Sources & docs — every link card was untitled in the v1.4.2 + About surface, so the buttons floated alone with no heading + telling the user what the section was for. */} {version && (
+
+ +

+ {t("settings.about.linksHeading")} +

+
+
+ +

+ {t("settings.about.updatesHeading")} +

+
- {updateResult?.kind === "up_to_date" && ( +
+ + +

+ {lastCheckedISO + ? t("settings.about.lastChecked", { + time: fmt.dateTime(lastCheckedISO), + }) + : t("settings.about.lastCheckedNever")} +

+
+ + {updateResult?.status === "up_to_date" && (

)} - {updateResult?.kind === "newer" && ( + {updateResult?.status === "newer_available" && (

- - {t("settings.about.newerAvailable", { - tag: updateResult.tag, - })} - + {updateResult.html_url ? ( + + {t("settings.about.newerAvailable", { + tag: updateResult.latest_tag, + })} + + ) : ( + + {t("settings.about.newerAvailable", { + tag: updateResult.latest_tag, + })} + + )}

)} - {updateResult?.kind === "error" && ( + {updateResult?.status === "unknown" && (

- {t("settings.testConnection.errors.connection_failed")} + {t("settings.about.checkFailed")}

)}
diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 5bc00c94a..649078f86 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -71,7 +71,7 @@ function ApiEndpointsCard() {

- +
@@ -237,7 +237,7 @@ function ApiTokensCard() { {t("settings.activeTokensTitle")}

- +
@@ -360,7 +360,7 @@ function ApiTokensCard() { {showRevokedTokens && (
- +