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 && ( -
+

Artifact

b{serverState.version}

- {recommendedArtifact && ( -
-

Recommended

- - b{recommendedArtifact} - - -
+ {/* a state implies a recommended build — the check narrows it to a string */} + {recommendedArtifact && artifactState && ( + + + + {artifactState.label(recommendedArtifact)} + + + )}
)} 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} + + ), + }, +};