diff --git a/backend/__tests__/services/updateCheckService.getReleasesSince.test.js b/backend/__tests__/services/updateCheckService.getReleasesSince.test.js new file mode 100644 index 00000000..3c43f016 --- /dev/null +++ b/backend/__tests__/services/updateCheckService.getReleasesSince.test.js @@ -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([]); + }); +}); diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js index e72af785..672542eb 100644 --- a/backend/src/routes/adminSystem.js +++ b/backend/src/routes/adminSystem.js @@ -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 { diff --git a/backend/src/services/updateCheckService.js b/backend/src/services/updateCheckService.js index 549b29b6..7228f92e 100644 --- a/backend/src/services/updateCheckService.js +++ b/backend/src/services/updateCheckService.js @@ -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>} + * 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 diff --git a/frontend/src/components/admin/UpdateAvailableModal.tsx b/frontend/src/components/admin/UpdateAvailableModal.tsx new file mode 100644 index 00000000..00c846f2 --- /dev/null +++ b/frontend/src/components/admin/UpdateAvailableModal.tsx @@ -0,0 +1,312 @@ +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { X, ExternalLink, Copy, CheckCircle, ChevronDown, ChevronRight, ArrowUpCircle } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { api } from '../../config/api'; +import { Button, Card } from '../common'; +import { MarkdownContent } from '../common/MarkdownContent'; +import { githubReleaseUrl } from '../../utils/githubReleaseUrl'; + +/** + * Update-available modal (#567). + * + * Opened from the sidebar "vX.Y.Z available" chip. Shows: + * - Aggregated release notes for every version between current and + * latest in the user's channel (one collapsible section each). + * - Copy-paste upgrade command tailored to the detected environment + * (Docker compose, native git, standalone). + * - "Dismiss this version" — writes to localStorage so the chip + * doesn't reappear until an even newer version is published. + */ + +interface ReleaseEntry { + version: string; + tag: string; + name: string; + body: string; + publishedAt: string | null; + htmlUrl: string | null; +} + +interface ChangelogResponse { + enabled: boolean; + current: string; + channel: string; + releases: ReleaseEntry[]; +} + +interface InstructionStep { + description: string; + command?: string; + url?: string; +} + +interface InstructionsResponse { + updateAvailable: boolean; + currentVersion: string; + targetVersion?: string; + environment?: { type: string; description?: string }; + instructions?: { + title: string; + description?: string; + steps: InstructionStep[]; + notes?: string[]; + }; + releaseNotesUrl?: string; +} + +interface UpdateAvailableModalProps { + currentVersion: string; + latestVersion: string; + onClose: () => void; + onDismiss: (version: string) => void; +} + +const fetchChangelog = async (): Promise => { + const { data } = await api.get('/admin/system/updates/changelog'); + return data; +}; + +const fetchInstructions = async (): Promise => { + const { data } = await api.get('/admin/system/updates/instructions'); + return data; +}; + +const formatDate = (iso: string | null): string => { + if (!iso) return ''; + try { + return new Date(iso).toLocaleDateString(); + } catch { + return iso; + } +}; + +export const UpdateAvailableModal: React.FC = ({ + currentVersion, + latestVersion, + onClose, + onDismiss, +}) => { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState>(new Set([latestVersion])); + const [copiedKey, setCopiedKey] = useState(null); + + const { data: changelog, isLoading: changelogLoading, isError: changelogError } = useQuery({ + queryKey: ['update-changelog'], + queryFn: fetchChangelog, + staleTime: 60 * 60 * 1000, + }); + + const { data: instructions, isLoading: instructionsLoading } = useQuery({ + queryKey: ['update-instructions'], + queryFn: fetchInstructions, + staleTime: 60 * 60 * 1000, + }); + + const toggle = (version: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(version)) next.delete(version); + else next.add(version); + return next; + }); + }; + + const copy = async (text: string, key: string) => { + try { + await navigator.clipboard.writeText(text); + setCopiedKey(key); + setTimeout(() => setCopiedKey((current) => (current === key ? null : current)), 2000); + } catch { + toast.error(t('admin.updates.copyFailed', 'Could not copy to clipboard')); + } + }; + + return ( +
+ e.stopPropagation()} + > + {/* Header */} +
+
+ +
+

+ {t('admin.updates.modalTitle', 'Update available')} +

+

+ {t('admin.updates.modalSubtitle', 'v{{current}} → v{{latest}}', { + current: currentVersion, + latest: latestVersion, + })} +

+
+
+ +
+ + {/* Body — scrollable */} +
+ {/* Upgrade instructions */} +
+

+ {t('admin.updates.howToUpgrade', 'How to upgrade')} +

+ {instructionsLoading && ( +

{t('common.loading', 'Loading…')}

+ )} + {!instructionsLoading && instructions?.instructions && ( +
+ {instructions.environment?.description && ( +

+ {t('admin.updates.detectedEnv', 'Detected environment: {{env}}', { + env: instructions.environment.description, + })} +

+ )} + {instructions.instructions.steps.map((step, idx) => { + const key = `step-${idx}`; + return ( +
+

+ {idx + 1}. {step.description} +

+ {step.command && ( +
+
+                            {step.command}
+                          
+ +
+ )} +
+ ); + })} + {instructions.instructions.notes && instructions.instructions.notes.length > 0 && ( +
    + {instructions.instructions.notes.map((note, idx) => ( +
  • {note}
  • + ))} +
+ )} +
+ )} +
+ + {/* Aggregated changelog */} +
+

+ {t('admin.updates.releaseNotes', 'Release notes')} +

+ {changelogLoading && ( +

{t('common.loading', 'Loading…')}

+ )} + {changelogError && ( +

+ {t('admin.updates.changelogError', 'Could not load release notes. Check the release pages directly on GitHub.')} +

+ )} + {changelog?.releases.length === 0 && !changelogLoading && ( +

+ {t('admin.updates.noReleases', 'No release notes available.')} +

+ )} + {changelog && changelog.releases.length > 0 && ( +
+ {changelog.releases.map((release) => { + const isOpen = expanded.has(release.version); + return ( +
+ + {isOpen && release.body && ( +
+ +
+ )} + {isOpen && !release.body && ( +
+ {t('admin.updates.noNotes', 'No release notes provided.')} +
+ )} +
+ ); + })} +
+ )} +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/components/admin/VersionInfo.tsx b/frontend/src/components/admin/VersionInfo.tsx index 653cac38..b99d5e41 100644 --- a/frontend/src/components/admin/VersionInfo.tsx +++ b/frontend/src/components/admin/VersionInfo.tsx @@ -1,9 +1,14 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Info, ArrowUpCircle } from 'lucide-react'; import { api } from '../../config/api'; import { githubReleaseUrl as releaseUrl } from '../../utils/githubReleaseUrl'; +import { + setDismissedVersion, + shouldShowUpdateChip, +} from '../../utils/updateDismissal'; +import { UpdateAvailableModal } from './UpdateAvailableModal'; import packageJson from '../../../package.json'; // Frontend version from package.json @@ -20,6 +25,7 @@ interface SystemVersion { interface UpdateInfo { enabled: boolean; updateAvailable: boolean; + current?: string; latest?: { forChannel: string; }; @@ -37,6 +43,12 @@ async function fetchUpdateInfo(): Promise { export const VersionInfo: React.FC = () => { const { t } = useTranslation(); + const [modalOpen, setModalOpen] = useState(false); + // Re-render trigger so dismissing in the modal immediately hides the + // chip without waiting for the next route change. State value is + // irrelevant; we just bump it. + const [dismissBump, setDismissBump] = useState(0); + const { data: versionInfo } = useQuery({ queryKey: ['system-version'], queryFn: fetchSystemVersion, @@ -56,57 +68,82 @@ export const VersionInfo: React.FC = () => { ) : null; + const latestVersion = updateInfo?.latest?.forChannel; + const currentVersion = updateInfo?.current || versionInfo?.backend || FRONTEND_VERSION; + // `dismissBump` referenced here so React re-runs the dismissal check + // immediately after the modal calls handleDismiss; the value itself + // is unused. Voiding it keeps lint happy without an eslint-disable. + void dismissBump; + const showUpdateChip = updateInfo?.enabled + && updateInfo?.updateAvailable + && !!latestVersion + && shouldShowUpdateChip(latestVersion); + + const handleDismiss = (version: string) => { + setDismissedVersion(version); + setDismissBump((n) => n + 1); + }; + return ( -
-
- - {t('admin.version')} - {channelBadge} -
-
-
- Frontend:{' '} - - v{FRONTEND_VERSION} - + <> +
+
+ + {t('admin.version')} + {channelBadge}
- {versionInfo && ( +
- Backend:{' '} + Frontend:{' '} - v{versionInfo.backend} + v{FRONTEND_VERSION}
+ {versionInfo && ( +
+ Backend:{' '} + + v{versionInfo.backend} + +
+ )} +
+ {showUpdateChip && ( + )}
- {updateInfo?.enabled && updateInfo?.updateAvailable && updateInfo.latest && ( - - - - {t('admin.updates.updateAvailableShort', 'v{{version}} available', { - version: updateInfo.latest.forChannel - })} - - + {modalOpen && latestVersion && ( + setModalOpen(false)} + onDismiss={handleDismiss} + /> )} -
+ ); -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/__tests__/updateDismissal.test.ts b/frontend/src/utils/__tests__/updateDismissal.test.ts new file mode 100644 index 00000000..b78a61f8 --- /dev/null +++ b/frontend/src/utils/__tests__/updateDismissal.test.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/updateDismissal.ts b/frontend/src/utils/updateDismissal.ts new file mode 100644 index 00000000..e97f8793 --- /dev/null +++ b/frontend/src/utils/updateDismissal.ts @@ -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); +};