ui(admin): add "working" React admin ui

This commit is contained in:
Matthew Penner 2022-12-15 19:06:14 -07:00
parent d1c7494933
commit 5402584508
No known key found for this signature in database
199 changed files with 13387 additions and 151 deletions

View 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>
</>
);
};

View 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>
</>
);
};

View file

@ -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>
);
};

View 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>
);
};

View 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>
);
};

View file

@ -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>
</>
);
};

View file

@ -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>
);
};

View 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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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;

View 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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};