From 50467f896c6ddf6dc5bf134deed2b826ba0b1fe5 Mon Sep 17 00:00:00 2001
From: Joost H <26064691+jhag01@users.noreply.github.com>
Date: Wed, 16 Sep 2026 20:29:17 +0200
Subject: [PATCH] feat(webpanel): compare running artifact against recommended
build
The server status card showed the running artifact and the recommended
one as two plain rows, leaving the reader to diff the build numbers by
eye and repeating the same number when they matched.
Both builds now go through a compareArtifactBuilds helper and the card
renders a single status strip: a primary accent linking out when the
server is behind, and a muted note when it matches or runs ahead. The
accent stays reserved for the one state that asks the admin to act, and
the card keeps a single green in the server status badge. The separate
recommended row is gone, so a build number only ever appears when it
adds something.
Comparison is numeric rather than lexicographic, and anything that is
not a build number yields "unknown" so a failed lookup or an unreadable
convar leaves the strip off instead of guessing.
---
.../src/components/sidebar/server-status.tsx | 44 ++++++++++------
.../webpanel/src/lib/artifact-version.test.ts | 36 +++++++++++++
apps/webpanel/src/lib/artifact-version.ts | 30 +++++++++++
apps/webpanel/src/static/artifact-state.tsx | 51 +++++++++++++++++++
4 files changed, 146 insertions(+), 15 deletions(-)
create mode 100644 apps/webpanel/src/lib/artifact-version.test.ts
create mode 100644 apps/webpanel/src/lib/artifact-version.ts
create mode 100644 apps/webpanel/src/static/artifact-state.tsx
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}
+ >
+ ),
+ },
+};