feat(about): add changelog refresh button that bypasses the cache

The changelog is cached server-side (1h) and by React Query, so stale content
lingered. Add a refresh icon in the drawer header that calls the endpoint with
?refresh=true to skip the server cache and replaces the cached query data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saltbo
2026-06-09 14:20:02 -04:00
co-authored by Claude Opus 4.8
parent b9d4429286
commit dc936e5eff
7 changed files with 50 additions and 9 deletions
+3 -2
View File
@@ -35,8 +35,9 @@ const app = new Hono<Env>()
const info = await buildCloudInstanceInfo(db, { url: origin, runtime: runtimeInfo(platform) })
return c.json(info)
})
.get('/changelog', requireAdmin, async (c) => {
const { latestVersion, markdown } = await fetchChangelog()
.get('/changelog', requireAdmin, zValidator('query', z.object({ refresh: z.string().optional() })), async (c) => {
const force = c.req.valid('query').refresh === 'true'
const { latestVersion, markdown } = await fetchChangelog(Date.now(), { force })
const currentVersion = getAppVersion()
const updateAvailable = latestVersion ? compareSemver(latestVersion, currentVersion) > 0 : false
return c.json({ currentVersion, latestVersion, updateAvailable, markdown })
+5 -2
View File
@@ -39,8 +39,11 @@ export function resetChangelogCache(): void {
cache = null
}
export async function fetchChangelog(now: number = Date.now()): Promise<ChangelogSource> {
if (cache && now - cache.at < TTL_MS) {
export async function fetchChangelog(
now: number = Date.now(),
opts: { force?: boolean } = {},
): Promise<ChangelogSource> {
if (!opts.force && cache && now - cache.at < TTL_MS) {
return cache.value
}
const [latestVersion, markdown] = await Promise.all([fetchLatestReleaseVersion(), fetchChangelogMarkdown()])
+1
View File
@@ -1342,6 +1342,7 @@
"admin.about.updateAvailable": "Update available",
"admin.about.upToDate": "Up to date",
"admin.about.commit": "Commit",
"admin.about.refresh": "Refresh changelog",
"admin.about.commitTooltip": "View this commit on GitHub",
"admin.about.changelogTitle": "Changelog",
"admin.about.changelogSubtitle": "What's new in each release.",
+1
View File
@@ -1342,6 +1342,7 @@
"admin.about.updateAvailable": "有新版本",
"admin.about.upToDate": "已是最新",
"admin.about.commit": "提交",
"admin.about.refresh": "刷新更新日志",
"admin.about.commitTooltip": "在 GitHub 上查看此提交",
"admin.about.changelogTitle": "更新日志",
"admin.about.changelogSubtitle": "了解每个版本的新增功能。",
+10
View File
@@ -2955,6 +2955,16 @@ describe('api', () => {
expect(result).toEqual(changelog)
})
it('appends refresh=true to bypass the server cache', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(changelog))
await getChangelog({ refresh: true })
const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/system/changelog')
expect(url).toContain('refresh=true')
})
it('throws ApiError on failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
+2 -2
View File
@@ -1029,8 +1029,8 @@ export function getInstanceInfo() {
return unwrap<InstanceInfo>(system.instance.$get())
}
export function getChangelog() {
return unwrap<ChangelogInfo>(system.changelog.$get())
export function getChangelog(opts?: { refresh?: boolean }) {
return unwrap<ChangelogInfo>(system.changelog.$get({ query: opts?.refresh ? { refresh: 'true' } : {} }))
}
export function connectCloud() {
+28 -3
View File
@@ -1,7 +1,7 @@
import { githubCommitUrl, ZPAN_CLOUD_URL_DEFAULT, ZPAN_GITHUB_URL } from '@shared/constants'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { ArrowUpCircle, BadgeCheck, ExternalLink, Github, Server, Sparkles, Star } from 'lucide-react'
import { ArrowUpCircle, BadgeCheck, ExternalLink, Github, RefreshCw, Server, Sparkles, Star } from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AdminPageHeader } from '@/components/admin/admin-page-header'
@@ -31,6 +31,8 @@ function AboutPage() {
const { t } = useTranslation()
const { bound, active, edition, licenseId, cloudDashboardUrl } = useEntitlement()
const [changelogOpen, setChangelogOpen] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const queryClient = useQueryClient()
const { data: instance, isLoading } = useQuery({
queryKey: ['system', 'instance'],
queryFn: getInstanceInfo,
@@ -38,10 +40,22 @@ function AboutPage() {
})
const { data: changelog, isError: changelogError } = useQuery({
queryKey: ['system', 'changelog'],
queryFn: getChangelog,
queryFn: () => getChangelog(),
staleTime: 60 * 60 * 1000,
})
// Bypass the server-side cache (?refresh=true) and replace the cached data so
// the drawer shows the freshly fetched changelog without a full refetch.
async function refreshChangelog() {
setRefreshing(true)
try {
const fresh = await getChangelog({ refresh: true })
queryClient.setQueryData(['system', 'changelog'], fresh)
} finally {
setRefreshing(false)
}
}
if (isLoading || !instance) return null
const key = editionKey(bound, edition)
@@ -199,6 +213,17 @@ function AboutPage() {
<SheetTitle>{t('admin.about.changelogTitle')}</SheetTitle>
<SheetDescription>{t('admin.about.changelogSubtitle')}</SheetDescription>
</SheetHeader>
<Button
variant="ghost"
size="icon"
onClick={refreshChangelog}
disabled={refreshing}
title={t('admin.about.refresh')}
className="absolute top-4 right-12 h-7 w-7 text-muted-foreground"
>
<RefreshCw className={refreshing ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} />
<span className="sr-only">{t('admin.about.refresh')}</span>
</Button>
<div className="flex-1 overflow-y-auto px-4 pb-6">
{changelogError ? (
<p className="text-sm text-muted-foreground">{t('admin.about.changelogError')}</p>