Merge pull request #575 from the-luap/feat/clickable-version-links-566

feat(admin): clickable version links + update-available modal with changelog & upgrade command
This commit is contained in:
Paul Nothaft
2026-05-29 11:35:06 +02:00
committed by GitHub
9 changed files with 769 additions and 33 deletions
@@ -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>
);
};
+88 -22
View File
@@ -1,8 +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
@@ -19,6 +25,7 @@ interface SystemVersion {
interface UpdateInfo {
enabled: boolean;
updateAvailable: boolean;
current?: string;
latest?: {
forChannel: string;
};
@@ -36,6 +43,12 @@ async function fetchUpdateInfo(): Promise<UpdateInfo> {
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,
@@ -55,29 +68,82 @@ export const VersionInfo: React.FC = () => {
</span>
) : 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 (
<div className="px-4 py-3 border-t border-neutral-200">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<Info className="w-3 h-3" />
<span className="font-medium">{t('admin.version')}</span>
{channelBadge}
</div>
<div className="mt-1 space-y-0.5 text-xs text-neutral-500">
<div>Frontend: v{FRONTEND_VERSION}</div>
{versionInfo && (
<div>Backend: v{versionInfo.backend}</div>
<>
<div className="px-4 py-3 border-t border-neutral-200">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<Info className="w-3 h-3" />
<span className="font-medium">{t('admin.version')}</span>
{channelBadge}
</div>
<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>
{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>
{updateInfo?.enabled && updateInfo?.updateAvailable && (
<div className="mt-2 flex items-center gap-1 text-xs text-blue-600">
<ArrowUpCircle className="w-3 h-3" />
<span>
{t('admin.updates.updateAvailableShort', 'v{{version}} available', {
version: updateInfo.latest?.forChannel
})}
</span>
</div>
{modalOpen && latestVersion && (
<UpdateAvailableModal
currentVersion={currentVersion}
latestVersion={latestVersion}
onClose={() => setModalOpen(false)}
onDismiss={handleDismiss}
/>
)}
</div>
</>
);
};
};
@@ -0,0 +1,30 @@
/**
* Regression coverage for the version → release-page URL template (#566).
*
* release-please tags every release as `vX.Y.Z[-beta.N]`. If anyone
* later refactors the helper (e.g. switches the prefix, the repo path,
* or strips the channel suffix), these assertions will fail loud
* instead of silently producing dead links in the admin sidebar.
*/
import { describe, expect, it } from 'vitest';
import { githubReleaseUrl } from '../githubReleaseUrl';
describe('githubReleaseUrl', () => {
it('maps a stable version to its tag page', () => {
expect(githubReleaseUrl('3.55.0')).toBe(
'https://github.com/the-luap/picpeak/releases/tag/v3.55.0',
);
});
it('maps a beta version (with channel suffix) to its tag page', () => {
expect(githubReleaseUrl('3.55.0-beta.0')).toBe(
'https://github.com/the-luap/picpeak/releases/tag/v3.55.0-beta.0',
);
});
it('does not double-prefix the v when given a bare version', () => {
// Defensive — the helper is the single source of truth for the
// leading "v". Callers must pass the bare version string.
expect(githubReleaseUrl('3.55.0')).not.toContain('vv');
});
});
@@ -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);
});
});
+14
View File
@@ -0,0 +1,14 @@
/**
* Build the GitHub release page URL for a given version string.
*
* Version strings already carry their channel suffix (e.g. `3.55.0`
* for stable, `3.55.0-beta.0` for beta). release-please tags every
* release as `vX.Y.Z[-beta.N]`, so a pure string template covers
* both channels without branching on the suffix.
*
* Used by the admin sidebar version display to deep-link to release
* notes for the running version (#566) and by the update-available
* indicator to link to the upgrade target's notes.
*/
export const githubReleaseUrl = (version: string): string =>
`https://github.com/the-luap/picpeak/releases/tag/v${version}`;
+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);
};