Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 141 additions & 1 deletion apps/core/src/common/version_check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -171,3 +171,143 @@ describe('Version Checking', () => {
});
});
});

describe('createVersionChecker', () => {
let originalFetch: typeof globalThis.fetch;
let errorSpy: ReturnType<typeof spyOn>;

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,
});
});
});
114 changes: 97 additions & 17 deletions apps/core/src/common/version_check.ts
Original file line number Diff line number Diff line change
@@ -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<GitHubRelease> {
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 [
Expand Down Expand Up @@ -27,30 +66,71 @@ 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<VersionStatus> {
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.`);
return;
}

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');
Expand Down
5 changes: 2 additions & 3 deletions apps/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions apps/core/src/routes/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;

Expand Down
39 changes: 39 additions & 0 deletions apps/webpanel/src/components/sidebar/server-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -224,6 +227,42 @@ export function ServerStatusCard() {
)}
</div>
)}
{version && !version.isDev && (
<div className="space-y-2 border-t pt-3">
<div className="flex flex-row justify-between">
<p>Panel</p>
{version.updateAvailable && version.latestUrl ? (
<a
href={version.latestUrl}
target="_blank"
rel="noreferrer"
className="font-mono text-primary hover:underline"
>
v{version.current}
</a>
) : (
<p className="font-mono text-muted-foreground">
v{version.current}
</p>
)}
</div>
{version.updateAvailable && version.latestUrl && (
<a
href={version.latestUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-md border border-primary/30 bg-primary/10 px-2 py-1.5 text-xs text-primary transition-colors hover:bg-primary/20"
title="View the latest patch notes on GitHub"
>
<ArrowUpCircle className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">
<span className="font-mono">{version.latest}</span> available
</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
)}
</div>
)}
</CardContent>
</Card>
</SidebarGroup>
Expand Down
Loading
Loading