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,101 @@
/**
* Coverage for the changelog aggregation introduced for #567.
*
* `getReleasesSince` is what feeds the update-available modal so the
* admin can see release notes for every version between their current
* version and latest. The cases below pin:
*
* - Strictly-newer filtering (the running version itself never
* appears in the list).
* - Channel filtering (a stable user does not see beta releases,
* and vice versa).
* - Empty-result fallback when the GitHub fetch returns nothing
* (cached null from `fetchAvailableVersions`).
*
* We mock axios's GitHub response — these tests are pure logic, no
* network. Cache is cleared between cases via the service's exported
* `clearCache()` to avoid bleed.
*/
jest.mock('axios');
const axios = require('axios');
const { getReleasesSince, clearCache } = require('../../src/services/updateCheckService');
function release(tag, body = '', publishedAt = '2026-01-01T00:00:00Z') {
return {
tag_name: tag,
name: tag,
body,
published_at: publishedAt,
html_url: `https://github.com/the-luap/picpeak/releases/tag/${tag}`,
};
}
describe('updateCheckService.getReleasesSince', () => {
beforeEach(() => {
clearCache();
axios.get.mockReset();
});
it('returns only releases strictly newer than current, for the requested channel', async () => {
axios.get.mockResolvedValue({
data: [
release('v3.55.0', 'stable notes 3.55.0'),
release('v3.54.0', 'stable notes 3.54.0'),
release('v3.43.1', 'stable notes 3.43.1 — the running version, should be excluded'),
release('v3.43.0'),
release('v3.55.0-beta.0', 'beta notes — wrong channel, excluded'),
release('v3.54.0-beta.5'),
],
});
const result = await getReleasesSince('3.43.1', 'stable');
expect(result.map((r) => r.version)).toEqual(['3.55.0', '3.54.0']);
// Body + html_url + publishedAt are preserved so the modal can render them
expect(result[0]).toMatchObject({
version: '3.55.0',
tag: 'v3.55.0',
name: 'v3.55.0',
body: 'stable notes 3.55.0',
htmlUrl: 'https://github.com/the-luap/picpeak/releases/tag/v3.55.0',
});
});
it('returns only beta releases for a beta user', async () => {
axios.get.mockResolvedValue({
data: [
release('v3.55.0-beta.0'),
release('v3.54.7-beta.0'),
release('v3.55.0'), // stable — wrong channel for a beta user
release('v3.54.6-beta.0'),
],
});
const result = await getReleasesSince('3.54.6-beta.0', 'beta');
// Strictly newer beta-channel only — does not include 3.55.0 stable
// even though it's a newer release, because the beta channel user
// wants to see beta releases (which can include releases that
// landed on the beta line after the stable cut).
expect(result.map((r) => r.version)).toEqual(['3.55.0-beta.0', '3.54.7-beta.0']);
});
it('returns empty array when GitHub fetch fails (e.g. rate-limited)', async () => {
axios.get.mockRejectedValue(new Error('API rate limit exceeded'));
const result = await getReleasesSince('3.43.1', 'stable');
expect(result).toEqual([]);
});
it('returns empty array when the user is already on the latest version', async () => {
axios.get.mockResolvedValue({
data: [release('v3.55.0'), release('v3.54.0')],
});
const result = await getReleasesSince('3.55.0', 'stable');
expect(result).toEqual([]);
});
});
+28 -1
View File
@@ -7,7 +7,7 @@ const path = require('path');
const os = require('os');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
const { checkForUpdates, getCurrentChannel, getReleasesSince } = require('../services/updateCheckService');
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
const {
checkAndNotifyUpdates,
@@ -71,6 +71,33 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
}
});
// Aggregated changelog — every release between current and latest in
// the user's channel. Powers the update-available modal (#567) so the
// admin can read release notes for ALL versions they're behind on, not
// just the latest. Body is raw GitHub-flavoured markdown; rendering is
// the client's job (frontend uses `marked`).
router.get('/updates/changelog', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
if (!updateCheckEnabled) {
return res.json({ enabled: false, releases: [] });
}
const updateInfo = await checkForUpdates();
const releases = await getReleasesSince(updateInfo.current, updateInfo.channel);
res.json({
enabled: true,
current: updateInfo.current,
channel: updateInfo.channel,
releases,
});
} catch (error) {
logger.error('Error fetching update changelog:', error);
res.status(500).json({ error: 'Failed to fetch changelog' });
}
});
// Get update instructions for current environment
router.get('/updates/instructions', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
+42 -10
View File
@@ -105,7 +105,10 @@ async function fetchAvailableVersions() {
}
);
// Extract version tags from releases
// Extract version tags from releases. We keep the full release
// object (name, body, publishedAt, htmlUrl) so the changelog modal
// (#567) can render the release notes without a second GitHub API
// round-trip per modal open.
const versions = {
stable: [],
beta: []
@@ -118,18 +121,25 @@ async function fetchAvailableVersions() {
// Remove 'v' prefix if present
const version = tag.startsWith('v') ? tag.substring(1) : tag;
const entry = {
version,
tag,
name: release.name || tag,
body: release.body || '',
publishedAt: release.published_at || null,
htmlUrl: release.html_url || null,
};
if (version.match(/^\d+\.\d+\.\d+$/)) {
// Stable version
versions.stable.push(version);
versions.stable.push(entry);
} else if (version.match(/^\d+\.\d+\.\d+-beta\.\d+$/)) {
// Beta version
versions.beta.push(version);
versions.beta.push(entry);
}
}
// Sort versions descending (newest first)
versions.stable.sort((a, b) => compareVersions(b, a));
versions.beta.sort((a, b) => compareVersions(b, a));
versions.stable.sort((a, b) => compareVersions(b.version, a.version));
versions.beta.sort((a, b) => compareVersions(b.version, a.version));
return versions;
} catch (error) {
@@ -162,9 +172,12 @@ async function checkForUpdates(forceRefresh = false) {
};
}
// Determine latest version for current channel
const latestStable = availableVersions.stable[0] || currentVersion;
const latestBeta = availableVersions.beta[0] || currentVersion;
// Determine latest version for current channel. Entry shape is
// `{version, tag, name, body, publishedAt, htmlUrl}` since the rich
// changelog work (#567); fall back to current when GitHub returned
// nothing (e.g. brand-new install before any releases exist).
const latestStable = availableVersions.stable[0]?.version || currentVersion;
const latestBeta = availableVersions.beta[0]?.version || currentVersion;
const latestForChannel = currentChannel === 'beta' ? latestBeta : latestStable;
const updateAvailable = compareVersions(latestForChannel, currentVersion) > 0;
@@ -194,6 +207,24 @@ async function checkForUpdates(forceRefresh = false) {
return result;
}
/**
* Get the list of releases newer than the running version, filtered to
* the user's channel. Powers the changelog modal (#567) so the admin
* sees release notes for every version between current and latest,
* not just the latest.
*
* @returns {Promise<Array<{version, tag, name, body, publishedAt, htmlUrl}>>}
* Releases sorted newest-first. Empty array when no update available
* or when the GitHub fetch failed.
*/
async function getReleasesSince(currentVersion, channel) {
const availableVersions = await fetchAvailableVersions();
if (!availableVersions) return [];
const list = channel === 'beta' ? availableVersions.beta : availableVersions.stable;
return list.filter((entry) => compareVersions(entry.version, currentVersion) > 0);
}
/**
* Clear the version cache (useful for testing)
*/
@@ -206,6 +237,7 @@ module.exports = {
checkForUpdates,
getCurrentVersion,
getCurrentChannel,
getReleasesSince,
compareVersions,
parseVersion,
clearCache