From e700fd4977fe8d4bef4ac8328154108735f66a22 Mon Sep 17 00:00:00 2001 From: saltbo Date: Tue, 9 Jun 2026 13:05:54 -0400 Subject: [PATCH] feat(about): add changelog drawer, latest-version check, and commit hash Maintain a CHANGELOG.md (Keep a Changelog format) at the repo root and surface it on the admin About page: - The page now shows the running build's short commit hash next to the version, linked to the GitHub commit. Commit is injected at build time via a new resolveAppCommit() (ZPAN_APP_COMMIT -> WORKERS_CI_COMMIT_SHA -> git rev-parse), wired through vite/tsup defines, the node entry, Docker, and CI. - A new admin-only GET /api/system/changelog endpoint fetches CHANGELOG.md from master on GitHub, caches it, parses the latest released version, and reports whether an update is available (semver compare against the running version). - The About page renders a "latest version" row with an update-available badge and a side drawer that displays the changelog markdown. Tests cover the semver compare, changelog parse/fetch caching, the API wrapper, and the route (admin-gated, parsed payload). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy-cloudflare.yml | 1 + .github/workflows/release.yml | 1 + CHANGELOG.md | 89 +++++++++++++++++++--- Dockerfile | 5 +- scripts/app-version.d.mts | 1 + scripts/app-version.mjs | 16 ++++ server/entry-node.ts | 6 +- server/licensing/instance-info.ts | 3 +- server/routes/system.integration.test.ts | 36 ++++++++- server/routes/system.ts | 9 +++ server/services/changelog.test.ts | 60 +++++++++++++++ server/services/changelog.ts | 34 +++++++++ server/tsup.config.mjs | 4 +- server/version.ts | 7 ++ shared/constants.ts | 5 ++ shared/semver.test.ts | 22 ++++++ shared/semver.ts | 18 +++++ shared/types/index.ts | 2 +- shared/types/instance.ts | 13 ++++ src/i18n/locales/en.json | 8 ++ src/i18n/locales/zh.json | 8 ++ src/lib/api.test.ts | 34 +++++++++ src/lib/api.ts | 5 ++ src/routes/_authenticated/admin/about.tsx | 91 ++++++++++++++++++++--- vite.config.ts | 4 +- 25 files changed, 455 insertions(+), 27 deletions(-) create mode 100644 server/services/changelog.test.ts create mode 100644 server/services/changelog.ts create mode 100644 shared/semver.test.ts create mode 100644 shared/semver.ts diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml index 9e327040..60e8ecd8 100644 --- a/.github/workflows/deploy-cloudflare.yml +++ b/.github/workflows/deploy-cloudflare.yml @@ -189,6 +189,7 @@ jobs: - name: Build env: ZPAN_APP_VERSION: ${{ steps.release.outputs.version }} + ZPAN_APP_COMMIT: ${{ github.sha }} run: pnpm build - name: Deploy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95e869ff..1cd346fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,6 +80,7 @@ jobs: platforms: linux/amd64,linux/arm64 build-args: | APP_VERSION=${{ github.ref_name }} + APP_COMMIT=${{ github.sha }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3edb5c..e4fcd303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,91 @@ # Changelog -All notable changes to this project will be documented in this file. +All notable changes to ZPan are documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.0.0-alpha] - Unreleased +The admin **About** page reads this file straight from `master` on GitHub to show +the latest released version and surface this log in a side drawer. Keep the newest +released version as the first `## [x.y.z]` heading so that detection stays correct. -Complete rewrite from Go to TypeScript. +## [Unreleased] + +### Added +- About page now shows the running build's commit hash next to the version and a + "latest version" indicator that compares the instance against the newest entry + in this changelog, with a side drawer that renders this file. +- PostHog-based instance telemetry for understanding deployment footprint. +- Independent business (commercial) authorization alongside Pro licensing, with an + edition-aware comparison table and gates for social login and the downloader. ### Changed -- New tech stack: Hono + Drizzle + Better Auth -- Dual deployment: Cloudflare Workers and Docker -- R2 as default storage backend -- Better Auth replaces custom JWT implementation +- App version now resolves from `package.json` (overridable via `ZPAN_APP_VERSION`) + instead of `git describe`, so Cloudflare Workers Builds and Docker report correctly. -See [V2_ROADMAP.md](V2_ROADMAP.md) for the full plan. +## [2.7.2] - 2026-06-07 + +### Added +- Refreshed ZPan logo and branding assets. + +### Fixed +- Remote downloader data volume is now writable in the Docker image. + +## [2.7.1] - 2026-06-06 + +### Added +- Admins can edit a remote downloader's display name. + +### Fixed +- Docker remote downloader runtime configuration, host hostname registration, and + torrent listen-port exposure. +- More reliable downloader assignment and transfer-speed reporting. + +## [2.7.0] - 2026-06-06 + +### Added +- Remote download manager: peer geo-region reporting, richer download table + controls, and a detailed transfer/log timeline. +- `zpan` CLI gains the downloader subcommand with device-login auto bootstrap and + configurable server URL. +- Seed retention policy with persistent aria2 seeds that survive restarts. + +### Fixed +- Hardened downloader lifecycle: idempotent resets, stable upload tokens, clean + shutdown/recovery, and correct runtime snapshots after restart. +- WebDAV downloads are now metered against traffic quota; storage usage reconciles + after purge. +- Batch file operations show per-item failure details and progress. + +## [2.6.2] - 2026-05-11 + +### Fixed +- Stability fixes for storage plans and quota metering. + +## [2.6.0] - 2026-05-10 + +### Added +- Quota entitlements: plan-based and extra quotas, monthly traffic quotas, and + metered traffic overage with per-currency pricing. +- Cloud store: manage workspace subscription plans, subscription and fixed-quota + packages, and redemption codes. +- Redesigned storage plan catalog and upgrade prompts with tightened Pro gates. + +## [2.5.0] - 2026-04-22 + +### Added +- New deployment targets: AWS Lambda, Vercel, Netlify, Azure Functions, and Google + Cloud Run. +- libSQL (Turso) platform adapter with an opt-in Docker configuration. +- Avatar upload in Settings → Profile. +- Prefer the R2 binding for image uploads on Cloudflare, falling back to S3. + +## [2.0.0] - 2026 + +Complete rewrite from Go to TypeScript. New stack: Hono + Drizzle + Better Auth, +dual deployment on Cloudflare Workers and Docker, R2 as the default storage +backend. See [V2_ROADMAP.md](V2_ROADMAP.md) for the full plan. --- -For v1 changelog, see the [v1 branch](https://github.com/saltbo/zpan/tree/v1/CHANGELOG.md). +For the v1 changelog, see the [v1 branch](https://github.com/saltbo/zpan/tree/v1/CHANGELOG.md). diff --git a/Dockerfile b/Dockerfile index 7f4b6790..a689bbd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,9 +13,12 @@ RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ COPY . . # .git is excluded from the build context, so git describe cannot run here. -# The release workflow passes the tag via APP_VERSION; resolveAppVersion reads it. +# The release workflow passes the tag via APP_VERSION and the commit SHA via +# APP_COMMIT; resolveAppVersion/resolveAppCommit read them. ARG APP_VERSION=dev ENV ZPAN_APP_VERSION=${APP_VERSION} +ARG APP_COMMIT= +ENV ZPAN_APP_COMMIT=${APP_COMMIT} RUN pnpm build:node \ && pnpm prune --prod --ignore-scripts diff --git a/scripts/app-version.d.mts b/scripts/app-version.d.mts index 7f18250e..ce5afdc7 100644 --- a/scripts/app-version.d.mts +++ b/scripts/app-version.d.mts @@ -1 +1,2 @@ export function resolveAppVersion(): string +export function resolveAppCommit(): string diff --git a/scripts/app-version.mjs b/scripts/app-version.mjs index 2555598f..83aef967 100644 --- a/scripts/app-version.mjs +++ b/scripts/app-version.mjs @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' export function resolveAppVersion() { @@ -11,3 +12,18 @@ export function resolveAppVersion() { const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) return pkg.version } + +export function resolveAppCommit() { + // Release/Docker builds pass the exact SHA via ZPAN_APP_COMMIT; Cloudflare + // Workers Builds injects WORKERS_CI_COMMIT_SHA. Local dev has neither, so fall + // back to git. Returns the short SHA, or '' when no source is available. + const explicit = process.env.ZPAN_APP_COMMIT ?? process.env.WORKERS_CI_COMMIT_SHA + if (explicit) { + return explicit.slice(0, 7) + } + try { + return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim() + } catch { + return '' + } +} diff --git a/server/entry-node.ts b/server/entry-node.ts index 84ca2278..4640e4e8 100644 --- a/server/entry-node.ts +++ b/server/entry-node.ts @@ -2,7 +2,7 @@ import { release as osRelease } from 'node:os' import { serve } from '@hono/node-server' import { serveStatic } from '@hono/node-server/serve-static' import { Hono } from 'hono' -import { resolveAppVersion } from '../scripts/app-version.mjs' +import { resolveAppCommit, resolveAppVersion } from '../scripts/app-version.mjs' import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants' import { createBootstrap } from './bootstrap' import { buildCloudInstanceInfo } from './licensing/instance-info' @@ -20,6 +20,7 @@ const TRAFFIC_SYNC_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes const INSTANCE_TELEMETRY_INTERVAL_MS = 12 * 60 * 60 * 1000 // 12 hours const QUOTA_RESET_INTERVAL_MS = 24 * 60 * 60 * 1000 // daily; idempotent, resets only stale periods const appVersionGlobalKey = '__ZPAN_APP_VERSION__' +const appCommitGlobalKey = '__ZPAN_APP_COMMIT__' // tsx runs this entry directly (dev + E2E) without the tsup build-time define, // so resolve the version at runtime. In the built output the define inlines the @@ -29,6 +30,9 @@ const appVersionGlobalKey = '__ZPAN_APP_VERSION__' if (!globalThis.__ZPAN_APP_VERSION__) { globalThis[appVersionGlobalKey] = resolveAppVersion() } +if (globalThis.__ZPAN_APP_COMMIT__ === undefined) { + globalThis[appCommitGlobalKey] = resolveAppCommit() +} const platform = process.env.TURSO_DATABASE_URL ? await createLibsqlPlatform({ diff --git a/server/licensing/instance-info.ts b/server/licensing/instance-info.ts index 3aabf3a3..81c5529d 100644 --- a/server/licensing/instance-info.ts +++ b/server/licensing/instance-info.ts @@ -3,7 +3,7 @@ import { eq } from 'drizzle-orm' import { systemOptions } from '../db/schema' import type { Database, Platform } from '../platform/interface' import type { CloudInstanceInfo } from '../services/licensing-cloud' -import { getAppVersion } from '../version' +import { getAppCommit, getAppVersion } from '../version' import { getOrCreateInstanceId } from './instance-id' type RuntimeInfo = Pick @@ -42,6 +42,7 @@ export async function buildCloudInstanceInfo( name: await getInstanceDisplayName(db), url: params.url, version: getAppVersion(), + commit: getAppCommit(), ...params.runtime, } } diff --git a/server/routes/system.integration.test.ts b/server/routes/system.integration.test.ts index b79be9a8..92ea962e 100644 --- a/server/routes/system.integration.test.ts +++ b/server/routes/system.integration.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { CAPTCHA_ENABLED_KEY, CAPTCHA_PROVIDER_KEY, CAPTCHA_SECRET_OPTION_KEY, CAPTCHA_SITE_KEY_KEY, } from '../../shared/captcha.js' +import { resetChangelogCache } from '../services/changelog.js' import { adminHeaders, createTestApp } from '../test/setup.js' async function putOption( @@ -134,6 +135,39 @@ describe('System API — options CRUD', () => { expect(body.runtime?.provider).toBe('node') }) + describe('changelog', () => { + afterEach(() => { + vi.unstubAllGlobals() + resetChangelogCache() + }) + + it('serves the parsed changelog to admins only', async () => { + const { app } = await createTestApp() + resetChangelogCache() + const markdown = '## [Unreleased]\n- wip\n\n## [2.8.0] - 2026-07-01\n- shiny' + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, status: 200, text: async () => markdown }) as unknown as Response), + ) + + const anon = await app.request('/api/system/changelog') + expect(anon.status).toBe(401) + + const admin = await adminHeaders(app) + const res = await app.request('/api/system/changelog', { headers: admin }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + currentVersion: string + latestVersion: string + updateAvailable: boolean + markdown: string + } + expect(body.latestVersion).toBe('2.8.0') + expect(body.currentVersion).toBe('test-version') + expect(body.markdown).toBe(markdown) + }) + }) + it('keeps captcha secret private and rejects enabling captcha before keys exist', async () => { const { app } = await createTestApp() const admin = await adminHeaders(app) diff --git a/server/routes/system.ts b/server/routes/system.ts index 03476a44..4605121f 100644 --- a/server/routes/system.ts +++ b/server/routes/system.ts @@ -10,6 +10,7 @@ import { CAPTCHA_PUBLIC_KEYS, } from '../../shared/captcha' import { SignupMode } from '../../shared/constants' +import { compareSemver } from '../../shared/semver' import { systemOptions } from '../db/schema' import { hasFeature, loadBindingState } from '../licensing/has-feature' import { buildCloudInstanceInfo, runtimeInfo } from '../licensing/instance-info' @@ -17,7 +18,9 @@ import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' import { recordActivity } from '../services/activity' import { loadCaptchaOptionValues, readCaptchaConfig } from '../services/captcha' +import { fetchChangelog } from '../services/changelog' import { getSitePublicOrigin, originFromRequestUrl } from '../services/site-public-origin' +import { getAppVersion } from '../version' const setOptionSchema = z.object({ value: z.string(), @@ -32,6 +35,12 @@ const app = new Hono() const info = await buildCloudInstanceInfo(db, { url: origin, runtime: runtimeInfo(platform) }) return c.json(info) }) + .get('/changelog', requireAdmin, async (c) => { + const { latestVersion, markdown } = await fetchChangelog() + const currentVersion = getAppVersion() + const updateAvailable = latestVersion ? compareSemver(latestVersion, currentVersion) > 0 : false + return c.json({ currentVersion, latestVersion, updateAvailable, markdown }) + }) .get('/options', async (c) => { const db = c.get('platform').db const isAdmin = c.get('userRole') === 'admin' diff --git a/server/services/changelog.test.ts b/server/services/changelog.test.ts new file mode 100644 index 00000000..e035fe23 --- /dev/null +++ b/server/services/changelog.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchChangelog, parseLatestVersion, resetChangelogCache } from './changelog.js' + +describe('parseLatestVersion', () => { + it('reads the first released version, skipping Unreleased', () => { + const md = ['# Changelog', '', '## [Unreleased]', '- wip', '', '## [2.7.2] - 2026-06-07', '- fix'].join('\n') + expect(parseLatestVersion(md)).toBe('2.7.2') + }) + + it('tolerates a leading v and missing brackets', () => { + expect(parseLatestVersion('## v2.8.0\n- x')).toBe('2.8.0') + expect(parseLatestVersion('### 3.0.1\n- x')).toBe('3.0.1') + }) + + it('returns null when no version heading is present', () => { + expect(parseLatestVersion('# Changelog\n\n## [Unreleased]\n- wip')).toBeNull() + }) +}) + +describe('fetchChangelog', () => { + beforeEach(() => { + resetChangelogCache() + vi.stubGlobal('fetch', vi.fn()) + }) + afterEach(() => { + vi.unstubAllGlobals() + resetChangelogCache() + }) + + function textResponse(body: string, ok = true, status = 200): Response { + return { ok, status, text: async () => body } as unknown as Response + } + + it('fetches, parses, and caches within the TTL', async () => { + const md = '## [2.7.2] - 2026-06-07\n- fix' + vi.mocked(fetch).mockResolvedValue(textResponse(md)) + + const first = await fetchChangelog(1_000) + expect(first).toEqual({ latestVersion: '2.7.2', markdown: md }) + + const second = await fetchChangelog(1_000 + 60_000) + expect(second).toEqual(first) + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1) + }) + + it('refetches after the TTL expires', async () => { + vi.mocked(fetch).mockResolvedValue(textResponse('## [2.7.2]\n- fix')) + + await fetchChangelog(0) + await fetchChangelog(60 * 60 * 1000 + 1) + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2) + }) + + it('throws when GitHub responds with an error', async () => { + vi.mocked(fetch).mockResolvedValue(textResponse('not found', false, 404)) + + await expect(fetchChangelog(0)).rejects.toThrow(/404/) + }) +}) diff --git a/server/services/changelog.ts b/server/services/changelog.ts new file mode 100644 index 00000000..1a7c9128 --- /dev/null +++ b/server/services/changelog.ts @@ -0,0 +1,34 @@ +import { ZPAN_CHANGELOG_RAW_URL } from '../../shared/constants' + +export interface ChangelogSource { + latestVersion: string | null + markdown: string +} + +// Pull the newest released version from the first `## [x.y.z]` heading. The +// `[Unreleased]` section carries no semver, so it is skipped automatically. +export function parseLatestVersion(markdown: string): string | null { + const match = markdown.match(/^#{2,3}\s*\[?v?(\d+\.\d+\.\d+)\]?/m) + return match ? match[1] : null +} + +const TTL_MS = 60 * 60 * 1000 // 1 hour — releases are infrequent. +let cache: { at: number; value: ChangelogSource } | null = null + +export function resetChangelogCache(): void { + cache = null +} + +export async function fetchChangelog(now: number = Date.now()): Promise { + if (cache && now - cache.at < TTL_MS) { + return cache.value + } + const res = await fetch(ZPAN_CHANGELOG_RAW_URL, { headers: { 'user-agent': 'zpan' } }) + if (!res.ok) { + throw new Error(`Failed to fetch changelog: ${res.status}`) + } + const markdown = await res.text() + const value: ChangelogSource = { latestVersion: parseLatestVersion(markdown), markdown } + cache = { at: now, value } + return value +} diff --git a/server/tsup.config.mjs b/server/tsup.config.mjs index b2041e31..deeacf54 100644 --- a/server/tsup.config.mjs +++ b/server/tsup.config.mjs @@ -1,10 +1,12 @@ import { defineConfig } from 'tsup' -import { resolveAppVersion } from '../scripts/app-version.mjs' +import { resolveAppCommit, resolveAppVersion } from '../scripts/app-version.mjs' const appVersion = resolveAppVersion() +const appCommit = resolveAppCommit() export default defineConfig({ define: { 'globalThis.__ZPAN_APP_VERSION__': JSON.stringify(appVersion), + 'globalThis.__ZPAN_APP_COMMIT__': JSON.stringify(appCommit), }, }) diff --git a/server/version.ts b/server/version.ts index 3bdd9394..12eb0d00 100644 --- a/server/version.ts +++ b/server/version.ts @@ -1,5 +1,6 @@ declare global { var __ZPAN_APP_VERSION__: string | undefined + var __ZPAN_APP_COMMIT__: string | undefined } export function getAppVersion(): string { @@ -8,3 +9,9 @@ export function getAppVersion(): string { } return globalThis.__ZPAN_APP_VERSION__ } + +// The commit SHA is best-effort: absent in build environments without git or an +// injected SHA. Returns null rather than throwing so the About page degrades. +export function getAppCommit(): string | null { + return globalThis.__ZPAN_APP_COMMIT__ || null +} diff --git a/shared/constants.ts b/shared/constants.ts index f42bac60..7a0f126f 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -45,6 +45,11 @@ export type SignupMode = (typeof SignupMode)[keyof typeof SignupMode] export const ZPAN_CLOUD_URL_DEFAULT = 'https://cloud.zpan.space' export const ZPAN_GITHUB_URL = 'https://github.com/saltbo/zpan' +// The About page fetches CHANGELOG.md straight from master to detect the latest +// release and render the log; raw.githubusercontent.com serves the file with CORS. +export const ZPAN_CHANGELOG_RAW_URL = 'https://raw.githubusercontent.com/saltbo/zpan/master/CHANGELOG.md' +// Build a link to a specific commit on GitHub. +export const githubCommitUrl = (sha: string) => `${ZPAN_GITHUB_URL}/commit/${sha}` export const DEFAULT_SITE_NAME = 'ZPan' export const DEFAULT_SITE_DESCRIPTION = '' export const DEFAULT_ORG_QUOTA = 10 * 1024 * 1024 diff --git a/shared/semver.test.ts b/shared/semver.test.ts new file mode 100644 index 00000000..1ef6681d --- /dev/null +++ b/shared/semver.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { compareSemver } from './semver' + +describe('compareSemver', () => { + it('orders by major, minor, then patch', () => { + expect(compareSemver('2.8.0', '2.7.2')).toBe(1) + expect(compareSemver('2.7.2', '2.8.0')).toBe(-1) + expect(compareSemver('3.0.0', '2.9.9')).toBe(1) + expect(compareSemver('2.7.3', '2.7.2')).toBe(1) + expect(compareSemver('2.7.2', '2.7.2')).toBe(0) + }) + + it('tolerates a leading v and pre-release/build suffixes', () => { + expect(compareSemver('v2.8.0', '2.7.2')).toBe(1) + expect(compareSemver('2.8.0-beta.1', '2.8.0')).toBe(0) + }) + + it('returns 0 when either version is unparseable', () => { + expect(compareSemver('not-a-version', '2.7.2')).toBe(0) + expect(compareSemver('2.7.2', '')).toBe(0) + }) +}) diff --git a/shared/semver.ts b/shared/semver.ts new file mode 100644 index 00000000..83e18428 --- /dev/null +++ b/shared/semver.ts @@ -0,0 +1,18 @@ +// Minimal semver comparison for the version freshness check on the About page. +// Compares the major.minor.patch core only; any pre-release/build suffix is +// ignored. Returns 1 if a > b, -1 if a < b, 0 if equal or either is unparseable. +export function compareSemver(a: string, b: string): number { + const parse = (v: string): [number, number, number] | null => { + const m = v.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/) + if (!m) return null + return [Number(m[1]), Number(m[2]), Number(m[3])] + } + const pa = parse(a) + const pb = parse(b) + if (!pa || !pb) return 0 + for (let i = 0; i < 3; i++) { + if (pa[i] > pb[i]) return 1 + if (pa[i] < pb[i]) return -1 + } + return 0 +} diff --git a/shared/types/index.ts b/shared/types/index.ts index cd8a447d..1235c5ae 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -584,7 +584,7 @@ export interface ImageHosting { createdAt: string } -export type { InstanceInfo } from './instance' +export type { ChangelogInfo, InstanceInfo } from './instance' export type { BindingState, LicenseAssertion, LicenseEdition, LicenseFeature, ProFeature } from './licensing' export type BrandingThemePresetId = 'default' | 'ocean' | 'forest' | 'rose' diff --git a/shared/types/instance.ts b/shared/types/instance.ts index f56fbe19..c97d9250 100644 --- a/shared/types/instance.ts +++ b/shared/types/instance.ts @@ -4,6 +4,7 @@ export interface InstanceInfo { name: string url: string version: string + commit?: string | null runtime?: { provider: 'cloudflare' | 'node' target: 'cloudflare-worker' | 'node/docker' @@ -19,3 +20,15 @@ export interface InstanceInfo { version?: string | null } | null } + +// Changelog feed shown on the About page, sourced from CHANGELOG.md on GitHub. +export interface ChangelogInfo { + // The version this instance is running, from the build-time version global. + currentVersion: string + // Newest released version parsed from the changelog, or null if undetectable. + latestVersion: string | null + // True when latestVersion is strictly newer than currentVersion. + updateAvailable: boolean + // Raw CHANGELOG.md markdown for rendering in the drawer. + markdown: string +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5b181986..21fba83b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1338,6 +1338,14 @@ "admin.about.runtime": "Runtime", "admin.about.server": "Server", "admin.about.nodeVersion": "Node.js", + "admin.about.latestVersion": "Latest version", + "admin.about.updateAvailable": "Update available", + "admin.about.upToDate": "Up to date", + "admin.about.commitTooltip": "View this commit on GitHub", + "admin.about.viewChangelog": "Changelog", + "admin.about.changelogTitle": "Changelog", + "admin.about.changelogSubtitle": "What's new in each release.", + "admin.about.changelogError": "Could not load the changelog. Check your connection to GitHub and try again.", "admin.about.github.title": "Open Source", "admin.about.github.description": "ZPan is open source. If it helps you, give us a star on GitHub.", "admin.about.github.cta": "Star on GitHub", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 9b63dc56..98c27dac 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1338,6 +1338,14 @@ "admin.about.runtime": "运行时", "admin.about.server": "服务器", "admin.about.nodeVersion": "Node.js", + "admin.about.latestVersion": "最新版本", + "admin.about.updateAvailable": "有新版本", + "admin.about.upToDate": "已是最新", + "admin.about.commitTooltip": "在 GitHub 上查看此提交", + "admin.about.viewChangelog": "更新日志", + "admin.about.changelogTitle": "更新日志", + "admin.about.changelogSubtitle": "了解每个版本的新增功能。", + "admin.about.changelogError": "无法加载更新日志,请检查与 GitHub 的网络连接后重试。", "admin.about.github.title": "开源项目", "admin.about.github.description": "ZPan 是开源项目。如果它对你有帮助,欢迎在 GitHub 上点个 Star。", "admin.about.github.cta": "在 GitHub 点 Star", diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index b971aba7..6569e537 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -43,6 +43,7 @@ import { getAnnouncement, getBackgroundJob, getBranding, + getChangelog, getCloudCredits, getEmailConfig, getIhostConfig, @@ -2928,6 +2929,39 @@ describe('api', () => { }) }) + describe('getChangelog', () => { + const changelog = { + currentVersion: '2.7.2', + latestVersion: '2.8.0', + updateAvailable: true, + markdown: '## [2.8.0]\n- new stuff', + } + + it('calls the correct endpoint', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(changelog)) + + await getChangelog() + + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toContain('/api/system/changelog') + expect(init?.method ?? 'GET').toBe('GET') + }) + + it('returns the changelog payload', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(changelog)) + + const result = await getChangelog() + + expect(result).toEqual(changelog) + }) + + it('throws ApiError on failure', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403)) + + await expect(getChangelog()).rejects.toThrow() + }) + }) + describe('connectCloud', () => { it('calls the correct endpoint with POST', async () => { const payload = { diff --git a/src/lib/api.ts b/src/lib/api.ts index e0b942e1..cbe5767d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -33,6 +33,7 @@ import type { BrandingThemeMode, BrandingThemePresetId, BrandingThemeValues, + ChangelogInfo, CloudOrder, CloudProduct, CloudStoreTarget, @@ -1028,6 +1029,10 @@ export function getInstanceInfo() { return unwrap(system.instance.$get()) } +export function getChangelog() { + return unwrap(system.changelog.$get()) +} + export function connectCloud() { return unwrap(licensingAdminApi.pair.$post()) } diff --git a/src/routes/_authenticated/admin/about.tsx b/src/routes/_authenticated/admin/about.tsx index 45302797..93d86744 100644 --- a/src/routes/_authenticated/admin/about.tsx +++ b/src/routes/_authenticated/admin/about.tsx @@ -1,15 +1,17 @@ -import { ZPAN_CLOUD_URL_DEFAULT, ZPAN_GITHUB_URL } from '@shared/constants' +import { githubCommitUrl, ZPAN_CLOUD_URL_DEFAULT, ZPAN_GITHUB_URL } from '@shared/constants' import { useQuery } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' -import { BadgeCheck, ExternalLink, Github, Server, Sparkles, Star } from 'lucide-react' -import type { ReactNode } from 'react' +import { ArrowUpCircle, BadgeCheck, ExternalLink, Github, History, 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' +import { AnnouncementMarkdown } from '@/components/announcements/markdown-content' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet' import { useEntitlement } from '@/hooks/useEntitlement' -import { getInstanceInfo } from '@/lib/api' +import { getChangelog, getInstanceInfo } from '@/lib/api' import { EDITION_COLORS, editionKey } from '@/lib/license-edition' export const Route = createFileRoute('/_authenticated/admin/about')({ @@ -28,11 +30,17 @@ function InfoRow({ label, children }: { label: string; children: ReactNode }) { function AboutPage() { const { t } = useTranslation() const { bound, active, edition, licenseId, cloudDashboardUrl } = useEntitlement() + const [changelogOpen, setChangelogOpen] = useState(false) const { data: instance, isLoading } = useQuery({ queryKey: ['system', 'instance'], queryFn: getInstanceInfo, staleTime: 5 * 60 * 1000, }) + const { data: changelog, isError: changelogError } = useQuery({ + queryKey: ['system', 'changelog'], + queryFn: getChangelog, + staleTime: 60 * 60 * 1000, + }) if (isLoading || !instance) return null @@ -52,15 +60,31 @@ function AboutPage() { v{instance.version}} + badge={ +
+ v{instance.version} + {changelog?.updateAvailable && ( + + + {t('admin.about.updateAvailable')} + + )} +
+ } /> - - - {t('admin.about.instanceTitle')} - +
+ + + {t('admin.about.instanceTitle')} + + +
{instance.name} @@ -78,7 +102,36 @@ function AboutPage() { - {instance.version} + + + v{instance.version} + {instance.commit && ( + + {instance.commit} + + )} + + + {changelog && ( + + + {changelog.latestVersion ? `v${changelog.latestVersion}` : '—'} + {changelog.updateAvailable ? ( + + {t('admin.about.updateAvailable')} + + ) : ( + {t('admin.about.upToDate')} + )} + + + )} {editionLabel} @@ -140,6 +193,24 @@ function AboutPage() {
+ + + + + {t('admin.about.changelogTitle')} + {t('admin.about.changelogSubtitle')} + +
+ {changelogError ? ( +

{t('admin.about.changelogError')}

+ ) : changelog ? ( + + ) : ( +

{t('common.loading')}

+ )} +
+
+
) } diff --git a/vite.config.ts b/vite.config.ts index c57e5cca..496f0f15 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,15 +4,17 @@ import tailwindcss from '@tailwindcss/vite' import { TanStackRouterVite } from '@tanstack/router-plugin/vite' import react from '@vitejs/plugin-react-swc' import { defineConfig } from 'vite' -import { resolveAppVersion } from './scripts/app-version.mjs' +import { resolveAppCommit, resolveAppVersion } from './scripts/app-version.mjs' const appPort = Number(process.env.E2E_APP_PORT ?? 5185) const apiPort = Number(process.env.E2E_API_PORT ?? 8222) const appVersion = resolveAppVersion() +const appCommit = resolveAppCommit() export default defineConfig(({ mode }) => ({ define: { 'globalThis.__ZPAN_APP_VERSION__': JSON.stringify(appVersion), + 'globalThis.__ZPAN_APP_COMMIT__': JSON.stringify(appCommit), }, build: { outDir: 'dist',