mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Bucket B of the v1.4.3 polish pass. Three issues, one commit because they're the same surface: 1. **API & Tokens tables overflowed the settings content column.** Each table set `min-w-[760px]` (or 860px) so the table never compressed below that width. On mobile this is intentional (it gives the inner cells breathing room while the outer wrapper horizontally scrolls). On md+ the parent column is smaller than the min-width on common laptop sizings (~800px content area when the browser isn't full-width), which forced an inline scrollbar for no benefit. Switched to `min-w-[760px] md:min-w-0` per table so the constraint releases at the breakpoint where the layout stops scrolling. 2. **"Auf Updates prüfen" button silently did nothing.** The client-side handler called `https://api.github.com/.../releases/ latest` directly. Production CSP locks `connect-src` down to `'self'` + `https://wbsapi.withings.net`, so the fetch was blocked at the browser before it left the tab. Replaced with a server-side proxy at `/api/version/check-updates` that fetches GitHub for the user, compares the running `package.json` version against `tag_name`, and returns one of `up_to_date | newer_ available | unknown`. The route is auth-gated (cookie OR bearer) so anonymous traffic can't burn through GitHub's 60-req/hr anon rate limit on the deployment's IP. The route also annotates the external call into the Wide Event so a future spike is visible in the logs. 3. **The About surface had no headings and a stray Badge box.** The v1.4.2 version put the License inside a `<Badge variant= "outline">`, which created a small framed pill that read as a separate widget rather than a peer of the version number. Below that, the Sources/Changelog/Docs and the update-check button each lived in their own card with no h2 — three nameless cards stacked on the page. Restructured into three titled cards: - **HealthLog** — identity, with version + license rendered inline as plain monospace pairs (no Badge, no nested grid). Build SHA + build timestamp ride along on the same row when set. - **Quellen & Dokumentation / Sources & docs** — links unchanged but now under their own h2. - **Updates** — manual `Check now` button + a passive "Last checked: …" line that pulls from localStorage so the user can see when the previous check ran. On mount, if the last successful check is >24h ago (or has never run), the page auto-checks once. The 24h window is a deliberate compromise between "always fresh" and "GitHub anon rate limit". The error path renders a non-blocking "Couldn't reach GitHub" message with the alert role so screen readers announce it. i18n keys touched in both `de.json` + `en.json`: - `settings.about.linksHeading` (new) - `settings.about.updatesHeading` (new) - `settings.about.checkUpdates` (label shortened) - `settings.about.checking` (button busy state — new) - `settings.about.lastChecked` / `lastCheckedNever` (new) - `settings.about.checkFailed` (new — replaces the misleading `testConnection.errors.connection_failed` reuse) Tests updated for the new heading + button strings; 669/669 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
4.9 KiB
TypeScript
157 lines
4.9 KiB
TypeScript
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(),
|
|
});
|
|
});
|