539 lines
18 KiB
TypeScript
539 lines
18 KiB
TypeScript
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<T> {
|
|
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<Record<string, string>>(
|
|
{},
|
|
);
|
|
const [memoryLimit, setMemoryLimit] = useState(1024);
|
|
const [diskLimit, setDiskLimit] = useState(5120);
|
|
const [cpuLimit, setCpuLimit] = useState(100);
|
|
const [environment, setEnvironment] = useState<Record<string, string>>({});
|
|
|
|
const { data: gamesData } = useQuery({
|
|
queryKey: ['games'],
|
|
queryFn: () => api.get<PaginatedResponse<Game>>('/games'),
|
|
});
|
|
|
|
const { data: nodesData } = useQuery({
|
|
queryKey: ['nodes', orgId],
|
|
queryFn: () => api.get<PaginatedResponse<Node>>(`/organizations/${orgId}/nodes`),
|
|
});
|
|
|
|
const { data: allocationsData } = useQuery({
|
|
queryKey: ['allocations', orgId, nodeId],
|
|
queryFn: () =>
|
|
api.get<PaginatedResponse<Allocation>>(`/organizations/${orgId}/nodes/${nodeId}/allocations`),
|
|
enabled: !!nodeId,
|
|
});
|
|
|
|
const freeAllocations = (allocationsData?.data ?? []).filter((a) => !a.serverId);
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (body: Record<string, unknown>) =>
|
|
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<string, string> = {};
|
|
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 (
|
|
<div className="mx-auto max-w-2xl space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Create Server</h1>
|
|
<p className="text-muted-foreground">Set up a new game server</p>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{[1, 2, 3].map((s) => (
|
|
<div
|
|
key={s}
|
|
className={`h-1.5 flex-1 rounded-full ${s <= step ? 'bg-primary' : 'bg-muted'}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{step === 1 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Basic Information</CardTitle>
|
|
<CardDescription>Choose a name and game for your server</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Server Name</Label>
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="My Awesome Server"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Description (optional)</Label>
|
|
<Input
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="A short description"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Game</Label>
|
|
<Select value={gameId} onValueChange={setGameId}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a game" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{games.map((game) => (
|
|
<SelectItem key={game.id} value={game.id}>
|
|
{game.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{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 (
|
|
<div key={key} className="space-y-2">
|
|
<Label>
|
|
{label}
|
|
{variable.required ? ' *' : ''}
|
|
</Label>
|
|
{variable.inputType === 'boolean' ? (
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
variant={value === 'true' ? 'default' : 'outline'}
|
|
onClick={() =>
|
|
setEnvironment((current) => ({
|
|
...current,
|
|
[key]: 'true',
|
|
}))
|
|
}
|
|
>
|
|
{variable.enabledLabel ?? 'Enabled'}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={value === 'false' ? 'secondary' : 'outline'}
|
|
onClick={() =>
|
|
setEnvironment((current) => ({
|
|
...current,
|
|
[key]: 'false',
|
|
}))
|
|
}
|
|
>
|
|
{variable.disabledLabel ?? 'Disabled'}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Input
|
|
type={isSecret ? 'password' : 'text'}
|
|
value={value}
|
|
onChange={(e) =>
|
|
setEnvironment((current) => ({
|
|
...current,
|
|
[key]: e.target.value,
|
|
}))
|
|
}
|
|
placeholder={variable.description || label}
|
|
/>
|
|
)}
|
|
{variable.description && (
|
|
<p className="text-xs text-muted-foreground">{variable.description}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
<Button
|
|
onClick={() => setStep(2)}
|
|
disabled={!name || !gameId || missingRequiredEnvironment}
|
|
>
|
|
Next
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Node & Allocation</CardTitle>
|
|
<CardDescription>Choose where to host your server</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Node</Label>
|
|
<Select
|
|
value={nodeId}
|
|
onValueChange={(v) => {
|
|
setNodeId(v);
|
|
setAllocationId('');
|
|
setAdditionalAllocationIds({});
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a node" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{nodes.map((node) => (
|
|
<SelectItem key={node.id} value={node.id}>
|
|
{node.name} ({node.fqdn})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{nodeId && (
|
|
<div className="space-y-2">
|
|
<Label>Port Allocation</Label>
|
|
<Select
|
|
value={allocationId}
|
|
onValueChange={(value) => {
|
|
setAllocationId(value);
|
|
setAdditionalAllocationIds((current) =>
|
|
Object.fromEntries(
|
|
Object.entries(current).filter(([, selectedId]) => selectedId !== value),
|
|
),
|
|
);
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a port" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{freeAllocations.map((a) => (
|
|
<SelectItem key={a.id} value={a.id}>
|
|
{a.ip}:{a.port}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{freeAllocations.length === 0 && nodeId && (
|
|
<p className="text-sm text-destructive">No free allocations on this node</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{nodeId &&
|
|
additionalPortRequirements.map((requirement) => {
|
|
const options = allocationOptionsForRequirement(requirement.key);
|
|
const protocols = requirement.protocols.join('/').toUpperCase();
|
|
|
|
return (
|
|
<div key={requirement.key} className="space-y-2">
|
|
<Label>
|
|
{requirement.label} ({protocols})
|
|
</Label>
|
|
<Select
|
|
value={additionalAllocationIds[requirement.key] ?? ''}
|
|
onValueChange={(value) =>
|
|
setAdditionalAllocationIds((current) => ({
|
|
...current,
|
|
[requirement.key]: value,
|
|
}))
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={`Select port ${requirement.defaultPort}`} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((allocation) => (
|
|
<SelectItem key={allocation.id} value={allocation.id}>
|
|
{allocation.ip}:{allocation.port}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">{requirement.description}</p>
|
|
{options.length === 0 && (
|
|
<p className="text-sm text-destructive">
|
|
No free allocation available for this port
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => setStep(1)}>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
onClick={() => setStep(3)}
|
|
disabled={!nodeId || !allocationId || missingRequiredAdditionalPorts}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{step === 3 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Resources</CardTitle>
|
|
<CardDescription>Set resource limits for this server</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Memory (MB)</Label>
|
|
<Input
|
|
type="number"
|
|
value={memoryLimit}
|
|
onChange={(e) => setMemoryLimit(Number(e.target.value))}
|
|
min={128}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatBytes(memoryLimit * 1024 * 1024)}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Disk (MB)</Label>
|
|
<Input
|
|
type="number"
|
|
value={diskLimit}
|
|
onChange={(e) => setDiskLimit(Number(e.target.value))}
|
|
min={256}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatBytes(diskLimit * 1024 * 1024)}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>CPU Limit (%)</Label>
|
|
<Input
|
|
type="number"
|
|
value={cpuLimit}
|
|
onChange={(e) => setCpuLimit(Number(e.target.value))}
|
|
min={10}
|
|
max={10000}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">100% = 1 core</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => setStep(2)}>
|
|
Back
|
|
</Button>
|
|
<Button onClick={handleCreate} disabled={createMutation.isPending}>
|
|
{createMutation.isPending ? 'Creating...' : 'Create Server'}
|
|
</Button>
|
|
</div>
|
|
{createMutation.isError && (
|
|
<p className="text-sm text-destructive">Failed to create server. Please try again.</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|