feat(admin): update-available modal with aggregated changelog + upgrade command (#567)

Closes #567.

The sidebar already had a "vX.Y.Z available" indicator (#566 made it a
link to that release's page) but there was no way to read the actual
changelog inline or to grab a copy-paste upgrade command. This adds
the modal the issue spec'd, layered on top of the existing
updateCheckService / environmentService backend infrastructure that
already shipped.

## Backend

- `updateCheckService.fetchAvailableVersions` now returns full release
  objects (tag, name, body, publishedAt, htmlUrl) instead of just
  version strings — body data is what the changelog modal renders.
  `checkForUpdates` extracts the version strings for its existing
  consumers; no API change visible to callers.
- New `getReleasesSince(currentVersion, channel)` returns the list of
  releases strictly newer than current, filtered to the user's
  channel. Reuses the same 1-hour cache as `checkForUpdates` so the
  modal opening doesn't trigger an extra GitHub round-trip.
- New `GET /admin/system/updates/changelog` route in `adminSystem.js`,
  same auth + UPDATE_CHECK_ENABLED gating as the existing
  /updates and /updates/instructions endpoints.
- 4 unit tests (axios mocked) pin: strictly-newer filtering,
  channel-scoped, empty array on GitHub fetch failure, empty array
  when already on latest.

## Frontend

- New `UpdateAvailableModal.tsx` — opens from the sidebar chip. Two
  sections:
    1. **How to upgrade** — fetches /updates/instructions for the
       environment-detected copy-paste command (Docker compose / git /
       standalone). Copy-to-clipboard button per step.
    2. **Release notes** — fetches /updates/changelog for every
       version between current and latest in the user's channel.
       Latest is auto-expanded; older releases are collapsed by
       default (click to expand). Each release also has a "View on
       GitHub" link to the canonical release page.
  - Renders release body markdown through the existing safe
    MarkdownContent component (marked + DOMPurify allowlist).
- New `updateDismissal.ts` helper — single localStorage key holds the
  last-dismissed version. Chip stays hidden until a STRICTLY newer
  version appears, using the same compare semantics as the backend
  (stable > beta, higher beta > lower beta, semantic numeric on
  major.minor.patch). 9 unit tests pin the rules.
- `VersionInfo.tsx` — chip is now a button that opens the modal
  instead of an external link (the #566 link-to-release behaviour is
  preserved on the modal's per-release "View on GitHub" affordance).
  Dismissal triggers an immediate re-render so the chip disappears
  without waiting for the next route change.

No new dependencies — uses `marked` + `DOMPurify` that were already
present in the bundle for the contract block renderer.
This commit is contained in:
Paul Nothaft
2026-05-29 11:26:24 +02:00
parent d231623c59
commit 832f7bad45
7 changed files with 714 additions and 51 deletions
@@ -0,0 +1,76 @@
/**
* Coverage for the per-version "Update available" chip dismissal (#567).
*
* The chip should reappear only when a newer version than the
* dismissed one is published. These tests pin the comparison rules
* (mirroring the backend's compareVersions in updateCheckService.js
* — stable > beta, higher beta > lower beta, semantic numeric compare
* on major.minor.patch) so a future refactor can't silently break the
* "I dismissed v3.55.0 but should still see v3.55.1" flow.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
isNewerVersion,
setDismissedVersion,
shouldShowUpdateChip,
} from '../updateDismissal';
describe('isNewerVersion', () => {
it('compares major.minor.patch semantically (not lexically)', () => {
expect(isNewerVersion('3.10.0', '3.9.0')).toBe(true);
expect(isNewerVersion('3.9.0', '3.10.0')).toBe(false);
expect(isNewerVersion('10.0.0', '9.99.99')).toBe(true);
});
it('treats stable as newer than the same-numbered beta', () => {
expect(isNewerVersion('3.55.0', '3.55.0-beta.0')).toBe(true);
expect(isNewerVersion('3.55.0-beta.0', '3.55.0')).toBe(false);
});
it('compares beta numbers when both are betas of the same base version', () => {
expect(isNewerVersion('3.55.0-beta.2', '3.55.0-beta.1')).toBe(true);
expect(isNewerVersion('3.55.0-beta.1', '3.55.0-beta.2')).toBe(false);
});
it('returns false when versions are equal', () => {
expect(isNewerVersion('3.55.0', '3.55.0')).toBe(false);
expect(isNewerVersion('3.55.0-beta.0', '3.55.0-beta.0')).toBe(false);
});
it('returns false for unparseable input rather than throwing', () => {
expect(isNewerVersion('nonsense', '3.55.0')).toBe(false);
expect(isNewerVersion('3.55.0', '')).toBe(false);
});
});
describe('shouldShowUpdateChip', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('shows the chip when nothing has been dismissed', () => {
expect(shouldShowUpdateChip('3.55.0')).toBe(true);
});
it('hides the chip when the same version has been dismissed', () => {
setDismissedVersion('3.55.0');
expect(shouldShowUpdateChip('3.55.0')).toBe(false);
});
it('shows the chip again when a newer version appears after dismissal', () => {
setDismissedVersion('3.55.0');
expect(shouldShowUpdateChip('3.55.1')).toBe(true);
expect(shouldShowUpdateChip('3.56.0')).toBe(true);
});
it('keeps the chip hidden when an OLDER version somehow becomes the latest', () => {
// Defensive — shouldn't happen in practice (release-please never
// republishes older tags) but a regression here would re-pester
// an admin who's deliberately on a newer version.
setDismissedVersion('3.55.0');
expect(shouldShowUpdateChip('3.54.0')).toBe(false);
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Per-version dismissal of the "Update available" chip (#567).
*
* Admins who deliberately stay on the current version can dismiss the
* chip; it reappears only when an even newer version is published. The
* dismissal lives in localStorage keyed by storage version of this
* helper, so a future schema change can invalidate old dismissals
* without touching individual admin's storage manually.
*
* Single key holds the last-dismissed version string. If the latest
* version > dismissed version → show the chip. Otherwise hide.
*
* Uses a string-based comparison helper so we don't pull in semver
* just for one greater-than check — see `isNewerVersion` below for
* the same algorithm the backend's updateCheckService uses.
*/
const STORAGE_KEY = 'picpeak.updateDismissedVersion.v1';
interface ParsedVersion {
major: number;
minor: number;
patch: number;
beta: number | null;
}
const parseVersion = (version: string): ParsedVersion | null => {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/);
if (!match) return null;
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
beta: match[4] ? parseInt(match[4], 10) : null,
};
};
/** Returns true when `a` is strictly newer than `b`. Mirrors backend semantics. */
export const isNewerVersion = (a: string, b: string): boolean => {
const va = parseVersion(a);
const vb = parseVersion(b);
if (!va || !vb) return false;
if (va.major !== vb.major) return va.major > vb.major;
if (va.minor !== vb.minor) return va.minor > vb.minor;
if (va.patch !== vb.patch) return va.patch > vb.patch;
// Same major.minor.patch — stable > beta, higher beta > lower beta
if (va.beta === null && vb.beta !== null) return true;
if (va.beta !== null && vb.beta === null) return false;
if (va.beta !== null && vb.beta !== null) return va.beta > vb.beta;
return false;
};
export const getDismissedVersion = (): string | null => {
try {
return localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
};
export const setDismissedVersion = (version: string): void => {
try {
localStorage.setItem(STORAGE_KEY, version);
} catch {
// localStorage unavailable (private mode, quota exceeded) — silently
// skip. The chip will keep reappearing, which is harmless.
}
};
/**
* Should the chip be shown for this latest version? True when the user
* has never dismissed (or dismissed an older version than latest).
*/
export const shouldShowUpdateChip = (latestVersion: string): boolean => {
const dismissed = getDismissedVersion();
if (!dismissed) return true;
return isNewerVersion(latestVersion, dismissed);
};