her sey
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -7,15 +7,34 @@ 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';
|
||||
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;
|
||||
@@ -36,6 +55,28 @@ interface PaginatedResponse<T> {
|
||||
meta: { total: number };
|
||||
}
|
||||
|
||||
interface AdditionalPortRequirement {
|
||||
key: string;
|
||||
label: string;
|
||||
defaultPort: number;
|
||||
protocols: Array<'tcp' | 'udp'>;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] {
|
||||
if (gameSlug.trim().toLowerCase() !== 'satisfactory') return [];
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'satisfactory-messaging',
|
||||
label: 'Messaging Port',
|
||||
defaultPort: 8888,
|
||||
protocols: ['tcp'],
|
||||
description: 'Required by the Satisfactory server messaging API.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function CreateServerPage() {
|
||||
const { orgId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -46,9 +87,13 @@ export function CreateServerPage() {
|
||||
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'],
|
||||
@@ -63,9 +108,7 @@ export function CreateServerPage() {
|
||||
const { data: allocationsData } = useQuery({
|
||||
queryKey: ['allocations', orgId, nodeId],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<Allocation>>(
|
||||
`/organizations/${orgId}/nodes/${nodeId}/allocations`,
|
||||
),
|
||||
api.get<PaginatedResponse<Allocation>>(`/organizations/${orgId}/nodes/${nodeId}/allocations`),
|
||||
enabled: !!nodeId,
|
||||
});
|
||||
|
||||
@@ -81,8 +124,60 @@ export function CreateServerPage() {
|
||||
|
||||
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,
|
||||
@@ -92,9 +187,30 @@ export function CreateServerPage() {
|
||||
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>
|
||||
@@ -149,7 +265,69 @@ export function CreateServerPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={() => setStep(2)} disabled={!name || !gameId}>
|
||||
{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>
|
||||
@@ -170,6 +348,7 @@ export function CreateServerPage() {
|
||||
onValueChange={(v) => {
|
||||
setNodeId(v);
|
||||
setAllocationId('');
|
||||
setAdditionalAllocationIds({});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -187,7 +366,17 @@ export function CreateServerPage() {
|
||||
{nodeId && (
|
||||
<div className="space-y-2">
|
||||
<Label>Port Allocation</Label>
|
||||
<Select value={allocationId} onValueChange={setAllocationId}>
|
||||
<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>
|
||||
@@ -204,11 +393,53 @@ export function CreateServerPage() {
|
||||
)}
|
||||
</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}>
|
||||
<Button
|
||||
onClick={() => setStep(3)}
|
||||
disabled={!nodeId || !allocationId || missingRequiredAdditionalPorts}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user