ui(admin): add "working" React admin ui
This commit is contained in:
parent
d1c7494933
commit
5402584508
199 changed files with 13387 additions and 151 deletions
75
resources/scripts/components/admin/servers/EggSelect.tsx
Normal file
75
resources/scripts/components/admin/servers/EggSelect.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { useField } from 'formik';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { WithRelationships } from '@/api/admin';
|
||||
import type { Egg } from '@/api/admin/egg';
|
||||
import { searchEggs } from '@/api/admin/egg';
|
||||
import Label from '@/components/elements/Label';
|
||||
import Select from '@/components/elements/Select';
|
||||
|
||||
interface Props {
|
||||
nestId?: number;
|
||||
selectedEggId?: number;
|
||||
onEggSelect: (egg: Egg | null) => void;
|
||||
}
|
||||
|
||||
export default ({ nestId, selectedEggId, onEggSelect }: Props) => {
|
||||
const [, , { setValue, setTouched }] = useField<Record<string, string | undefined>>('environment');
|
||||
const [eggs, setEggs] = useState<WithRelationships<Egg, 'variables'>[] | null>(null);
|
||||
|
||||
const selectEgg = (egg: Egg | null) => {
|
||||
if (egg === null) {
|
||||
onEggSelect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
selectEgg(eggs[0] || null);
|
||||
})
|
||||
.catch(error => console.error(error));
|
||||
}, [nestId]);
|
||||
|
||||
const onSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
selectEgg(eggs?.find(egg => egg.id.toString() === e.currentTarget.value) || null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label>Egg</Label>
|
||||
<Select id={'eggId'} name={'eggId'} defaultValue={selectedEggId} onChange={onSelectChange}>
|
||||
{!eggs ? (
|
||||
<option disabled>Loading...</option>
|
||||
) : (
|
||||
eggs.map(v => (
|
||||
<option key={v.id} value={v.id.toString()}>
|
||||
{v.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
</>
|
||||
);
|
||||
};
|
44
resources/scripts/components/admin/servers/NestSelector.tsx
Normal file
44
resources/scripts/components/admin/servers/NestSelector.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { Nest } from '@/api/admin/nest';
|
||||
import { searchNests } from '@/api/admin/nest';
|
||||
import Label from '@/components/elements/Label';
|
||||
import Select from '@/components/elements/Select';
|
||||
|
||||
interface Props {
|
||||
selectedNestId?: number;
|
||||
onNestSelect: (nest: number) => void;
|
||||
}
|
||||
|
||||
export default ({ selectedNestId, onNestSelect }: Props) => {
|
||||
const [nests, setNests] = useState<Nest[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchNests({})
|
||||
.then(nests => {
|
||||
setNests(nests);
|
||||
if (selectedNestId === 0 && nests.length > 0) {
|
||||
// @ts-expect-error go away
|
||||
onNestSelect(nests[0].id);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error(error));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label>Nest</Label>
|
||||
<Select value={selectedNestId} onChange={e => onNestSelect(Number(e.currentTarget.value))}>
|
||||
{!nests ? (
|
||||
<option disabled>Loading...</option>
|
||||
) : (
|
||||
nests?.map(v => (
|
||||
<option key={v.uuid} value={v.id.toString()}>
|
||||
{v.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,225 @@
|
|||
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
|
||||
import type { FormikHelpers } from 'formik';
|
||||
import { Form, Formik, useFormikContext } from 'formik';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import tw from 'twin.macro';
|
||||
import { object } from 'yup';
|
||||
|
||||
import type { Egg } from '@/api/admin/egg';
|
||||
import type { CreateServerRequest } from '@/api/admin/servers/createServer';
|
||||
import createServer from '@/api/admin/servers/createServer';
|
||||
import type { Allocation, Node } from '@/api/admin/node';
|
||||
import { getAllocations } from '@/api/admin/node';
|
||||
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 useFlash from '@/plugins/useFlash';
|
||||
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||
|
||||
function InternalForm() {
|
||||
const {
|
||||
isSubmitting,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
values: { environment },
|
||||
} = useFormikContext<CreateServerRequest>();
|
||||
|
||||
const [egg, setEgg] = useState<Egg | null>(null);
|
||||
const [node, setNode] = useState<Node | null>(null);
|
||||
const [allocations, setAllocations] = useState<Allocation[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (egg === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFieldValue('eggId', egg.id);
|
||||
setFieldValue('startup', '');
|
||||
setFieldValue('image', egg.dockerImages.length > 0 ? egg.dockerImages[0] : '');
|
||||
}, [egg]);
|
||||
|
||||
useEffect(() => {
|
||||
if (node === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// server_id: 0 filters out assigned allocations
|
||||
getAllocations(node.id, { filters: { server_id: '0' } }).then(setAllocations);
|
||||
}, [node]);
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<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 node={node} setNode={setNode} />
|
||||
<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 />
|
||||
<ServerServiceContainer egg={egg} setEgg={setEgg} nestId={0} />
|
||||
</div>
|
||||
<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={'allocation.default'}>Primary Allocation</Label>
|
||||
<Select
|
||||
id={'allocation.default'}
|
||||
name={'allocation.default'}
|
||||
disabled={node === null}
|
||||
onChange={e => setFieldValue('allocation.default', Number(e.currentTarget.value))}
|
||||
>
|
||||
{node === null ? (
|
||||
<option value="">Select a node...</option>
|
||||
) : (
|
||||
<option value="">Select an allocation...</option>
|
||||
)}
|
||||
{allocations?.map(a => (
|
||||
<option key={a.id} value={a.id.toString()}>
|
||||
{a.getDisplayText()}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{/*<div>*/}
|
||||
{/* /!* TODO: Multi-select *!/*/}
|
||||
{/* <Label htmlFor={'allocation.additional'}>Additional Allocations</Label>*/}
|
||||
{/* <Select id={'allocation.additional'} name={'allocation.additional'} disabled={node === null}>*/}
|
||||
{/* {node === null ? <option value="">Select a node...</option> : <option value="">Select additional allocations...</option>}*/}
|
||||
{/* {allocations?.map(a => <option key={a.id} value={a.id.toString()}>{a.getDisplayText()}</option>)}*/}
|
||||
{/* </Select>*/}
|
||||
{/*</div>*/}
|
||||
</div>
|
||||
</AdminBox>
|
||||
<ServerResourceBox />
|
||||
<ServerImageContainer />
|
||||
</div>
|
||||
|
||||
<AdminBox title={'Startup Command'} css={tw`relative w-full col-span-2`}>
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
|
||||
<Field
|
||||
id={'startup'}
|
||||
name={'startup'}
|
||||
label={'Startup Command'}
|
||||
type={'text'}
|
||||
description={
|
||||
"Edit your server's startup command here. The following variables are available by default: {{SERVER_MEMORY}}, {{SERVER_IP}}, and {{SERVER_PORT}}."
|
||||
}
|
||||
placeholder={egg?.startup || ''}
|
||||
/>
|
||||
</AdminBox>
|
||||
|
||||
<div css={tw`col-span-2 grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-8`}>
|
||||
{/* 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} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div css={tw`bg-neutral-700 rounded shadow-md px-4 py-3 col-span-2`}>
|
||||
<div css={tw`flex flex-row`}>
|
||||
<Button type="submit" size="small" css={tw`ml-auto`} disabled={isSubmitting || !isValid}>
|
||||
Create Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
|
||||
const submit = (r: CreateServerRequest, { setSubmitting }: FormikHelpers<CreateServerRequest>) => {
|
||||
clearFlashes('server:create');
|
||||
|
||||
createServer(r)
|
||||
.then(s => navigate(`/admin/servers/${s.id}`))
|
||||
.catch(error => clearAndAddHttpError({ key: 'server:create', error }))
|
||||
.then(() => 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>
|
||||
);
|
||||
};
|
46
resources/scripts/components/admin/servers/NodeSelect.tsx
Normal file
46
resources/scripts/components/admin/servers/NodeSelect.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Node } from '@/api/admin/node';
|
||||
import { searchNodes } from '@/api/admin/node';
|
||||
import SearchableSelect, { Option } from '@/components/elements/SearchableSelect';
|
||||
|
||||
export default ({ node, setNode }: { node: Node | null; setNode: (_: Node | null) => void }) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
|
||||
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);
|
||||
setFieldValue('nodeId', 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>
|
||||
);
|
||||
};
|
47
resources/scripts/components/admin/servers/OwnerSelect.tsx
Normal file
47
resources/scripts/components/admin/servers/OwnerSelect.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { searchUserAccounts } from '@/api/admin/users';
|
||||
import SearchableSelect, { Option } from '@/components/elements/SearchableSelect';
|
||||
import type { User } from '@definitions/admin';
|
||||
|
||||
export default ({ selected }: { selected?: User }) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
|
||||
const [user, setUser] = useState<User | null>(selected || null);
|
||||
const [users, setUsers] = useState<User[] | null>(null);
|
||||
|
||||
const onSearch = async (query: string) => {
|
||||
setUsers(await searchUserAccounts({ filters: { username: query, email: query } }));
|
||||
};
|
||||
|
||||
const onSelect = (user: User | null) => {
|
||||
setUser(user);
|
||||
setFieldValue('ownerId', user?.id || null);
|
||||
};
|
||||
|
||||
const getSelectedText = (user: User | null): string => user?.email || '';
|
||||
|
||||
return (
|
||||
<SearchableSelect
|
||||
id={'ownerId'}
|
||||
name={'ownerId'}
|
||||
label={'Owner'}
|
||||
placeholder={'Select a user...'}
|
||||
items={users}
|
||||
selected={user}
|
||||
setSelected={setUser}
|
||||
setItems={setUsers}
|
||||
onSearch={onSearch}
|
||||
onSelect={onSelect}
|
||||
getSelectedText={getSelectedText}
|
||||
nullable
|
||||
>
|
||||
{users?.map(d => (
|
||||
<Option key={d.id} selectId={'ownerId'} id={d.id} item={d} active={d.id === user?.id}>
|
||||
{d.email}
|
||||
</Option>
|
||||
))}
|
||||
</SearchableSelect>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,66 @@
|
|||
import { TrashIcon } from '@heroicons/react/outline';
|
||||
import type { Actions } from 'easy-peasy';
|
||||
import { useStoreActions } from 'easy-peasy';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
import Button from '@/components/elements/Button';
|
||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||
import deleteServer from '@/api/admin/servers/deleteServer';
|
||||
import { useServerFromRoute } from '@/api/admin/server';
|
||||
import type { ApplicationStore } from '@/state';
|
||||
|
||||
export default () => {
|
||||
const navigate = useNavigate();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { data: server } = useServerFromRoute();
|
||||
|
||||
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||
);
|
||||
|
||||
const onDelete = () => {
|
||||
if (!server) return;
|
||||
|
||||
setLoading(true);
|
||||
clearFlashes('server');
|
||||
|
||||
deleteServer(server.id)
|
||||
.then(() => navigate('/admin/servers'))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'server', error });
|
||||
|
||||
setLoading(false);
|
||||
setVisible(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!server) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmationModal
|
||||
visible={visible}
|
||||
title={'Delete server?'}
|
||||
buttonText={'Yes, delete server'}
|
||||
onConfirmed={onDelete}
|
||||
showSpinnerOverlay={loading}
|
||||
onModalDismissed={() => setVisible(false)}
|
||||
>
|
||||
Are you sure you want to delete this server?
|
||||
</ConfirmationModal>
|
||||
<Button
|
||||
type={'button'}
|
||||
size={'small'}
|
||||
color={'red'}
|
||||
onClick={() => setVisible(true)}
|
||||
css={tw`flex items-center justify-center`}
|
||||
>
|
||||
<TrashIcon css={tw`w-5 h-5 mr-2`} /> Delete Server
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
import tw from 'twin.macro';
|
||||
|
||||
import { useServerFromRoute } from '@/api/admin/server';
|
||||
import AdminBox from '@/components/admin/AdminBox';
|
||||
import Button from '@/components/elements/Button';
|
||||
|
||||
export default () => {
|
||||
const { data: server } = useServerFromRoute();
|
||||
|
||||
if (!server) return null;
|
||||
|
||||
return (
|
||||
<div css={tw`grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-x-2 gap-y-2`}>
|
||||
<div css={tw`h-auto flex flex-col`}>
|
||||
<AdminBox title={'Reinstall Server'} css={tw`relative w-full`}>
|
||||
<div css={tw`flex flex-row text-red-500 justify-start items-center mb-4`}>
|
||||
<div css={tw`w-12 mr-2`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p css={tw`text-sm`}>Danger! This could overwrite server data.</p>
|
||||
</div>
|
||||
<Button size={'large'} color={'red'} css={tw`w-full`}>
|
||||
Reinstall Server
|
||||
</Button>
|
||||
<p css={tw`text-xs text-neutral-400 mt-2`}>
|
||||
This will reinstall the server with the assigned service scripts.
|
||||
</p>
|
||||
</AdminBox>
|
||||
</div>
|
||||
<div css={tw`h-auto flex flex-col`}>
|
||||
<AdminBox title={'Install Status'} css={tw`relative w-full`}>
|
||||
<Button size={'large'} color={'primary'} css={tw`w-full`}>
|
||||
Set Server as Installing
|
||||
</Button>
|
||||
<p css={tw`text-xs text-neutral-400 mt-2`}>
|
||||
If you need to change the install status from uninstalled to installed, or vice versa, you may
|
||||
do so with the button below.
|
||||
</p>
|
||||
</AdminBox>
|
||||
</div>
|
||||
<div css={tw`h-auto flex flex-col`}>
|
||||
<AdminBox title={'Suspend Server '} css={tw`relative w-full`}>
|
||||
<Button size={'large'} color={'primary'} css={tw`w-full`}>
|
||||
Suspend Server
|
||||
</Button>
|
||||
<p css={tw`text-xs text-neutral-400 mt-2`}>
|
||||
This will suspend the server, stop any running processes, and immediately block the user from
|
||||
being able to access their files or otherwise manage the server through the panel or API.
|
||||
</p>
|
||||
</AdminBox>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
76
resources/scripts/components/admin/servers/ServerRouter.tsx
Normal file
76
resources/scripts/components/admin/servers/ServerRouter.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { useEffect } from 'react';
|
||||
import { Route, Routes, useParams } from 'react-router-dom';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
import ServerManageContainer from '@/components/admin/servers/ServerManageContainer';
|
||||
import ServerStartupContainer from '@/components/admin/servers/ServerStartupContainer';
|
||||
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation';
|
||||
import ServerSettingsContainer from '@/components/admin/servers/ServerSettingsContainer';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { useServerFromRoute } from '@/api/admin/server';
|
||||
import { AdjustmentsIcon, CogIcon, DatabaseIcon, FolderIcon, ShieldExclamationIcon } from '@heroicons/react/outline';
|
||||
|
||||
export default () => {
|
||||
const params = useParams<'id'>();
|
||||
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { data: server, error, isValidating, mutate } = useServerFromRoute();
|
||||
|
||||
useEffect(() => {
|
||||
mutate();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) clearFlashes('server');
|
||||
if (error) clearAndAddHttpError({ key: 'server', error });
|
||||
}, [error]);
|
||||
|
||||
if (!server || (error && isValidating)) {
|
||||
return (
|
||||
<AdminContentBlock showFlashKey={'server'}>
|
||||
<Spinner size={'large'} centered />
|
||||
</AdminContentBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminContentBlock title={'Server - ' + server.name}>
|
||||
<FlashMessageRender byKey={'backups'} css={tw`mb-4`} />
|
||||
<div css={tw`w-full flex flex-row items-center mb-4`}>
|
||||
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>{server.name}</h2>
|
||||
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||
{server.uuid}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FlashMessageRender byKey={'server'} css={tw`mb-4`} />
|
||||
|
||||
<SubNavigation>
|
||||
<SubNavigationLink to={`/admin/servers/${params.id}`} name={'Settings'} icon={CogIcon} />
|
||||
<SubNavigationLink to={`/admin/servers/${params.id}/startup`} name={'Startup'} icon={AdjustmentsIcon} />
|
||||
<SubNavigationLink
|
||||
to={`/admin/servers/${params.id}/databases`}
|
||||
name={'Databases'}
|
||||
icon={DatabaseIcon}
|
||||
/>
|
||||
<SubNavigationLink to={`/admin/servers/${params.id}/mounts`} name={'Mounts'} icon={FolderIcon} />
|
||||
<SubNavigationLink
|
||||
to={`/admin/servers/${params.id}/manage`}
|
||||
name={'Manage'}
|
||||
icon={ShieldExclamationIcon}
|
||||
/>
|
||||
</SubNavigation>
|
||||
|
||||
<Routes>
|
||||
<Route path="" element={<ServerSettingsContainer />} />
|
||||
<Route path="startup" element={<ServerStartupContainer />} />
|
||||
<Route path="manage" element={<ServerManageContainer />} />
|
||||
</Routes>
|
||||
</AdminContentBlock>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,103 @@
|
|||
import { useStoreActions } from 'easy-peasy';
|
||||
import type { FormikHelpers } from 'formik';
|
||||
import { Form, Formik } from 'formik';
|
||||
import tw from 'twin.macro';
|
||||
import { object } from 'yup';
|
||||
|
||||
import { useServerFromRoute } from '@/api/admin/server';
|
||||
import type { Values } from '@/api/admin/servers/updateServer';
|
||||
import updateServer from '@/api/admin/servers/updateServer';
|
||||
import ServerDeleteButton from '@/components/admin/servers/ServerDeleteButton';
|
||||
import BaseSettingsBox from '@/components/admin/servers/settings/BaseSettingsBox';
|
||||
import FeatureLimitsBox from '@/components/admin/servers/settings/FeatureLimitsBox';
|
||||
import NetworkingBox from '@/components/admin/servers/settings/NetworkingBox';
|
||||
import ServerResourceBox from '@/components/admin/servers/settings/ServerResourceBox';
|
||||
import Button from '@/components/elements/Button';
|
||||
|
||||
export default () => {
|
||||
const { data: server } = useServerFromRoute();
|
||||
const { clearFlashes, clearAndAddHttpError } = useStoreActions(actions => actions.flashes);
|
||||
|
||||
if (!server) return null;
|
||||
|
||||
const submit = (values: Values, { setSubmitting, setFieldValue }: FormikHelpers<Values>) => {
|
||||
clearFlashes('server');
|
||||
|
||||
// This value is inverted to have the switch be on when the
|
||||
// OOM Killer is enabled, rather than when disabled.
|
||||
values.limits.oomDisabled = !values.limits.oomDisabled;
|
||||
|
||||
updateServer(server.id, values)
|
||||
.then(() => {
|
||||
// setServer({ ...server, ...s });
|
||||
|
||||
// TODO: Figure out how to properly clear react-selects for allocations.
|
||||
setFieldValue('addAllocations', []);
|
||||
setFieldValue('removeAllocations', []);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'server', error });
|
||||
})
|
||||
.then(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{
|
||||
externalId: server.externalId || '',
|
||||
name: server.name,
|
||||
ownerId: server.userId,
|
||||
limits: {
|
||||
memory: server.limits.memory,
|
||||
swap: server.limits.swap,
|
||||
disk: server.limits.disk,
|
||||
io: server.limits.io,
|
||||
cpu: server.limits.cpu,
|
||||
threads: server.limits.threads || '',
|
||||
// This value is inverted to have the switch be on when the
|
||||
// OOM Killer is enabled, rather than when disabled.
|
||||
oomDisabled: !server.limits.oomDisabled,
|
||||
},
|
||||
featureLimits: {
|
||||
allocations: server.featureLimits.allocations,
|
||||
backups: server.featureLimits.backups,
|
||||
databases: server.featureLimits.databases,
|
||||
},
|
||||
allocationId: server.allocationId,
|
||||
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 />
|
||||
<FeatureLimitsBox />
|
||||
<NetworkingBox />
|
||||
</div>
|
||||
<div css={tw`flex flex-col`}>
|
||||
<ServerResourceBox />
|
||||
<div css={tw`bg-neutral-700 rounded shadow-md px-4 xl:px-5 py-4 mt-6`}>
|
||||
<div css={tw`flex flex-row`}>
|
||||
<ServerDeleteButton />
|
||||
<Button
|
||||
type="submit"
|
||||
size="small"
|
||||
css={tw`ml-auto`}
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,258 @@
|
|||
import type { Actions } from 'easy-peasy';
|
||||
import { useStoreActions } from 'easy-peasy';
|
||||
import type { FormikHelpers } from 'formik';
|
||||
import { Form, Formik, useField, useFormikContext } from 'formik';
|
||||
import { useEffect, useState } from 'react';
|
||||
import tw from 'twin.macro';
|
||||
import { object } from 'yup';
|
||||
|
||||
import type { InferModel } from '@/api/admin';
|
||||
import type { Egg, EggVariable } from '@/api/admin/egg';
|
||||
import { getEgg } from '@/api/admin/egg';
|
||||
import type { Server } from '@/api/admin/server';
|
||||
import { useServerFromRoute } from '@/api/admin/server';
|
||||
import type { Values } from '@/api/admin/servers/updateServerStartup';
|
||||
import updateServerStartup from '@/api/admin/servers/updateServerStartup';
|
||||
import EggSelect from '@/components/admin/servers/EggSelect';
|
||||
import NestSelector from '@/components/admin/servers/NestSelector';
|
||||
import FormikSwitch from '@/components/elements/FormikSwitch';
|
||||
import Button from '@/components/elements/Button';
|
||||
import Input from '@/components/elements/Input';
|
||||
import AdminBox from '@/components/admin/AdminBox';
|
||||
import Field from '@/components/elements/Field';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import Label from '@/components/elements/Label';
|
||||
import type { ApplicationStore } from '@/state';
|
||||
|
||||
function ServerStartupLineContainer({ egg, server }: { egg: Egg | null; server: Server }) {
|
||||
const { isSubmitting, setFieldValue } = useFormikContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (egg === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (server.eggId === egg.id) {
|
||||
setFieldValue('image', server.container.image);
|
||||
setFieldValue('startup', server.container.startup || '');
|
||||
return;
|
||||
}
|
||||
|
||||
// Whenever the egg is changed, set the server's startup command to the egg's default.
|
||||
setFieldValue('image', egg.dockerImages.length > 0 ? egg.dockerImages[0] : '');
|
||||
setFieldValue('startup', '');
|
||||
}, [egg]);
|
||||
|
||||
return (
|
||||
<AdminBox title={'Startup Command'} css={tw`relative w-full`}>
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
|
||||
<div css={tw`mb-6`}>
|
||||
<Field
|
||||
id={'startup'}
|
||||
name={'startup'}
|
||||
label={'Startup Command'}
|
||||
type={'text'}
|
||||
description={
|
||||
"Edit your server's startup command here. The following variables are available by default: {{SERVER_MEMORY}}, {{SERVER_IP}}, and {{SERVER_PORT}}."
|
||||
}
|
||||
placeholder={egg?.startup || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Default Startup Command</Label>
|
||||
<Input value={egg?.startup || ''} readOnly />
|
||||
</div>
|
||||
</AdminBox>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerServiceContainer({
|
||||
egg,
|
||||
setEgg,
|
||||
nestId: _nestId,
|
||||
}: {
|
||||
egg: Egg | null;
|
||||
setEgg: (value: Egg | null) => void;
|
||||
nestId: number;
|
||||
}) {
|
||||
const { isSubmitting } = useFormikContext();
|
||||
|
||||
const [nestId, setNestId] = useState<number>(_nestId);
|
||||
|
||||
return (
|
||||
<AdminBox title={'Service Configuration'} isLoading={isSubmitting} css={tw`w-full`}>
|
||||
<div css={tw`mb-6`}>
|
||||
<NestSelector selectedNestId={nestId} onNestSelect={setNestId} />
|
||||
</div>
|
||||
<div css={tw`mb-6`}>
|
||||
<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={'skipScripts'} label={'Skip Egg Install Script'} description={'Soon™'} />
|
||||
</div>
|
||||
</AdminBox>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerImageContainer() {
|
||||
const { isSubmitting } = useFormikContext();
|
||||
|
||||
return (
|
||||
<AdminBox title={'Image Configuration'} css={tw`relative w-full`}>
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
|
||||
<div css={tw`md:w-full md:flex md:flex-col`}>
|
||||
<div>
|
||||
<Field id={'image'} name={'image'} label={'Docker Image'} type={'text'} />
|
||||
</div>
|
||||
</div>
|
||||
</AdminBox>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerVariableContainer({ variable, value }: { variable: EggVariable; value?: string }) {
|
||||
const key = 'environment.' + variable.environmentVariable;
|
||||
|
||||
const [, , { setValue, setTouched }] = useField<string | undefined>(key);
|
||||
|
||||
const { isSubmitting } = useFormikContext();
|
||||
|
||||
useEffect(() => {
|
||||
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>}>
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
|
||||
<Field
|
||||
id={key}
|
||||
name={key}
|
||||
type={'text'}
|
||||
placeholder={variable.defaultValue}
|
||||
description={variable.description}
|
||||
/>
|
||||
</AdminBox>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerStartupForm({
|
||||
egg,
|
||||
setEgg,
|
||||
server,
|
||||
}: {
|
||||
egg: Egg | null;
|
||||
setEgg: (value: Egg | null) => void;
|
||||
server: Server;
|
||||
}) {
|
||||
const {
|
||||
isSubmitting,
|
||||
isValid,
|
||||
values: { environment },
|
||||
} = useFormikContext<Values>();
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<div css={tw`flex flex-col mb-16`}>
|
||||
<div css={tw`flex flex-row mb-6`}>
|
||||
<ServerStartupLineContainer egg={egg} server={server} />
|
||||
</div>
|
||||
|
||||
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
|
||||
<div css={tw`flex`}>
|
||||
<ServerServiceContainer egg={egg} setEgg={setEgg} nestId={server.nestId} />
|
||||
</div>
|
||||
|
||||
<div css={tw`flex`}>
|
||||
<ServerImageContainer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-8`}>
|
||||
{/* 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}
|
||||
value={
|
||||
server.relationships.variables?.find(
|
||||
v2 => v.eggId === v2.eggId && v.environmentVariable === v2.environmentVariable,
|
||||
)?.serverValue
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div css={tw`bg-neutral-700 rounded shadow-md py-2 pr-6 mt-6`}>
|
||||
<div css={tw`flex flex-row`}>
|
||||
<Button type="submit" size="small" css={tw`ml-auto`} disabled={isSubmitting || !isValid}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const { data: server } = useServerFromRoute();
|
||||
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||
);
|
||||
const [egg, setEgg] = useState<InferModel<typeof getEgg> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!server) return;
|
||||
|
||||
getEgg(server.eggId)
|
||||
.then(egg => setEgg(egg))
|
||||
.catch(error => console.error(error));
|
||||
}, [server?.eggId]);
|
||||
|
||||
if (!server) return null;
|
||||
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('server');
|
||||
|
||||
updateServerStartup(server.id, values)
|
||||
// .then(s => {
|
||||
// mutate(data => { ...data, ...s });
|
||||
// })
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'server', error });
|
||||
})
|
||||
.then(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{
|
||||
startup: server.container.startup || '',
|
||||
environment: [] as Record<string, any>,
|
||||
image: server.container.image,
|
||||
eggId: server.eggId,
|
||||
skipScripts: false,
|
||||
}}
|
||||
validationSchema={object().shape({})}
|
||||
>
|
||||
<ServerStartupForm
|
||||
egg={egg}
|
||||
// @ts-ignore
|
||||
setEgg={setEgg}
|
||||
server={server}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
import { NavLink } from 'react-router-dom';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||
import ServersTable from '@/components/admin/servers/ServersTable';
|
||||
import Button from '@/components/elements/Button';
|
||||
|
||||
function ServersContainer() {
|
||||
return (
|
||||
<AdminContentBlock title={'Servers'}>
|
||||
<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`}>Servers</h2>
|
||||
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||
All servers available on the system.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div css={tw`flex ml-auto pl-4`}>
|
||||
<NavLink to={`/admin/servers/new`}>
|
||||
<Button type={'button'} size={'large'} css={tw`h-10 px-4 py-0 whitespace-nowrap`}>
|
||||
New Server
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FlashMessageRender byKey={'servers'} css={tw`mb-4`} />
|
||||
|
||||
<ServersTable />
|
||||
</AdminContentBlock>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServersContainer;
|
236
resources/scripts/components/admin/servers/ServersTable.tsx
Normal file
236
resources/scripts/components/admin/servers/ServersTable.tsx
Normal file
|
@ -0,0 +1,236 @@
|
|||
import type { ChangeEvent } from 'react';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
import type { Filters } from '@/api/admin/servers/getServers';
|
||||
import getServers, { Context as ServersContext } from '@/api/admin/servers/getServers';
|
||||
import AdminCheckbox from '@/components/admin/AdminCheckbox';
|
||||
import AdminTable, {
|
||||
ContentWrapper,
|
||||
Loading,
|
||||
NoItems,
|
||||
Pagination,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
useTableHooks,
|
||||
} from '@/components/admin/AdminTable';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import { AdminContext } from '@/state/admin';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
|
||||
function RowCheckbox({ id }: { id: number }) {
|
||||
const isChecked = AdminContext.useStoreState(state => state.servers.selectedServers.indexOf(id) >= 0);
|
||||
const appendSelectedServer = AdminContext.useStoreActions(actions => actions.servers.appendSelectedServer);
|
||||
const removeSelectedServer = AdminContext.useStoreActions(actions => actions.servers.removeSelectedServer);
|
||||
|
||||
return (
|
||||
<AdminCheckbox
|
||||
name={id.toString()}
|
||||
checked={isChecked}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.currentTarget.checked) {
|
||||
appendSelectedServer(id);
|
||||
} else {
|
||||
removeSelectedServer(id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filters?: Filters;
|
||||
}
|
||||
|
||||
function ServersTable({ filters }: Props) {
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
|
||||
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(ServersContext);
|
||||
const { data: servers, error, isValidating } = getServers(['node', 'user']);
|
||||
|
||||
const length = servers?.items?.length || 0;
|
||||
|
||||
const setSelectedServers = AdminContext.useStoreActions(actions => actions.servers.setSelectedServers);
|
||||
const selectedServerLength = AdminContext.useStoreState(state => state.servers.selectedServers.length);
|
||||
|
||||
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSelectedServers(e.currentTarget.checked ? servers?.items?.map(server => server.id) || [] : []);
|
||||
};
|
||||
|
||||
const onSearch = (query: string): Promise<void> => {
|
||||
return new Promise(resolve => {
|
||||
if (query.length < 2) {
|
||||
setFilters(filters || null);
|
||||
} else {
|
||||
setFilters({ ...filters, name: query });
|
||||
}
|
||||
return resolve();
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedServers([]);
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
clearFlashes('servers');
|
||||
return;
|
||||
}
|
||||
|
||||
clearAndAddHttpError({ key: 'servers', error });
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<AdminTable>
|
||||
<ContentWrapper
|
||||
checked={selectedServerLength === (length === 0 ? -1 : length)}
|
||||
onSelectAllClick={onSelectAllClick}
|
||||
onSearch={onSearch}
|
||||
>
|
||||
<Pagination data={servers} onPageSelect={setPage}>
|
||||
<div css={tw`overflow-x-auto`}>
|
||||
<table css={tw`w-full table-auto`}>
|
||||
<TableHead>
|
||||
<TableHeader
|
||||
name={'Identifier'}
|
||||
direction={sort === 'uuidShort' ? (sortDirection ? 1 : 2) : null}
|
||||
onClick={() => setSort('uuidShort')}
|
||||
/>
|
||||
<TableHeader
|
||||
name={'Name'}
|
||||
direction={sort === 'name' ? (sortDirection ? 1 : 2) : null}
|
||||
onClick={() => setSort('name')}
|
||||
/>
|
||||
<TableHeader
|
||||
name={'Owner'}
|
||||
direction={sort === 'owner_id' ? (sortDirection ? 1 : 2) : null}
|
||||
onClick={() => setSort('owner_id')}
|
||||
/>
|
||||
<TableHeader
|
||||
name={'Node'}
|
||||
direction={sort === 'node_id' ? (sortDirection ? 1 : 2) : null}
|
||||
onClick={() => setSort('node_id')}
|
||||
/>
|
||||
<TableHeader
|
||||
name={'Status'}
|
||||
direction={sort === 'status' ? (sortDirection ? 1 : 2) : null}
|
||||
onClick={() => setSort('status')}
|
||||
/>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{servers !== undefined &&
|
||||
!error &&
|
||||
!isValidating &&
|
||||
length > 0 &&
|
||||
servers.items.map(server => (
|
||||
<tr key={server.id} css={tw`h-14 hover:bg-neutral-600`}>
|
||||
<td css={tw`pl-6`}>
|
||||
<RowCheckbox id={server.id} />
|
||||
</td>
|
||||
|
||||
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||
<CopyOnClick text={server.identifier}>
|
||||
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
|
||||
{server.identifier}
|
||||
</code>
|
||||
</CopyOnClick>
|
||||
</td>
|
||||
|
||||
<td css={tw`px-6 text-sm text-left whitespace-nowrap`}>
|
||||
<NavLink
|
||||
to={`/admin/servers/${server.id}`}
|
||||
css={tw`text-primary-400 hover:text-primary-300`}
|
||||
>
|
||||
{server.name}
|
||||
</NavLink>
|
||||
</td>
|
||||
|
||||
{/* TODO: Have permission check for displaying user information. */}
|
||||
<td css={tw`px-6 text-sm text-left whitespace-nowrap`}>
|
||||
<NavLink
|
||||
to={`/admin/users/${server.relations.user?.id}`}
|
||||
css={tw`text-primary-400 hover:text-primary-300`}
|
||||
>
|
||||
<div css={tw`text-sm text-neutral-200`}>
|
||||
{server.relations.user?.email}
|
||||
</div>
|
||||
|
||||
<div css={tw`text-sm text-neutral-400`}>
|
||||
{server.relations.user?.uuid.split('-')[0]}
|
||||
</div>
|
||||
</NavLink>
|
||||
</td>
|
||||
|
||||
{/* TODO: Have permission check for displaying node information. */}
|
||||
<td css={tw`px-6 text-sm text-left whitespace-nowrap`}>
|
||||
<NavLink
|
||||
to={`/admin/nodes/${server.relations.node?.id}`}
|
||||
css={tw`text-primary-400 hover:text-primary-300`}
|
||||
>
|
||||
<div css={tw`text-sm text-neutral-200`}>
|
||||
{server.relations.node?.name}
|
||||
</div>
|
||||
|
||||
<div css={tw`text-sm text-neutral-400`}>
|
||||
{server.relations.node?.fqdn}
|
||||
</div>
|
||||
</NavLink>
|
||||
</td>
|
||||
|
||||
<td css={tw`px-6 whitespace-nowrap`}>
|
||||
{server.status === 'installing' ? (
|
||||
<span
|
||||
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-yellow-200 text-yellow-800`}
|
||||
>
|
||||
Installing
|
||||
</span>
|
||||
) : server.status === 'transferring' ? (
|
||||
<span
|
||||
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-yellow-200 text-yellow-800`}
|
||||
>
|
||||
Transferring
|
||||
</span>
|
||||
) : server.status === 'suspended' ? (
|
||||
<span
|
||||
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-red-200 text-red-800`}
|
||||
>
|
||||
Suspended
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-green-100 text-green-800`}
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableBody>
|
||||
</table>
|
||||
|
||||
{servers === undefined || (error && isValidating) ? (
|
||||
<Loading />
|
||||
) : length < 1 ? (
|
||||
<NoItems />
|
||||
) : null}
|
||||
</div>
|
||||
</Pagination>
|
||||
</ContentWrapper>
|
||||
</AdminTable>
|
||||
);
|
||||
}
|
||||
|
||||
export default ({ filters }: Props) => {
|
||||
const hooks = useTableHooks<Filters>(filters);
|
||||
|
||||
return (
|
||||
<ServersContext.Provider value={hooks}>
|
||||
<ServersTable />
|
||||
</ServersContext.Provider>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
import { faCogs } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useFormikContext } from 'formik';
|
||||
import type { ReactNode } from 'react';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
import { useServerFromRoute } from '@/api/admin/server';
|
||||
import AdminBox from '@/components/admin/AdminBox';
|
||||
import OwnerSelect from '@/components/admin/servers/OwnerSelect';
|
||||
import Field from '@/components/elements/Field';
|
||||
|
||||
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'}
|
||||
placeholder={'My Amazing Server'}
|
||||
/>
|
||||
<Field id={'externalId'} name={'externalId'} label={'External Identifier'} type={'text'} />
|
||||
<OwnerSelect selected={server?.relationships.user} />
|
||||
{children}
|
||||
</div>
|
||||
</AdminBox>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
import { faConciergeBell } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useFormikContext } from 'formik';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
import AdminBox from '@/components/admin/AdminBox';
|
||||
import Field from '@/components/elements/Field';
|
||||
|
||||
export default () => {
|
||||
const { isSubmitting } = useFormikContext();
|
||||
|
||||
return (
|
||||
<AdminBox icon={faConciergeBell} title={'Feature Limits'} isLoading={isSubmitting}>
|
||||
<div css={tw`grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6`}>
|
||||
<Field
|
||||
id={'featureLimits.allocations'}
|
||||
name={'featureLimits.allocations'}
|
||||
label={'Allocation Limit'}
|
||||
type={'number'}
|
||||
description={'The total number of allocations a user is allowed to create for this server.'}
|
||||
/>
|
||||
<Field
|
||||
id={'featureLimits.backups'}
|
||||
name={'featureLimits.backups'}
|
||||
label={'Backup Limit'}
|
||||
type={'number'}
|
||||
description={'The total number of backups that can be created for this server.'}
|
||||
/>
|
||||
<Field
|
||||
id={'featureLimits.databases'}
|
||||
name={'featureLimits.databases'}
|
||||
label={'Database Limit'}
|
||||
type={'number'}
|
||||
description={'The total number of databases a user is allowed to create for this server.'}
|
||||
/>
|
||||
</div>
|
||||
</AdminBox>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,68 @@
|
|||
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useFormikContext } from 'formik';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
import getAllocations from '@/api/admin/nodes/getAllocations';
|
||||
import { useServerFromRoute } from '@/api/admin/server';
|
||||
import AdminBox from '@/components/admin/AdminBox';
|
||||
import Label from '@/components/elements/Label';
|
||||
import Select from '@/components/elements/Select';
|
||||
import type { Option } from '@/components/elements/SelectField';
|
||||
import SelectField, { AsyncSelectField } from '@/components/elements/SelectField';
|
||||
|
||||
export default () => {
|
||||
const { isSubmitting } = useFormikContext();
|
||||
const { data: server } = useServerFromRoute();
|
||||
|
||||
const loadOptions = async (inputValue: string, callback: (options: Option[]) => void) => {
|
||||
if (!server) {
|
||||
// eslint-disable-next-line node/no-callback-literal
|
||||
callback([] as Option[]);
|
||||
return;
|
||||
}
|
||||
|
||||
const allocations = await getAllocations(server.nodeId, { ip: inputValue, server_id: '0' });
|
||||
|
||||
callback(
|
||||
allocations.map(a => {
|
||||
return { value: a.id.toString(), label: a.getDisplayText() };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<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'}>
|
||||
{server?.relationships.allocations?.map(a => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.getDisplayText()}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<AsyncSelectField
|
||||
id={'addAllocations'}
|
||||
name={'addAllocations'}
|
||||
label={'Add Allocations'}
|
||||
loadOptions={loadOptions}
|
||||
isMulti
|
||||
/>
|
||||
<SelectField
|
||||
id={'removeAllocations'}
|
||||
name={'removeAllocations'}
|
||||
label={'Remove Allocations'}
|
||||
options={
|
||||
server?.relationships.allocations?.map(a => {
|
||||
return { value: a.id.toString(), label: a.getDisplayText() };
|
||||
}) || []
|
||||
}
|
||||
isMulti
|
||||
isSearchable
|
||||
/>
|
||||
</div>
|
||||
</AdminBox>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,73 @@
|
|||
import { faBalanceScale } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useFormikContext } from 'formik';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
import AdminBox from '@/components/admin/AdminBox';
|
||||
import Field from '@/components/elements/Field';
|
||||
import FormikSwitch from '@/components/elements/FormikSwitch';
|
||||
|
||||
export default () => {
|
||||
const { isSubmitting } = useFormikContext();
|
||||
|
||||
return (
|
||||
<AdminBox icon={faBalanceScale} title={'Resources'} isLoading={isSubmitting}>
|
||||
<div css={tw`grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6`}>
|
||||
<Field
|
||||
id={'limits.cpu'}
|
||||
name={'limits.cpu'}
|
||||
label={'CPU Limit'}
|
||||
type={'text'}
|
||||
description={
|
||||
'Each thread on the system is considered to be 100%. Setting this value to 0 will allow the server to use CPU time without restriction.'
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
id={'limits.threads'}
|
||||
name={'limits.threads'}
|
||||
label={'CPU Pinning'}
|
||||
type={'text'}
|
||||
description={
|
||||
'Advanced: Enter the specific CPU cores that this server can run on, or leave blank to allow all cores. This can be a single number, and or a comma seperated list, and or a dashed range. Example: 0, 0-1,3, or 0,1,3,4. It is recommended to leave this value blank and let the CPU handle balancing the load.'
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
id={'limits.memory'}
|
||||
name={'limits.memory'}
|
||||
label={'Memory Limit'}
|
||||
type={'number'}
|
||||
description={
|
||||
'The maximum amount of memory allowed for this container. Setting this to 0 will allow unlimited memory in a container.'
|
||||
}
|
||||
/>
|
||||
<Field id={'limits.swap'} name={'limits.swap'} label={'Swap Limit'} type={'number'} />
|
||||
<Field
|
||||
id={'limits.disk'}
|
||||
name={'limits.disk'}
|
||||
label={'Disk Limit'}
|
||||
type={'number'}
|
||||
description={
|
||||
'This server will not be allowed to boot if it is using more than this amount of space. If a server goes over this limit while running it will be safely stopped and locked until enough space is available. Set to 0 to allow unlimited disk usage.'
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
id={'limits.io'}
|
||||
name={'limits.io'}
|
||||
label={'Block IO Proportion'}
|
||||
type={'number'}
|
||||
description={
|
||||
'Advanced: The IO performance of this server relative to other running containers on the system. Value should be between 10 and 1000.'
|
||||
}
|
||||
/>
|
||||
<div css={tw`xl:col-span-2 bg-neutral-800 border border-neutral-900 shadow-inner p-4 rounded`}>
|
||||
<FormikSwitch
|
||||
name={'limits.oomDisabled'}
|
||||
label={'Out of Memory Killer'}
|
||||
description={
|
||||
'Enabling the Out of Memory Killer may cause server processes to exit unexpectedly.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AdminBox>
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue