diff --git a/apps/core/src/common/version_check.test.ts b/apps/core/src/common/version_check.test.ts index 000b3903..cb58f40e 100644 --- a/apps/core/src/common/version_check.test.ts +++ b/apps/core/src/common/version_check.test.ts @@ -9,7 +9,7 @@ import { mock, spyOn, } from 'bun:test'; -import { checkVersion } from './version_check'; +import { checkVersion, createVersionChecker } from './version_check'; describe('Version Checking', () => { let originalFetch: typeof globalThis.fetch; @@ -171,3 +171,143 @@ describe('Version Checking', () => { }); }); }); + +describe('createVersionChecker', () => { + let originalFetch: typeof globalThis.fetch; + let errorSpy: ReturnType; + + const mockGitHubRelease = ( + tagName: string, + url = 'https://github.com/mock/release', + ) => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ tag_name: tagName, html_url: url }), { + status: 200, + }), + ), + ) as any; + }; + + beforeEach(() => { + originalFetch = globalThis.fetch; + errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + errorSpy.mockRestore(); + }); + + it('flags an available update and links to the release', async () => { + mockGitHubRelease('v0.2.2', 'https://github.com/mock/v0.2.2'); + const get = createVersionChecker({ currentVersion: '0.2.1' }); + + expect(await get()).toEqual({ + current: '0.2.1', + latest: 'v0.2.2', + latestUrl: 'https://github.com/mock/v0.2.2', + updateAvailable: true, + isBeta: false, + isDev: false, + }); + }); + + it('reports no update when already on the latest release', async () => { + mockGitHubRelease('v0.2.1'); + const get = createVersionChecker({ currentVersion: '0.2.1' }); + + expect(await get()).toMatchObject({ updateAvailable: false }); + }); + + it('reports no update when ahead of the latest release', async () => { + mockGitHubRelease('v0.2.1'); + const get = createVersionChecker({ currentVersion: '0.3.0' }); + + expect(await get()).toMatchObject({ updateAvailable: false }); + }); + + it('never nags a beta build but still surfaces the latest stable', async () => { + mockGitHubRelease('v0.2.2'); + const get = createVersionChecker({ currentVersion: '0.2.1-b' }); + + expect(await get()).toMatchObject({ + isBeta: true, + updateAvailable: false, + latest: 'v0.2.2', + }); + }); + + it('short-circuits dev-build without hitting the network', async () => { + globalThis.fetch = mock(() => Promise.resolve(new Response())) as any; + const get = createVersionChecker({ currentVersion: 'dev-build' }); + + expect(await get()).toMatchObject({ isDev: true, updateAvailable: false }); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('serves a cached value without re-fetching inside the TTL', async () => { + mockGitHubRelease('v0.2.2'); + const get = createVersionChecker({ + currentVersion: '0.2.1', + ttlMs: 1000, + now: () => 0, + }); + + await get(); + await get(); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it('re-fetches once the TTL has elapsed', async () => { + mockGitHubRelease('v0.2.2'); + let now = 0; + const get = createVersionChecker({ + currentVersion: '0.2.1', + ttlMs: 1000, + now: () => now, + }); + + await get(); + now = 2000; + await get(); + + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it('falls back to the last cached value when a later fetch fails', async () => { + mockGitHubRelease('v0.2.2'); + let now = 0; + const get = createVersionChecker({ + currentVersion: '0.2.1', + ttlMs: 1000, + now: () => now, + }); + + expect(await get()).toMatchObject({ updateAvailable: true }); + + now = 2000; + globalThis.fetch = mock(() => + Promise.reject(new Error('network down')), + ) as any; + + expect(await get()).toMatchObject({ updateAvailable: true }); + }); + + it('returns a safe no-update state when the fetch fails and nothing is cached', async () => { + globalThis.fetch = mock(() => + Promise.reject(new Error('network down')), + ) as any; + const get = createVersionChecker({ currentVersion: '0.2.1' }); + + expect(await get()).toEqual({ + current: '0.2.1', + latest: null, + latestUrl: null, + updateAvailable: false, + isBeta: false, + isDev: false, + }); + }); +}); diff --git a/apps/core/src/common/version_check.ts b/apps/core/src/common/version_check.ts index a63b40a3..ff498788 100644 --- a/apps/core/src/common/version_check.ts +++ b/apps/core/src/common/version_check.ts @@ -1,3 +1,42 @@ +const GITHUB_LATEST_RELEASE = + 'https://api.github.com/repos/fxManagerProject/fxManager/releases/latest'; +const DEFAULT_TTL_MS = 30 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 5_000; + +export interface VersionStatus { + current: string; + latest: string | null; + latestUrl: string | null; + updateAvailable: boolean; + isBeta: boolean; + isDev: boolean; +} + +interface GitHubRelease { + tag_name: string; + html_url: string; +} + +export function getCurrentVersion(): string { + return process.env.VERSION ?? 'dev-build'; +} + +async function fetchLatestRelease(): Promise { + const response = await fetch(GITHUB_LATEST_RELEASE, { + headers: { 'User-Agent': 'fxManager-Updater' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) + throw new Error(`${response.status} - ${response.statusText}`); + + const data = (await response.json()) as GitHubRelease & { + [key: string]: unknown; + }; + + return { tag_name: data.tag_name, html_url: data.html_url }; +} + function compareVersions(current: string, latest: string): number { const parse = (v: string) => { const [main, tag] = v.replace('v', '').split('-') as [ @@ -27,6 +66,62 @@ function compareVersions(current: string, latest: string): number { return 0; } +/** + * TTL-cached version status provider for the API/UI. Mirrors the + * recommended-artifact fetcher: caches success, falls back to the last known + * value (or a safe "no update" state) on failure so it never blocks callers. + */ +export function createVersionChecker(opts?: { + currentVersion?: string; + ttlMs?: number; + now?: () => number; +}) { + const currentVersion = opts?.currentVersion ?? getCurrentVersion(); + const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS; + const now = opts?.now ?? Date.now; + const isDev = currentVersion === 'dev-build'; + const isBeta = !isDev && currentVersion.includes('-b'); + let cache: { value: VersionStatus; expiresAt: number } | null = null; + + const noUpdate = (): VersionStatus => ({ + current: currentVersion, + latest: null, + latestUrl: null, + updateAvailable: false, + isBeta, + isDev, + }); + + return async function getVersionStatus(): Promise { + if (isDev) return noUpdate(); + if (cache && cache.expiresAt > now()) return cache.value; + + try { + const { tag_name, html_url } = await fetchLatestRelease(); + + const value: VersionStatus = { + current: currentVersion, + latest: tag_name, + latestUrl: html_url, + updateAvailable: !isBeta && compareVersions(currentVersion, tag_name) === 1, + isBeta, + isDev, + }; + + cache = { value, expiresAt: now() + ttlMs }; + return value; + } catch (err) { + console.error( + `[version] Could not check for updates:`, + (err as Error).message, + ); + return cache?.value ?? noUpdate(); + } + }; +} + +export const getVersionStatus = createVersionChecker(); + export async function checkVersion(currentVersion: string) { if (currentVersion === 'dev-build') { console.info(`[version] Running in development mode.`); @@ -34,23 +129,8 @@ export async function checkVersion(currentVersion: string) { } try { - const response = await fetch( - `https://api.github.com/repos/fxManagerProject/fxManager/releases/latest`, - { - headers: { 'User-Agent': 'fxManager-Updater' }, - }, - ); - - if (!response.ok) - throw new Error(`${response.status} - ${response.statusText}`); - - const data = (await response.json()) as { - tag_name: string; - html_url: string; - [key: string]: unknown; - }; - const latestVersion = data.tag_name; - const releaseUrl = data.html_url; + const { tag_name: latestVersion, html_url: releaseUrl } = + await fetchLatestRelease(); const comparison = compareVersions(currentVersion, latestVersion); const isCurrentBeta = currentVersion.includes('-b'); diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 44fb03cf..ac59503e 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -9,7 +9,7 @@ import fastifyWebsocket from '@fastify/websocket'; import fastifyCookie from '@fastify/cookie'; import { applyMigrations } from '@fxmanager/database'; import { getIp, isFxManagerSetup, isProduction } from './common/utils'; -import { checkVersion } from './common/version_check'; +import { checkVersion, getCurrentVersion } from './common/version_check'; import apiRoutes from './routes/api'; import internalRoutes from './routes/internal'; import { processManager } from './modules/process/manager'; @@ -30,8 +30,7 @@ if (process.argv.includes(MIGRATE_WORKER_FLAG)) { } applyMigrations(); -// hardcoded until release versioning is wired up -checkVersion('dev-build'); +checkVersion(getCurrentVersion()); const cm = ConfigManager.getInstance(); const { cookieSecret, webServerPort } = cm.getSystemValues(); diff --git a/apps/core/src/routes/api/server.ts b/apps/core/src/routes/api/server.ts index c32a743c..6c1ee88f 100644 --- a/apps/core/src/routes/api/server.ts +++ b/apps/core/src/routes/api/server.ts @@ -8,6 +8,7 @@ import type { AuthedRequest, RouteModule } from '../../types'; import { sessionAuth } from '../../middleware/session'; import { resourceManager } from '../../modules/resource/manager'; import { getRecommendedArtifact } from '../../common/recommended-artifact'; +import { getVersionStatus } from '../../common/version_check'; import { restartScheduler } from '../../modules/schedule/manager'; const ServerEndpoints: RouteModule['handler'] = async (fastify, options) => { @@ -96,6 +97,11 @@ const ServerEndpoints: RouteModule['handler'] = async (fastify, options) => { return reply.code(200).send({ recommended }); }); + fastify.get('/version', async (_request, reply) => { + const version = await getVersionStatus(); + return reply.code(200).send(version); + }); + fastify.post('/resource/action/start', async (request, reply) => { const { admin } = request as AuthedRequest; diff --git a/apps/webpanel/src/components/sidebar/server-status.tsx b/apps/webpanel/src/components/sidebar/server-status.tsx index e9d08fac..3d1a91d3 100644 --- a/apps/webpanel/src/components/sidebar/server-status.tsx +++ b/apps/webpanel/src/components/sidebar/server-status.tsx @@ -10,6 +10,7 @@ import { STATUS_VARIANT } from '@/static/server-state'; import { formatDuration, formatRemaining, isServerRunning } from '@/lib/utils'; import { Button } from '@fxmanager/ui/components/button'; import { + ArrowUpCircle, ExternalLink, MonitorCog, Play, @@ -25,6 +26,7 @@ import { import { HandleServerAction } from '@/lib/query'; import { usePlayerlistSocket, useServerStateSocket } from '@/hooks/ws-channels'; import { useRecommendedArtifact } from '@/hooks/use-recommended-artifact'; +import { useVersionStatus } from '@/hooks/use-version'; import { useSchedule } from '@/hooks/use-schedule'; import { useEffect, useState } from 'react'; @@ -73,6 +75,7 @@ export function ServerStatusCard() { const { state: serverState } = useServerStateSocket(); const { count } = usePlayerlistSocket(); const recommendedArtifact = useRecommendedArtifact(); + const version = useVersionStatus(); const { state: sideBarState, setOpen } = useSidebar(); const { status: schedule, restartIn, skip } = useSchedule(); const isCollapsed = sideBarState === 'collapsed'; @@ -224,6 +227,42 @@ export function ServerStatusCard() { )} )} + {version && !version.isDev && ( +
+
+

Panel

+ {version.updateAvailable && version.latestUrl ? ( + + v{version.current} + + ) : ( +

+ v{version.current} +

+ )} +
+ {version.updateAvailable && version.latestUrl && ( + + + + {version.latest} available + + + + )} +
+ )} diff --git a/apps/webpanel/src/hooks/use-version.ts b/apps/webpanel/src/hooks/use-version.ts new file mode 100644 index 00000000..30a2158d --- /dev/null +++ b/apps/webpanel/src/hooks/use-version.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; +import { QueryService } from '@/lib/query'; + +export interface VersionStatus { + current: string; + latest: string | null; + latestUrl: string | null; + updateAvailable: boolean; + isBeta: boolean; + isDev: boolean; +} + +export function useVersionStatus(): VersionStatus | null { + const [version, setVersion] = useState(null); + + useEffect(() => { + let active = true; + + QueryService({ + endpoint: '/server/version', + method: 'GET', + }) + .then((res) => { + if (active) setVersion(res); + }) + .catch(() => {}); + + return () => { + active = false; + }; + }, []); + + return version; +}