ui(admin): fix server startup variables

This commit is contained in:
Matthew Penner 2021-10-24 14:14:04 -06:00
parent cf1cc97340
commit 5e99bb8dd6
No known key found for this signature in database
GPG key ID: BAB67850901908A8
11 changed files with 292 additions and 237 deletions

View file

@ -18,15 +18,9 @@ class StoreServerRequest extends ApplicationApiRequest
'external_id' => $rules['external_id'],
'name' => $rules['name'],
'description' => array_merge(['nullable'], $rules['description']),
'user' => $rules['owner_id'],
'egg' => $rules['egg_id'],
'docker_image' => $rules['image'],
'startup' => $rules['startup'],
'environment' => 'present|array',
'skip_scripts' => 'sometimes|boolean',
'oom_disabled' => 'sometimes|boolean',
'owner_id' => $rules['owner_id'],
'node_id' => $rules['node_id'],
// Resource limitations
'limits' => 'required|array',
'limits.memory' => $rules['memory'],
'limits.swap' => $rules['swap'],
@ -34,26 +28,21 @@ class StoreServerRequest extends ApplicationApiRequest
'limits.io' => $rules['io'],
'limits.threads' => $rules['threads'],
'limits.cpu' => $rules['cpu'],
'limits.oom_killer' => 'required|boolean',
// Application Resource Limits
'feature_limits' => 'required|array',
'feature_limits.databases' => $rules['database_limit'],
'feature_limits.allocations' => $rules['allocation_limit'],
'feature_limits.backups' => $rules['backup_limit'],
'feature_limits.databases' => $rules['database_limit'],
// Placeholders for rules added in withValidator() function.
'allocation.default' => '',
'allocation.additional.*' => '',
'allocation.default' => 'required|bail|integer|exists:allocations,id',
'allocation.additional.*' => 'integer|exists:allocations,id',
// Automatic deployment rules
'deploy' => 'sometimes|required|array',
'deploy.locations' => 'array',
'deploy.locations.*' => 'integer|min:1',
'deploy.dedicated_ip' => 'required_with:deploy,boolean',
'deploy.port_range' => 'array',
'deploy.port_range.*' => 'string',
'start_on_completion' => 'sometimes|boolean',
'startup' => $rules['startup'],
'environment' => 'present|array',
'egg_id' => $rules['egg_id'],
'image' => $rules['image'],
'skip_scripts' => 'present|boolean',
];
}
@ -65,69 +54,30 @@ class StoreServerRequest extends ApplicationApiRequest
'external_id' => array_get($data, 'external_id'),
'name' => array_get($data, 'name'),
'description' => array_get($data, 'description'),
'owner_id' => array_get($data, 'user'),
'egg_id' => array_get($data, 'egg'),
'image' => array_get($data, 'docker_image'),
'startup' => array_get($data, 'startup'),
'environment' => array_get($data, 'environment'),
'owner_id' => array_get($data, 'owner_id'),
'node_id' => array_get($data, 'node_id'),
'memory' => array_get($data, 'limits.memory'),
'swap' => array_get($data, 'limits.swap'),
'disk' => array_get($data, 'limits.disk'),
'io' => array_get($data, 'limits.io'),
'cpu' => array_get($data, 'limits.cpu'),
'threads' => array_get($data, 'limits.threads'),
'skip_scripts' => array_get($data, 'skip_scripts', false),
'allocation_id' => array_get($data, 'allocation.default'),
'allocation_additional' => array_get($data, 'allocation.additional'),
'start_on_completion' => array_get($data, 'start_on_completion', false),
'database_limit' => array_get($data, 'feature_limits.databases'),
'cpu' => array_get($data, 'limits.cpu'),
'oom_disabled' => !array_get($data, 'limits.oom_killer'),
'allocation_limit' => array_get($data, 'feature_limits.allocations'),
'backup_limit' => array_get($data, 'feature_limits.backups'),
'database_limit' => array_get($data, 'feature_limits.databases'),
'allocation_id' => array_get($data, 'allocation.default'),
'allocation_additional' => array_get($data, 'allocation.additional'),
'startup' => array_get($data, 'startup'),
'environment' => array_get($data, 'environment'),
'egg_id' => array_get($data, 'egg'),
'image' => array_get($data, 'image'),
'skip_scripts' => array_get($data, 'skip_scripts'),
'start_on_completion' => array_get($data, 'start_on_completion', false),
];
}
public function withValidator(Validator $validator)
{
$validator->sometimes('allocation.default', [
'required',
'integer',
'bail',
Rule::exists('allocations', 'id')->where(function ($query) {
$query->whereNull('server_id');
}),
], function ($input) {
return !($input->deploy);
});
$validator->sometimes('allocation.additional.*', [
'integer',
Rule::exists('allocations', 'id')->where(function ($query) {
$query->whereNull('server_id');
}),
], function ($input) {
return !($input->deploy);
});
$validator->sometimes('deploy.locations', 'present', function ($input) {
return $input->deploy;
});
$validator->sometimes('deploy.port_range', 'present', function ($input) {
return $input->deploy;
});
}
public function getDeploymentObject(): ?DeploymentObject
{
if (is_null($this->input('deploy'))) {
return null;
}
$object = new DeploymentObject();
$object->setDedicated($this->input('deploy.dedicated_ip', false));
$object->setLocations($this->input('deploy.locations', []));
$object->setPorts($this->input('deploy.port_range', []));
return $object;
}
}

View file

@ -55,7 +55,7 @@ class UpdateServerRequest extends ApplicationApiRequest
'io' => array_get($data, 'limits.io'),
'threads' => array_get($data, 'limits.threads'),
'cpu' => array_get($data, 'limits.cpu'),
'oom_disabled' => array_get($data, 'limits.oom_disabled'),
'oom_disabled' => !array_get($data, 'limits.oom_killer'),
'allocation_limit' => array_get($data, 'feature_limits.allocations'),
'backup_limit' => array_get($data, 'feature_limits.backups'),

View file

@ -1,6 +1,6 @@
import { Model, UUID, WithRelationships, withRelationships } from '@/api/admin/index';
import { Location } from '@/api/admin/location';
import http from '@/api/http';
import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http';
import { AdminTransformers } from '@/api/admin/transformers';
import { Server } from '@/api/admin/server';
@ -66,3 +66,11 @@ export const getNode = async (id: string | number): Promise<WithRelationships<No
return withRelationships(AdminTransformers.toNode(data.data), 'location');
};
export const searchNodes = async (params: QueryBuilderParams<'name'>): Promise<Node[]> => {
const { data } = await http.get('/api/application/nodes', {
params: withQueryBuilderParams(params),
});
return data.data.map(AdminTransformers.toNode);
};

View file

@ -79,11 +79,11 @@ type LoadedServer = WithRelationships<Server, 'allocations' | 'user' | 'node'>;
export const getServer = async (id: number | string): Promise<LoadedServer> => {
const { data } = await http.get(`/api/application/servers/${id}`, {
params: {
include: [ 'allocations', 'user', 'node' ],
include: [ 'allocations', 'user', 'node', 'variables' ],
},
});
return withRelationships(AdminTransformers.toServer(data), 'allocations', 'user', 'node');
return withRelationships(AdminTransformers.toServer(data), 'allocations', 'user', 'node', 'variables');
};
/**

View file

@ -1,57 +1,50 @@
import http from '@/api/http';
import { Server, rawDataToServer } from '@/api/admin/servers/getServers';
interface CreateServerRequest {
export interface CreateServerRequest {
externalId: string;
name: string;
description: string | null;
user: number;
egg: number;
dockerImage: string;
startup: string;
skipScripts: boolean;
oomDisabled: boolean;
startOnCompletion: boolean;
environment: string[];
allocation: {
default: number;
additional: number[];
};
ownerId: number;
nodeId: number;
limits: {
cpu: number;
disk: number;
io: number;
memory: number;
swap: number;
disk: number;
io: number;
cpu: number;
threads: string;
};
oomDisabled: boolean;
}
featureLimits: {
allocations: number;
backups: number;
databases: number;
};
allocation: {
default: number;
additional: number[];
};
startup: string;
environment: Record<string, any>;
eggId: number;
image: string;
skipScripts: boolean;
startOnCompletion: boolean;
}
export default (r: CreateServerRequest, include: string[] = []): Promise<Server> => {
return new Promise((resolve, reject) => {
http.post('/api/application/servers', {
externalId: r.externalId,
name: r.name,
description: r.description,
user: r.user,
egg: r.egg,
docker_image: r.dockerImage,
startup: r.startup,
skip_scripts: r.skipScripts,
oom_disabled: r.oomDisabled,
start_on_completion: r.startOnCompletion,
environment: r.environment,
allocation: {
default: r.allocation.default,
additional: r.allocation.additional,
},
owner_id: r.ownerId,
node_id: r.nodeId,
limits: {
cpu: r.limits.cpu,
@ -67,6 +60,18 @@ export default (r: CreateServerRequest, include: string[] = []): Promise<Server>
backups: r.featureLimits.backups,
databases: r.featureLimits.databases,
},
allocation: {
default: r.allocation.default,
additional: r.allocation.additional,
},
startup: r.startup,
environment: r.environment,
egg_id: r.eggId,
image: r.image,
skip_scripts: r.skipScripts,
start_on_completion: r.startOnCompletion,
}, { params: { include: include.join(',') } })
.then(({ data }) => resolve(rawDataToServer(data)))
.catch(reject);

View file

@ -43,7 +43,7 @@ export default (id: number, server: Partial<Values>, include: string[] = []): Pr
io: server.limits?.io,
cpu: server.limits?.cpu,
threads: server.limits?.threads,
oom_disabled: server.limits?.oomDisabled,
oom_killer: server.limits?.oomDisabled,
},
feature_limits: {

View file

@ -1,9 +1,9 @@
import Label from '@/components/elements/Label';
import Select from '@/components/elements/Select';
import { useField } from 'formik';
import React, { useEffect, useState } from 'react';
import { Egg, searchEggs } from '@/api/admin/egg';
import { WithRelationships } from '@/api/admin';
import { Egg, searchEggs } from '@/api/admin/egg';
import Label from '@/components/elements/Label';
import Select from '@/components/elements/Select';
interface Props {
nestId?: number;
@ -15,31 +15,40 @@ export default ({ nestId, selectedEggId, onEggSelect }: Props) => {
const [ , , { setValue, setTouched } ] = useField<Record<string, string | undefined>>('environment');
const [ eggs, setEggs ] = useState<WithRelationships<Egg, 'variables'>[] | null>(null);
useEffect(() => {
if (!nestId) return setEggs(null);
const selectEgg = (egg: Egg | null) => {
if (egg === null) {
onEggSelect(null);
return;
}
searchEggs(nestId, {}).then(eggs => {
// Clear values
setValue({});
setTouched(true);
onEggSelect(egg);
const values: Record<string, any> = {};
egg.relationships.variables?.forEach(v => { values[v.environmentVariable] = v.defaultValue; });
setValue(values);
setTouched(true);
};
useEffect(() => {
if (!nestId) {
setEggs(null);
return;
}
searchEggs(nestId, {})
.then(eggs => {
setEggs(eggs);
onEggSelect(eggs[0] || null);
}).catch(error => console.error(error));
selectEgg(eggs[0] || null);
})
.catch(error => console.error(error));
}, [ nestId ]);
const onSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
if (!eggs) return;
const match = eggs.find(egg => String(egg.id) === e.currentTarget.value);
if (!match) return onEggSelect(null);
// Ensure that only new egg variables are present in the record storing all
// of the possible variables. This ensures the fields are controlled, rather
// than uncontrolled when a user begins typing in them.
setValue(match.relationships.variables.reduce((obj, value) => ({
...obj,
[value.environmentVariable]: undefined,
}), {}));
setTouched(true);
onEggSelect(match);
selectEgg(eggs?.find(egg => egg.id.toString() === e.currentTarget.value) || null);
};
return (

View file

@ -1,77 +1,45 @@
import { Egg } from '@/api/admin/egg';
import AdminBox from '@/components/admin/AdminBox';
import NodeSelect from '@/components/admin/servers/NodeSelect';
import { ServerImageContainer, ServerServiceContainer, ServerVariableContainer } from '@/components/admin/servers/ServerStartupContainer';
import BaseSettingsBox from '@/components/admin/servers/settings/BaseSettingsBox';
import FeatureLimitsBox from '@/components/admin/servers/settings/FeatureLimitsBox';
import ServerResourceBox from '@/components/admin/servers/settings/ServerResourceBox';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import FormikSwitch from '@/components/elements/FormikSwitch';
import Label from '@/components/elements/Label';
import Select from '@/components/elements/Select';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import FlashMessageRender from '@/components/FlashMessageRender';
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import { Form, Formik } from 'formik';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import React, { useState } from 'react';
import tw from 'twin.macro';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import { object } from 'yup';
import { Values } from '@/api/admin/servers/updateServer';
import { CreateServerRequest } from '@/api/admin/servers/createServer';
function InternalForm () {
const { isSubmitting, isValid, values: { environment } } = useFormikContext<CreateServerRequest>();
export default () => {
const [ egg, setEgg ] = useState<Egg | null>(null);
const submit = (_: Values) => {
//
};
return (
<AdminContentBlock title={'New Server'}>
<div css={tw`w-full flex flex-row items-center mb-8`}>
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>New Server</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>Add a new server to the panel.</p>
</div>
</div>
<FlashMessageRender byKey={'server:create'} css={tw`mb-4`}/>
<Formik
onSubmit={submit}
initialValues={{
externalId: '',
name: '',
ownerId: 0,
limits: {
memory: 0,
swap: 0,
disk: 0,
io: 0,
cpu: 0,
threads: '',
// This value is inverted to have the switch be on when the
// OOM Killer is enabled, rather than when disabled.
oomDisabled: false,
},
featureLimits: {
allocations: 1,
backups: 0,
databases: 0,
},
allocationId: 0,
addAllocations: [] as number[],
removeAllocations: [] as number[],
}}
validationSchema={object().shape({})}
>
{({ isSubmitting, isValid }) => (
<Form>
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-8 mb-16`}>
<div css={tw`grid grid-cols-1 gap-y-6`}>
<BaseSettingsBox/>
<div css={tw`grid grid-cols-2 gap-y-6 gap-x-8 mb-16`}>
<div css={tw`grid grid-cols-1 gap-y-6 col-span-2 md:col-span-1`}>
<BaseSettingsBox>
<NodeSelect/>
<div css={tw`xl:col-span-2 bg-neutral-800 border border-neutral-900 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'startOnCompletion'}
label={'Start after installation'}
description={'Should the server be automatically started after it has been installed?'}
/>
</div>
</BaseSettingsBox>
<FeatureLimitsBox/>
{/* TODO: in networking box only show primary allocation and additional allocations */}
{/* TODO: add node select */}
<ServerServiceContainer
egg={egg}
setEgg={setEgg}
@ -79,16 +47,20 @@ export default () => {
nestId={1}
/>
</div>
<div css={tw`grid grid-cols-1 gap-y-6`}>
<div css={tw`grid grid-cols-1 gap-y-6 col-span-2 md:col-span-1`}>
<AdminBox icon={faNetworkWired} title={'Networking'} isLoading={isSubmitting}>
<div css={tw`grid grid-cols-1 gap-4 lg:gap-6`}>
<div>
<Label htmlFor={'allocationId'}>Primary Allocation</Label>
<Select id={'allocationId'} name={'allocationId'}/>
<Select id={'allocationId'} name={'allocationId'} disabled>
<option value="">Select a node...</option>
</Select>
</div>
<div>
<Label htmlFor={'additionalAllocations'}>Additional Allocations</Label>
<Select id={'additionalAllocations'} name={'additionalAllocations'}/>
<Select id={'additionalAllocations'} name={'additionalAllocations'} disabled>
<option value="">Select a node...</option>
</Select>
</div>
</div>
</AdminBox>
@ -110,11 +82,11 @@ export default () => {
</AdminBox>
<div css={tw`col-span-2 grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-8`}>
{egg?.relationships.variables?.map((v, i) => (
{/* This ensures that no variables are rendered unless the environment has a value for the variable. */}
{egg?.relationships.variables?.filter(v => Object.keys(environment).find(e => e === v.environmentVariable) !== undefined).map((v, i) => (
<ServerVariableContainer
key={i}
variable={v}
defaultValue={v.defaultValue}
/>
))}
</div>
@ -133,7 +105,64 @@ export default () => {
</div>
</div>
</Form>
)}
);
}
export default () => {
const submit = (r: CreateServerRequest, { setSubmitting }: FormikHelpers<CreateServerRequest>) => {
console.log(r);
setSubmitting(false);
};
return (
<AdminContentBlock title={'New Server'}>
<div css={tw`w-full flex flex-row items-center mb-8`}>
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>New Server</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>Add a new server to the panel.</p>
</div>
</div>
<FlashMessageRender byKey={'server:create'} css={tw`mb-4`}/>
<Formik
onSubmit={submit}
initialValues={{
externalId: '',
name: '',
description: '',
ownerId: 0,
nodeId: 0,
limits: {
memory: 1024,
swap: 0,
disk: 4096,
io: 500,
cpu: 0,
threads: '',
// This value is inverted to have the switch be on when the
// OOM Killer is enabled, rather than when disabled.
oomDisabled: false,
},
featureLimits: {
allocations: 1,
backups: 0,
databases: 0,
},
allocation: {
default: 0,
additional: [] as number[],
},
startup: '',
environment: [],
eggId: 0,
image: '',
skipScripts: false,
startOnCompletion: true,
} as CreateServerRequest}
validationSchema={object().shape({})}
>
<InternalForm/>
</Formik>
</AdminContentBlock>
);

View file

@ -0,0 +1,47 @@
import React, { useState } from 'react';
import { useFormikContext } from 'formik';
import SearchableSelect, { Option } from '@/components/elements/SearchableSelect';
import { Node, searchNodes } from '@/api/admin/node';
export default ({ selected }: { selected?: Node }) => {
const context = useFormikContext();
const [ node, setNode ] = useState<Node | null>(selected || null);
const [ nodes, setNodes ] = useState<Node[] | null>(null);
const onSearch = async (query: string) => {
setNodes(
await searchNodes({ filters: { name: query } }),
);
};
const onSelect = (node: Node | null) => {
setNode(node);
context.setFieldValue('ownerId', node?.id || null);
};
const getSelectedText = (node: Node | null): string => node?.name || '';
return (
<SearchableSelect
id={'nodeId'}
name={'nodeId'}
label={'Node'}
placeholder={'Select a node...'}
items={nodes}
selected={node}
setSelected={setNode}
setItems={setNodes}
onSearch={onSearch}
onSelect={onSelect}
getSelectedText={getSelectedText}
nullable
>
{nodes?.map(d => (
<Option key={d.id} selectId={'nodeId'} id={d.id} item={d} active={d.id === node?.id}>
{d.name}
</Option>
))}
</SearchableSelect>
);
};

View file

@ -10,7 +10,7 @@ import AdminBox from '@/components/admin/AdminBox';
import tw from 'twin.macro';
import Field from '@/components/elements/Field';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { Form, Formik, FormikHelpers, useField, useFormikContext } from 'formik';
import { ApplicationStore } from '@/state';
import { Actions, useStoreActions } from 'easy-peasy';
import Label from '@/components/elements/Label';
@ -60,7 +60,7 @@ function ServerStartupLineContainer ({ egg, server }: { egg: Egg | null; server:
export function ServerServiceContainer ({ egg, setEgg, nestId: _nestId }: { egg: Egg | null, setEgg: (value: Egg | null) => void, nestId: number }) {
const { isSubmitting } = useFormikContext();
const [ nestId, setNestId ] = useState(_nestId);
const [ nestId, setNestId ] = useState<number>(_nestId);
return (
<AdminBox title={'Service Configuration'} isLoading={isSubmitting} css={tw`w-full`}>
@ -71,7 +71,7 @@ export function ServerServiceContainer ({ egg, setEgg, nestId: _nestId }: { egg:
<EggSelect nestId={nestId} selectedEggId={egg?.id} onEggSelect={setEgg}/>
</div>
<div css={tw`bg-neutral-800 border border-neutral-900 shadow-inner p-4 rounded`}>
<FormikSwitch name={'skipScript'} label={'Skip Egg Install Script'} description={'Soon™'}/>
<FormikSwitch name={'skipScripts'} label={'Skip Egg Install Script'} description={'Soon™'}/>
</div>
</AdminBox>
);
@ -98,14 +98,21 @@ export function ServerImageContainer () {
);
}
export function ServerVariableContainer ({ variable, defaultValue }: { variable: EggVariable, defaultValue: string }) {
export function ServerVariableContainer ({ variable, value }: { variable: EggVariable, value?: string }) {
const key = 'environment.' + variable.environmentVariable;
const { isSubmitting, setFieldValue } = useFormikContext();
const [ , , { setValue, setTouched } ] = useField<string | undefined>(key);
const { isSubmitting } = useFormikContext();
useEffect(() => {
setFieldValue(key, defaultValue);
}, [ variable, defaultValue ]);
if (value === undefined) {
return;
}
setValue(value);
setTouched(true);
}, [ value ]);
return (
<AdminBox css={tw`relative w-full`} title={<p css={tw`text-sm uppercase`}>{variable.name}</p>}>
@ -123,7 +130,7 @@ export function ServerVariableContainer ({ variable, defaultValue }: { variable:
}
function ServerStartupForm ({ egg, setEgg, server }: { egg: Egg | null, setEgg: (value: Egg | null) => void; server: Server }) {
const { isSubmitting, isValid } = useFormikContext();
const { isSubmitting, isValid, values: { environment } } = useFormikContext<Values>();
return (
<Form>
@ -150,11 +157,12 @@ function ServerStartupForm ({ egg, setEgg, server }: { egg: Egg | null, setEgg:
</div>
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-8`}>
{egg?.relationships.variables?.map((v, i) => (
{/* This ensures that no variables are rendered unless the environment has a value for the variable. */}
{egg?.relationships.variables?.filter(v => Object.keys(environment).find(e => e === v.environmentVariable) !== undefined).map((v, i) => (
<ServerVariableContainer
key={i}
variable={v}
defaultValue={server.relationships?.variables?.find(v2 => v.eggId === v2.eggId && v.environmentVariable === v2.environmentVariable)?.serverValue || v.defaultValue}
value={server.relationships.variables?.find(v2 => v.eggId === v2.eggId && v.environmentVariable === v2.environmentVariable)?.serverValue}
/>
))}
</div>
@ -205,14 +213,12 @@ export default () => {
onSubmit={submit}
initialValues={{
startup: server.container.startup,
// Don't ask.
environment: Object.fromEntries(egg?.relationships.variables.map(v => [ v.environmentVariable, '' ]) || []),
environment: [] as Record<string, any>,
image: server.container.image,
eggId: server.eggId,
skipScripts: false,
}}
validationSchema={object().shape({
})}
validationSchema={object().shape({})}
>
<ServerStartupForm
egg={egg}

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { ReactNode } from 'react';
import tw from 'twin.macro';
import { useFormikContext } from 'formik';
import AdminBox from '@/components/admin/AdminBox';
@ -7,16 +7,17 @@ import Field from '@/components/elements/Field';
import OwnerSelect from '@/components/admin/servers/OwnerSelect';
import { useServerFromRoute } from '@/api/admin/server';
export default () => {
export default ({ children }: { children?: ReactNode }) => {
const { data: server } = useServerFromRoute();
const { isSubmitting } = useFormikContext();
return (
<AdminBox icon={faCogs} title={'Settings'} isLoading={isSubmitting}>
<div css={tw`grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6`}>
<Field id={'name'} name={'name'} label={'Server Name'} type={'text'}/>
<Field id={'name'} name={'name'} label={'Server Name'} type={'text'} placeholder={'My Amazing Server'}/>
<Field id={'externalId'} name={'externalId'} label={'External Identifier'} type={'text'}/>
<OwnerSelect selected={server?.relationships.user}/>
{children}
</div>
</AdminBox>
);