import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useQuery, useMutation } from '@tanstack/react-query'; import { api } from '@/lib/api'; import { formatBytes } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; interface Game { environmentVars?: GameEnvironmentVar[]; id: string; name: string; slug: string; dockerImage: string; } interface GameEnvironmentVar { key: string; label?: string; default?: string; description?: string; required?: boolean; inputType?: 'text' | 'boolean'; composeInto?: string; enabledLabel?: string; disabledLabel?: string; } interface Node { id: string; name: string; fqdn: string; memoryTotal: number; diskTotal: number; } interface Allocation { id: string; ip: string; port: number; serverId: string | null; } interface PaginatedResponse { data: T[]; meta: { total: number }; } interface AdditionalPortRequirement { key: string; label: string; defaultPort: number; protocols: Array<'tcp' | 'udp'>; description: string; } function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] { const slug = gameSlug.trim().toLowerCase(); if (slug === 'satisfactory') { return [ { key: 'satisfactory-messaging', label: 'Messaging Port', defaultPort: 8888, protocols: ['tcp'], description: 'Required by the Satisfactory server messaging API.', }, ]; } if (slug === 'ark-se') { return [ { key: 'ark-raw-udp', label: 'Raw UDP Socket Port', defaultPort: 7778, protocols: ['udp'], description: 'ARK opens a second UDP socket, normally the game port + 1.', }, { key: 'ark-query', label: 'Steam Query Port', defaultPort: 27015, protocols: ['udp'], description: 'Used by the Steam server browser to list the server.', }, { key: 'ark-rcon', label: 'RCON Port', defaultPort: 27020, protocols: ['tcp'], description: 'Console commands and the player list are sent over RCON.', }, ]; } return []; } export function CreateServerPage() { const { orgId } = useParams(); const navigate = useNavigate(); const [step, setStep] = useState(1); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [gameId, setGameId] = useState(''); const [nodeId, setNodeId] = useState(''); const [allocationId, setAllocationId] = useState(''); const [additionalAllocationIds, setAdditionalAllocationIds] = useState>( {}, ); const [memoryLimit, setMemoryLimit] = useState(1024); const [diskLimit, setDiskLimit] = useState(5120); const [cpuLimit, setCpuLimit] = useState(100); const [environment, setEnvironment] = useState>({}); const { data: gamesData } = useQuery({ queryKey: ['games'], queryFn: () => api.get>('/games'), }); const { data: nodesData } = useQuery({ queryKey: ['nodes', orgId], queryFn: () => api.get>(`/organizations/${orgId}/nodes`), }); const { data: allocationsData } = useQuery({ queryKey: ['allocations', orgId, nodeId], queryFn: () => api.get>(`/organizations/${orgId}/nodes/${nodeId}/allocations`), enabled: !!nodeId, }); const freeAllocations = (allocationsData?.data ?? []).filter((a) => !a.serverId); const createMutation = useMutation({ mutationFn: (body: Record) => api.post(`/organizations/${orgId}/servers`, body), onSuccess: () => { navigate(`/org/${orgId}/dashboard`); }, }); const games = gamesData?.data ?? []; const nodes = nodesData?.data ?? []; const activeGame = games.find((game) => game.id === gameId); const additionalPortRequirements = activeGame ? additionalPortRequirementsForGame(activeGame.slug) : []; const visibleEnvironmentVars = (activeGame?.environmentVars ?? []).filter((variable) => { const key = variable.key?.trim(); return Boolean(key) && !variable.composeInto?.trim(); }); const missingRequiredEnvironment = visibleEnvironmentVars.some((variable) => { const key = variable.key.trim(); const currentValue = environment[key] ?? String(variable.default ?? ''); return Boolean(variable.required) && !currentValue.trim(); }); const missingRequiredAdditionalPorts = additionalPortRequirements.some( (requirement) => !additionalAllocationIds[requirement.key], ); useEffect(() => { if (!activeGame) { setEnvironment({}); return; } setEnvironment((current) => { const next: Record = {}; for (const variable of activeGame.environmentVars ?? []) { const key = variable.key?.trim(); if (!key || variable.composeInto?.trim()) continue; next[key] = current[key] ?? String(variable.default ?? ''); } return next; }); setAdditionalAllocationIds({}); if (activeGame.slug.trim().toLowerCase() === 'satisfactory') { setMemoryLimit((current) => Math.max(current, 8192)); setDiskLimit((current) => Math.max(current, 12288)); } }, [activeGame?.id]); useEffect(() => { setAdditionalAllocationIds((current) => { const allowedKeys = new Set(additionalPortRequirements.map((requirement) => requirement.key)); const next = Object.fromEntries( Object.entries(current).filter(([key]) => allowedKeys.has(key)), ); return next; }); }, [activeGame?.slug]); const handleCreate = () => { const environmentPayload = Object.fromEntries( Object.entries(environment).filter(([, value]) => value.trim() !== ''), ); createMutation.mutate({ name, description: description || undefined, gameId, nodeId, allocationId, memoryLimit: memoryLimit * 1024 * 1024, diskLimit: diskLimit * 1024 * 1024, cpuLimit, additionalAllocationIds: additionalPortRequirements.length > 0 ? additionalPortRequirements .map((requirement) => additionalAllocationIds[requirement.key]) .filter(Boolean) : undefined, environment: Object.keys(environmentPayload).length > 0 ? environmentPayload : undefined, }); }; const allocationOptionsForRequirement = (requirementKey: string) => { const selectedByOtherRequirements = new Set( Object.entries(additionalAllocationIds) .filter(([key]) => key !== requirementKey) .map(([, id]) => id) .filter(Boolean), ); return freeAllocations.filter( (allocation) => allocation.id !== allocationId && !selectedByOtherRequirements.has(allocation.id), ); }; return (

Create Server

Set up a new game server

{[1, 2, 3].map((s) => (
))}
{step === 1 && ( Basic Information Choose a name and game for your server
setName(e.target.value)} placeholder="My Awesome Server" />
setDescription(e.target.value)} placeholder="A short description" />
{visibleEnvironmentVars.map((variable) => { const key = variable.key.trim(); const label = variable.label?.trim() || key; const value = environment[key] ?? String(variable.default ?? ''); const isSecret = key.toLowerCase().includes('password') || key.toLowerCase().includes('license'); return (
{variable.inputType === 'boolean' ? (
) : ( setEnvironment((current) => ({ ...current, [key]: e.target.value, })) } placeholder={variable.description || label} /> )} {variable.description && (

{variable.description}

)}
); })}
)} {step === 2 && ( Node & Allocation Choose where to host your server
{nodeId && (
{freeAllocations.length === 0 && nodeId && (

No free allocations on this node

)}
)} {nodeId && additionalPortRequirements.map((requirement) => { const options = allocationOptionsForRequirement(requirement.key); const protocols = requirement.protocols.join('/').toUpperCase(); return (

{requirement.description}

{options.length === 0 && (

No free allocation available for this port

)}
); })}
)} {step === 3 && ( Resources Set resource limits for this server
setMemoryLimit(Number(e.target.value))} min={128} />

{formatBytes(memoryLimit * 1024 * 1024)}

setDiskLimit(Number(e.target.value))} min={256} />

{formatBytes(diskLimit * 1024 * 1024)}

setCpuLimit(Number(e.target.value))} min={10} max={10000} />

100% = 1 core

{createMutation.isError && (

Failed to create server. Please try again.

)}
)}
); }