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:
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ const path = require('path');
|
|||||||
const os = require('os');
|
const os = require('os');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const logger = require('../utils/logger');
|
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 { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
|
||||||
const {
|
const {
|
||||||
checkAndNotifyUpdates,
|
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
|
// Get update instructions for current environment
|
||||||
router.get('/updates/instructions', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
router.get('/updates/instructions', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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 = {
|
const versions = {
|
||||||
stable: [],
|
stable: [],
|
||||||
beta: []
|
beta: []
|
||||||
@@ -118,18 +121,25 @@ async function fetchAvailableVersions() {
|
|||||||
// Remove 'v' prefix if present
|
// Remove 'v' prefix if present
|
||||||
const version = tag.startsWith('v') ? tag.substring(1) : tag;
|
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+$/)) {
|
if (version.match(/^\d+\.\d+\.\d+$/)) {
|
||||||
// Stable version
|
versions.stable.push(entry);
|
||||||
versions.stable.push(version);
|
|
||||||
} else if (version.match(/^\d+\.\d+\.\d+-beta\.\d+$/)) {
|
} else if (version.match(/^\d+\.\d+\.\d+-beta\.\d+$/)) {
|
||||||
// Beta version
|
versions.beta.push(entry);
|
||||||
versions.beta.push(version);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort versions descending (newest first)
|
// Sort versions descending (newest first)
|
||||||
versions.stable.sort((a, b) => compareVersions(b, a));
|
versions.stable.sort((a, b) => compareVersions(b.version, a.version));
|
||||||
versions.beta.sort((a, b) => compareVersions(b, a));
|
versions.beta.sort((a, b) => compareVersions(b.version, a.version));
|
||||||
|
|
||||||
return versions;
|
return versions;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -162,9 +172,12 @@ async function checkForUpdates(forceRefresh = false) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine latest version for current channel
|
// Determine latest version for current channel. Entry shape is
|
||||||
const latestStable = availableVersions.stable[0] || currentVersion;
|
// `{version, tag, name, body, publishedAt, htmlUrl}` since the rich
|
||||||
const latestBeta = availableVersions.beta[0] || currentVersion;
|
// 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 latestForChannel = currentChannel === 'beta' ? latestBeta : latestStable;
|
||||||
|
|
||||||
const updateAvailable = compareVersions(latestForChannel, currentVersion) > 0;
|
const updateAvailable = compareVersions(latestForChannel, currentVersion) > 0;
|
||||||
@@ -194,6 +207,24 @@ async function checkForUpdates(forceRefresh = false) {
|
|||||||
return result;
|
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)
|
* Clear the version cache (useful for testing)
|
||||||
*/
|
*/
|
||||||
@@ -206,6 +237,7 @@ module.exports = {
|
|||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
getCurrentVersion,
|
getCurrentVersion,
|
||||||
getCurrentChannel,
|
getCurrentChannel,
|
||||||
|
getReleasesSince,
|
||||||
compareVersions,
|
compareVersions,
|
||||||
parseVersion,
|
parseVersion,
|
||||||
clearCache
|
clearCache
|
||||||
|
|||||||
@@ -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<ChangelogResponse> => {
|
||||||
|
const { data } = await api.get<ChangelogResponse>('/admin/system/updates/changelog');
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchInstructions = async (): Promise<InstructionsResponse> => {
|
||||||
|
const { data } = await api.get<InstructionsResponse>('/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<UpdateAvailableModalProps> = ({
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
onClose,
|
||||||
|
onDismiss,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set([latestVersion]));
|
||||||
|
const [copiedKey, setCopiedKey] = useState<string | null>(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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
padding="none"
|
||||||
|
className="w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"
|
||||||
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between p-5 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ArrowUpCircle className="w-6 h-6 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('admin.updates.modalTitle', 'Update available')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||||
|
{t('admin.updates.modalSubtitle', 'v{{current}} → v{{latest}}', {
|
||||||
|
current: currentVersion,
|
||||||
|
latest: latestVersion,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200"
|
||||||
|
aria-label={t('common.close', 'Close')}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body — scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-5 space-y-5">
|
||||||
|
{/* Upgrade instructions */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
|
||||||
|
{t('admin.updates.howToUpgrade', 'How to upgrade')}
|
||||||
|
</h3>
|
||||||
|
{instructionsLoading && (
|
||||||
|
<p className="text-sm text-neutral-500">{t('common.loading', 'Loading…')}</p>
|
||||||
|
)}
|
||||||
|
{!instructionsLoading && instructions?.instructions && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{instructions.environment?.description && (
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('admin.updates.detectedEnv', 'Detected environment: {{env}}', {
|
||||||
|
env: instructions.environment.description,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{instructions.instructions.steps.map((step, idx) => {
|
||||||
|
const key = `step-${idx}`;
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{idx + 1}. {step.description}
|
||||||
|
</p>
|
||||||
|
{step.command && (
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="text-xs bg-neutral-900 text-neutral-100 rounded p-3 overflow-x-auto">
|
||||||
|
<code>{step.command}</code>
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => copy(step.command!, key)}
|
||||||
|
className="absolute top-2 right-2 p-1.5 rounded hover:bg-neutral-700/50 text-neutral-300"
|
||||||
|
aria-label={t('admin.updates.copyCommand', 'Copy command')}
|
||||||
|
>
|
||||||
|
{copiedKey === key
|
||||||
|
? <CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
|
: <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{instructions.instructions.notes && instructions.instructions.notes.length > 0 && (
|
||||||
|
<ul className="text-xs text-neutral-500 dark:text-neutral-400 list-disc list-inside space-y-1">
|
||||||
|
{instructions.instructions.notes.map((note, idx) => (
|
||||||
|
<li key={idx}>{note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Aggregated changelog */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
|
||||||
|
{t('admin.updates.releaseNotes', 'Release notes')}
|
||||||
|
</h3>
|
||||||
|
{changelogLoading && (
|
||||||
|
<p className="text-sm text-neutral-500">{t('common.loading', 'Loading…')}</p>
|
||||||
|
)}
|
||||||
|
{changelogError && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
{t('admin.updates.changelogError', 'Could not load release notes. Check the release pages directly on GitHub.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{changelog?.releases.length === 0 && !changelogLoading && (
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
{t('admin.updates.noReleases', 'No release notes available.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{changelog && changelog.releases.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{changelog.releases.map((release) => {
|
||||||
|
const isOpen = expanded.has(release.version);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={release.version}
|
||||||
|
className="border border-neutral-200 dark:border-neutral-700 rounded"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(release.version)}
|
||||||
|
className="w-full flex items-center justify-between p-3 hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-left">
|
||||||
|
{isOpen
|
||||||
|
? <ChevronDown className="w-4 h-4 text-neutral-500" />
|
||||||
|
: <ChevronRight className="w-4 h-4 text-neutral-500" />}
|
||||||
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{release.name}
|
||||||
|
</span>
|
||||||
|
{release.publishedAt && (
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
{formatDate(release.publishedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={release.htmlUrl || githubReleaseUrl(release.version)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-xs text-blue-600 hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{t('admin.updates.viewOnGitHub', 'View on GitHub')}
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</button>
|
||||||
|
{isOpen && release.body && (
|
||||||
|
<div className="px-4 pb-4 pt-1 border-t border-neutral-100 dark:border-neutral-800">
|
||||||
|
<MarkdownContent
|
||||||
|
source={release.body}
|
||||||
|
className="text-sm prose prose-sm dark:prose-invert max-w-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isOpen && !release.body && (
|
||||||
|
<div className="px-4 pb-4 pt-1 text-sm text-neutral-500 italic">
|
||||||
|
{t('admin.updates.noNotes', 'No release notes provided.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between gap-3 p-4 border-t border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900/50">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
onDismiss(latestVersion);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('admin.updates.dismissUntilNext', 'Dismiss until next version')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" onClick={onClose}>
|
||||||
|
{t('common.close', 'Close')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Info, ArrowUpCircle } from 'lucide-react';
|
import { Info, ArrowUpCircle } from 'lucide-react';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
import { githubReleaseUrl as releaseUrl } from '../../utils/githubReleaseUrl';
|
import { githubReleaseUrl as releaseUrl } from '../../utils/githubReleaseUrl';
|
||||||
|
import {
|
||||||
|
setDismissedVersion,
|
||||||
|
shouldShowUpdateChip,
|
||||||
|
} from '../../utils/updateDismissal';
|
||||||
|
import { UpdateAvailableModal } from './UpdateAvailableModal';
|
||||||
import packageJson from '../../../package.json';
|
import packageJson from '../../../package.json';
|
||||||
|
|
||||||
// Frontend version from package.json
|
// Frontend version from package.json
|
||||||
@@ -20,6 +25,7 @@ interface SystemVersion {
|
|||||||
interface UpdateInfo {
|
interface UpdateInfo {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
|
current?: string;
|
||||||
latest?: {
|
latest?: {
|
||||||
forChannel: string;
|
forChannel: string;
|
||||||
};
|
};
|
||||||
@@ -37,6 +43,12 @@ async function fetchUpdateInfo(): Promise<UpdateInfo> {
|
|||||||
|
|
||||||
export const VersionInfo: React.FC = () => {
|
export const VersionInfo: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
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({
|
const { data: versionInfo } = useQuery({
|
||||||
queryKey: ['system-version'],
|
queryKey: ['system-version'],
|
||||||
queryFn: fetchSystemVersion,
|
queryFn: fetchSystemVersion,
|
||||||
@@ -56,57 +68,82 @@ export const VersionInfo: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
) : null;
|
) : 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 (
|
return (
|
||||||
<div className="px-4 py-3 border-t border-neutral-200">
|
<>
|
||||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
<div className="px-4 py-3 border-t border-neutral-200">
|
||||||
<Info className="w-3 h-3" />
|
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||||
<span className="font-medium">{t('admin.version')}</span>
|
<Info className="w-3 h-3" />
|
||||||
{channelBadge}
|
<span className="font-medium">{t('admin.version')}</span>
|
||||||
</div>
|
{channelBadge}
|
||||||
<div className="mt-1 space-y-0.5 text-xs text-neutral-500">
|
|
||||||
<div>
|
|
||||||
Frontend:{' '}
|
|
||||||
<a
|
|
||||||
href={releaseUrl(FRONTEND_VERSION)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-neutral-500 hover:text-neutral-700 hover:underline"
|
|
||||||
title={t('admin.viewReleaseNotes', 'View release notes on GitHub')}
|
|
||||||
>
|
|
||||||
v{FRONTEND_VERSION}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{versionInfo && (
|
<div className="mt-1 space-y-0.5 text-xs text-neutral-500">
|
||||||
<div>
|
<div>
|
||||||
Backend:{' '}
|
Frontend:{' '}
|
||||||
<a
|
<a
|
||||||
href={releaseUrl(versionInfo.backend)}
|
href={releaseUrl(FRONTEND_VERSION)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-neutral-500 hover:text-neutral-700 hover:underline"
|
className="text-neutral-500 hover:text-neutral-700 hover:underline"
|
||||||
title={t('admin.viewReleaseNotes', 'View release notes on GitHub')}
|
title={t('admin.viewReleaseNotes', 'View release notes on GitHub')}
|
||||||
>
|
>
|
||||||
v{versionInfo.backend}
|
v{FRONTEND_VERSION}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{versionInfo && (
|
||||||
|
<div>
|
||||||
|
Backend:{' '}
|
||||||
|
<a
|
||||||
|
href={releaseUrl(versionInfo.backend)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-neutral-500 hover:text-neutral-700 hover:underline"
|
||||||
|
title={t('admin.viewReleaseNotes', 'View release notes on GitHub')}
|
||||||
|
>
|
||||||
|
v{versionInfo.backend}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showUpdateChip && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
className="mt-2 flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
|
||||||
|
title={t('admin.updates.viewDetails', 'View release notes and upgrade instructions')}
|
||||||
|
>
|
||||||
|
<ArrowUpCircle className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
{t('admin.updates.updateAvailableShort', 'v{{version}} available', {
|
||||||
|
version: latestVersion
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{updateInfo?.enabled && updateInfo?.updateAvailable && updateInfo.latest && (
|
{modalOpen && latestVersion && (
|
||||||
<a
|
<UpdateAvailableModal
|
||||||
href={releaseUrl(updateInfo.latest.forChannel)}
|
currentVersion={currentVersion}
|
||||||
target="_blank"
|
latestVersion={latestVersion}
|
||||||
rel="noopener noreferrer"
|
onClose={() => setModalOpen(false)}
|
||||||
className="mt-2 flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 hover:underline"
|
onDismiss={handleDismiss}
|
||||||
title={t('admin.viewReleaseNotes', 'View release notes on GitHub')}
|
/>
|
||||||
>
|
|
||||||
<ArrowUpCircle className="w-3 h-3" />
|
|
||||||
<span>
|
|
||||||
{t('admin.updates.updateAvailableShort', 'v{{version}} available', {
|
|
||||||
version: updateInfo.latest.forChannel
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user