diff --git a/apps/webpanel/src/components/sidebar/server-status.tsx b/apps/webpanel/src/components/sidebar/server-status.tsx
index 4356f3ad..eb0602c8 100644
--- a/apps/webpanel/src/components/sidebar/server-status.tsx
+++ b/apps/webpanel/src/components/sidebar/server-status.tsx
@@ -8,6 +8,9 @@ import { Card, CardContent } from '@fxmanager/ui/components/card';
import { Badge } from '@fxmanager/ui/components/badge';
import { STATUS_VARIANT } from '@/static/server-state';
import { formatDuration, formatRemaining, isServerRunning } from '@/lib/utils';
+import { compareArtifactBuilds } from '@/lib/artifact-version';
+import { ARTIFACT_STATE } from '@/static/artifact-state';
+import { cn } from '@fxmanager/ui/lib/utils';
import { Button } from '@fxmanager/ui/components/button';
import {
ArrowUpCircle,
@@ -31,6 +34,7 @@ import { useSchedule } from '@/hooks/use-schedule';
import { useEffect, useState } from 'react';
const TEMP_PRESETS = [5, 15, 30] as const;
+const ARTIFACTS_URL = 'https://artifacts.jgscripts.com/';
interface ActionButtonProps {
Icon: LucideIcon;
@@ -79,6 +83,12 @@ export function ServerStatusCard() {
const { state: sideBarState, setOpen } = useSidebar();
const { status: schedule, restartIn, skip } = useSchedule();
const isCollapsed = sideBarState === 'collapsed';
+ const artifactStatus = compareArtifactBuilds(
+ serverState.version,
+ recommendedArtifact,
+ );
+ const artifactState =
+ artifactStatus === 'unknown' ? null : ARTIFACT_STATE[artifactStatus];
const canStart =
serverState.status === 'stopped' || serverState.status === 'crashed';
const canStop =
@@ -205,25 +215,29 @@ export function ServerStatusCard() {
{serverState.version && (
-
+
)}
diff --git a/apps/webpanel/src/lib/artifact-version.test.ts b/apps/webpanel/src/lib/artifact-version.test.ts
new file mode 100644
index 00000000..7e2a87ec
--- /dev/null
+++ b/apps/webpanel/src/lib/artifact-version.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'bun:test';
+import { compareArtifactBuilds } from './artifact-version';
+
+describe('compareArtifactBuilds', () => {
+ it('flags a running build older than the recommended one', () => {
+ expect(compareArtifactBuilds('13068', '31725')).toBe('outdated');
+ });
+
+ it('reports a match when both builds are identical', () => {
+ expect(compareArtifactBuilds('31725', '31725')).toBe('current');
+ });
+
+ it('reports ahead when running a build newer than recommended', () => {
+ expect(compareArtifactBuilds('31726', '31725')).toBe('ahead');
+ });
+
+ it('compares numerically rather than lexicographically', () => {
+ expect(compareArtifactBuilds('9999', '13068')).toBe('outdated');
+ expect(compareArtifactBuilds('13068', '9999')).toBe('ahead');
+ });
+
+ it('returns unknown while either side is still missing', () => {
+ expect(compareArtifactBuilds(null, '31725')).toBe('unknown');
+ expect(compareArtifactBuilds('31725', null)).toBe('unknown');
+ expect(compareArtifactBuilds(null, null)).toBe('unknown');
+ expect(compareArtifactBuilds(undefined, undefined)).toBe('unknown');
+ });
+
+ it('returns unknown for values that are not build numbers', () => {
+ expect(compareArtifactBuilds('unknown', '31725')).toBe('unknown');
+ expect(compareArtifactBuilds('31725', 'v1.0.0.31725')).toBe('unknown');
+ expect(compareArtifactBuilds('b31725', '31725')).toBe('unknown');
+ expect(compareArtifactBuilds('', '31725')).toBe('unknown');
+ expect(compareArtifactBuilds('123', '31725')).toBe('unknown');
+ });
+});
diff --git a/apps/webpanel/src/lib/artifact-version.ts b/apps/webpanel/src/lib/artifact-version.ts
new file mode 100644
index 00000000..8b1954fb
--- /dev/null
+++ b/apps/webpanel/src/lib/artifact-version.ts
@@ -0,0 +1,30 @@
+const BUILD_NUMBER_REGEX = /^\d{4,8}$/;
+
+export type ArtifactStatus = 'unknown' | 'outdated' | 'current' | 'ahead';
+
+/**
+ * Compare the running FXServer build against the recommended one.
+ *
+ * Both sides are bare build numbers (e.g. '13068') — the running one comes off
+ * the server_state socket, the recommended one from /server/artifact/recommended.
+ * Anything missing or not shaped like a build yields 'unknown' so the UI stays
+ * quiet instead of claiming an artifact is outdated on bad data.
+ */
+export function compareArtifactBuilds(
+ current: string | null | undefined,
+ recommended: string | null | undefined,
+): ArtifactStatus {
+ if (!current || !recommended) return 'unknown';
+ if (
+ !BUILD_NUMBER_REGEX.test(current) ||
+ !BUILD_NUMBER_REGEX.test(recommended)
+ )
+ return 'unknown';
+
+ const currentBuild = Number(current);
+ const recommendedBuild = Number(recommended);
+
+ if (currentBuild === recommendedBuild) return 'current';
+
+ return currentBuild < recommendedBuild ? 'outdated' : 'ahead';
+}
diff --git a/apps/webpanel/src/static/artifact-state.tsx b/apps/webpanel/src/static/artifact-state.tsx
new file mode 100644
index 00000000..df278d26
--- /dev/null
+++ b/apps/webpanel/src/static/artifact-state.tsx
@@ -0,0 +1,51 @@
+import {
+ ArrowUpCircle,
+ CheckCircle2,
+ Info,
+ type LucideIcon,
+} from 'lucide-react';
+import type { ReactNode } from 'react';
+import type { ArtifactStatus } from '@/lib/artifact-version';
+
+interface ArtifactStatePresentation {
+ Icon: LucideIcon;
+ className: string;
+ label: (recommended: string) => ReactNode;
+}
+
+/**
+ * Presentation for the artifact status strip. Every state renders the same
+ * strip so the card reads consistently — only the tone, icon and wording
+ * differ, which keeps the primary accent on the one state that asks the admin
+ * to do something. 'unknown' has no entry: nothing is claimed when either
+ * build is missing.
+ */
+export const ARTIFACT_STATE: Record<
+ Exclude
,
+ ArtifactStatePresentation
+> = {
+ outdated: {
+ Icon: ArrowUpCircle,
+ className:
+ 'border-primary/30 bg-primary/10 text-primary hover:bg-primary/20',
+ label: (recommended) => (
+ <>
+ b{recommended} recommended
+ >
+ ),
+ },
+ current: {
+ Icon: CheckCircle2,
+ className: 'border-border bg-muted/50 text-muted-foreground hover:bg-muted',
+ label: () => 'Up to date',
+ },
+ ahead: {
+ Icon: Info,
+ className: 'border-border bg-muted/50 text-muted-foreground hover:bg-muted',
+ label: (recommended) => (
+ <>
+ Ahead of b{recommended}
+ >
+ ),
+ },
+};