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,56 @@
import { useFormikContext } from 'formik';
import { useState } from 'react';
import type { Database } from '@/api/admin/databases/getDatabases';
import searchDatabases from '@/api/admin/databases/searchDatabases';
import SearchableSelect, { Option } from '@/components/elements/SearchableSelect';
export default ({ selected }: { selected: Database | null }) => {
const context = useFormikContext();
const [database, setDatabase] = useState<Database | null>(selected);
const [databases, setDatabases] = useState<Database[] | null>(null);
const onSearch = (query: string): Promise<void> => {
return new Promise((resolve, reject) => {
searchDatabases({ name: query })
.then(databases => {
setDatabases(databases);
return resolve();
})
.catch(reject);
});
};
const onSelect = (database: Database | null) => {
setDatabase(database);
context.setFieldValue('databaseHostId', database?.id || null);
};
const getSelectedText = (database: Database | null): string | undefined => {
return database?.name;
};
return (
<SearchableSelect
id={'databaseId'}
name={'databaseId'}
label={'Database Host'}
placeholder={'Select a database host...'}
items={databases}
selected={database}
setSelected={setDatabase}
setItems={setDatabases}
onSearch={onSearch}
onSelect={onSelect}
getSelectedText={getSelectedText}
nullable
>
{databases?.map(d => (
<Option key={d.id} selectId={'databaseId'} id={d.id} item={d} active={d.id === database?.id}>
{d.name}
</Option>
))}
</SearchableSelect>
);
};

View file

@ -0,0 +1,56 @@
import { useFormikContext } from 'formik';
import { useState } from 'react';
import type { Location } from '@/api/admin/locations/getLocations';
import searchLocations from '@/api/admin/locations/searchLocations';
import SearchableSelect, { Option } from '@/components/elements/SearchableSelect';
export default ({ selected }: { selected: Location | null }) => {
const context = useFormikContext();
const [location, setLocation] = useState<Location | null>(selected);
const [locations, setLocations] = useState<Location[] | null>(null);
const onSearch = (query: string): Promise<void> => {
return new Promise((resolve, reject) => {
searchLocations({ short: query })
.then(locations => {
setLocations(locations);
return resolve();
})
.catch(reject);
});
};
const onSelect = (location: Location | null) => {
setLocation(location);
context.setFieldValue('locationId', location?.id || null);
};
const getSelectedText = (location: Location | null): string | undefined => {
return location?.short;
};
return (
<SearchableSelect
id={'locationId'}
name={'locationId'}
label={'Location'}
placeholder={'Select a location...'}
items={locations}
selected={location}
setSelected={setLocation}
setItems={setLocations}
onSearch={onSearch}
onSelect={onSelect}
getSelectedText={getSelectedText}
nullable
>
{locations?.map(d => (
<Option key={d.id} selectId={'locationId'} id={d.id} item={d} active={d.id === location?.id}>
{d.short}
</Option>
))}
</SearchableSelect>
);
};

View file

@ -0,0 +1,127 @@
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { Form, Formik } from 'formik';
import { useNavigate } from 'react-router-dom';
import tw from 'twin.macro';
import { number, object, string } from 'yup';
import type { Values } from '@/api/admin/nodes/createNode';
import createNode from '@/api/admin/nodes/createNode';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import NodeLimitContainer from '@/components/admin/nodes/NodeLimitContainer';
import NodeListenContainer from '@/components/admin/nodes/NodeListenContainer';
import NodeSettingsContainer from '@/components/admin/nodes/NodeSettingsContainer';
import Button from '@/components/elements/Button';
import FlashMessageRender from '@/components/FlashMessageRender';
import type { ApplicationStore } from '@/state';
type Values2 = Omit<Omit<Values, 'behindProxy'>, 'public'> & { behindProxy: string; public: string };
const initialValues: Values2 = {
name: '',
locationId: 0,
databaseHostId: null,
fqdn: '',
scheme: 'https',
behindProxy: 'false',
public: 'true',
daemonBase: '/var/lib/pterodactyl/volumes',
listenPortHTTP: 8080,
publicPortHTTP: 8080,
listenPortSFTP: 2022,
publicPortSFTP: 2022,
memory: 0,
memoryOverallocate: 0,
disk: 0,
diskOverallocate: 0,
};
export default () => {
const navigate = useNavigate();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const submit = (values2: Values2, { setSubmitting }: FormikHelpers<Values2>) => {
clearFlashes('node:create');
const values: Values = {
...values2,
behindProxy: values2.behindProxy === 'true',
public: values2.public === 'true',
};
createNode(values)
.then(node => navigate(`/admin/nodes/${node.id}`))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'node:create', error });
})
.then(() => setSubmitting(false));
};
return (
<AdminContentBlock title={'New Node'}>
<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 Node</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
Add a new node to the panel.
</p>
</div>
</div>
<FlashMessageRender byKey={'node:create'} />
<Formik
onSubmit={submit}
initialValues={initialValues}
validationSchema={object().shape({
name: string().required().max(191),
listenPortHTTP: number().required(),
publicPortHTTP: number().required(),
listenPortSFTP: number().required(),
publicPortSFTP: number().required(),
memory: number().required(),
memoryOverallocate: number().required(),
disk: number().required(),
diskOverallocate: number().required(),
})}
>
{({ isSubmitting, isValid }) => (
<Form>
<div css={tw`flex flex-col lg:flex-row`}>
<div css={tw`w-full lg:w-1/2 flex flex-col mr-0 lg:mr-2`}>
<NodeSettingsContainer />
</div>
<div css={tw`w-full lg:w-1/2 flex flex-col ml-0 lg:ml-2 mt-4 lg:mt-0`}>
<div css={tw`flex w-full`}>
<NodeListenContainer />
</div>
<div css={tw`flex w-full mt-4`}>
<NodeLimitContainer />
</div>
<div css={tw`rounded shadow-md bg-neutral-700 mt-4 py-2 pr-6`}>
<div css={tw`flex flex-row`}>
<Button type={'submit'} css={tw`ml-auto`} disabled={isSubmitting || !isValid}>
Create
</Button>
</div>
</div>
</div>
</div>
</Form>
)}
</Formik>
</AdminContentBlock>
);
};

View file

@ -0,0 +1,96 @@
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import tw from 'twin.macro';
import type { NodeInformation } from '@/api/admin/nodes/getNodeInformation';
import getNodeInformation from '@/api/admin/nodes/getNodeInformation';
import AdminBox from '@/components/admin/AdminBox';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { Context } from '@/components/admin/nodes/NodeRouter';
import type { ApplicationStore } from '@/state';
const Code = ({ className, children }: { className?: string; children: ReactNode }) => {
return (
<code css={tw`text-sm font-mono bg-neutral-900 rounded`} style={{ padding: '2px 6px' }} className={className}>
{children}
</code>
);
};
export default () => {
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const [loading, setLoading] = useState(true);
const [info, setInfo] = useState<NodeInformation | null>(null);
const node = Context.useStoreState(state => state.node);
if (node === undefined) {
return <></>;
}
useEffect(() => {
clearFlashes('node');
getNodeInformation(node.id)
.then(info => setInfo(info))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'node', error });
})
.then(() => setLoading(false));
}, []);
if (loading) {
return (
<AdminBox title={'Node Information'} css={tw`relative`}>
<SpinnerOverlay visible={loading} />
</AdminBox>
);
}
return (
<AdminBox title={'Node Information'}>
<table>
<tbody>
<tr>
<td css={tw`py-1 pr-6`}>Wings Version</td>
<td css={tw`py-1`}>
<Code css={tw`ml-auto`}>{info?.version}</Code>
</td>
</tr>
<tr>
<td css={tw`py-1 pr-6`}>Operating System</td>
<td css={tw`py-1`}>
<Code css={tw`ml-auto`}>{info?.system.type}</Code>
</td>
</tr>
<tr>
<td css={tw`py-1 pr-6`}>Architecture</td>
<td css={tw`py-1`}>
<Code css={tw`ml-auto`}>{info?.system.arch}</Code>
</td>
</tr>
<tr>
<td css={tw`py-1 pr-6`}>Kernel</td>
<td css={tw`py-1`}>
<Code css={tw`ml-auto`}>{info?.system.release}</Code>
</td>
</tr>
<tr>
<td css={tw`py-1 pr-6`}>CPU Threads</td>
<td css={tw`py-1`}>
<Code css={tw`ml-auto`}>{info?.system.cpus}</Code>
</td>
</tr>
</tbody>
</table>
{/* TODO: Description code-block with edit option */}
</AdminBox>
);
};

View file

@ -0,0 +1,27 @@
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import { useParams } from 'react-router-dom';
import tw from 'twin.macro';
import AdminBox from '@/components/admin/AdminBox';
import AllocationTable from '@/components/admin/nodes/allocations/AllocationTable';
import CreateAllocationForm from '@/components/admin/nodes/allocations/CreateAllocationForm';
export default () => {
const params = useParams<'id'>();
return (
<>
<div css={tw`w-full grid grid-cols-12 gap-x-8`}>
<div css={tw`w-full flex col-span-8`}>
<AllocationTable nodeId={Number(params.id)} />
</div>
<div css={tw`w-full flex col-span-4`}>
<AdminBox icon={faNetworkWired} title={'Allocations'} css={tw`h-auto w-full`}>
<CreateAllocationForm nodeId={Number(params.id)} />
</AdminBox>
</div>
</div>
</>
);
};

View file

@ -0,0 +1,70 @@
import { faCode, faDragon } from '@fortawesome/free-solid-svg-icons';
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import { useEffect, useState } from 'react';
import tw from 'twin.macro';
import getNodeConfiguration from '@/api/admin/nodes/getNodeConfiguration';
import AdminBox from '@/components/admin/AdminBox';
import { Context } from '@/components/admin/nodes/NodeRouter';
import CopyOnClick from '@/components/elements/CopyOnClick';
import type { ApplicationStore } from '@/state';
export default () => {
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const [configuration, setConfiguration] = useState('');
const node = Context.useStoreState(state => state.node);
if (node === undefined) {
return <></>;
}
useEffect(() => {
clearFlashes('node');
getNodeConfiguration(node.id)
.then(configuration => setConfiguration(configuration))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'node', error });
});
}, []);
return (
<>
<AdminBox title={'Configuration'} icon={faCode} css={tw`mb-4`}>
<div css={tw`relative`}>
<div css={tw`absolute top-0 right-0`}>
<CopyOnClick text={configuration} showInNotification={false}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
css={tw`h-5 w-5 text-neutral-500 hover:text-neutral-400 cursor-pointer mt-1 mr-1`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
</CopyOnClick>
</div>
<pre css={tw`text-sm rounded font-mono bg-neutral-900 shadow-md px-4 py-3 overflow-x-auto`}>
{configuration}
</pre>
</div>
</AdminBox>
<AdminBox title={'Auto Deploy'} icon={faDragon}>
Never&trade;
</AdminBox>
</>
);
};

View file

@ -0,0 +1,73 @@
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import { useState } from 'react';
import tw from 'twin.macro';
import deleteNode from '@/api/admin/nodes/deleteNode';
import Button from '@/components/elements/Button';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import type { ApplicationStore } from '@/state';
interface Props {
nodeId: number;
onDeleted: () => void;
}
export default ({ nodeId, onDeleted }: Props) => {
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const onDelete = () => {
setLoading(true);
clearFlashes('node');
deleteNode(nodeId)
.then(() => {
setLoading(false);
onDeleted();
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'node', error });
setLoading(false);
setVisible(false);
});
};
return (
<>
<ConfirmationModal
visible={visible}
title={'Delete node?'}
buttonText={'Yes, delete node'}
onConfirmed={onDelete}
showSpinnerOverlay={loading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this node?
</ConfirmationModal>
<Button type={'button'} size={'xsmall'} color={'red'} onClick={() => setVisible(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
css={tw`h-5 w-5`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</Button>
</>
);
};

View file

@ -0,0 +1,134 @@
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { Form, Formik } from 'formik';
import { useNavigate } from 'react-router-dom';
import tw from 'twin.macro';
import { number, object, string } from 'yup';
import updateNode from '@/api/admin/nodes/updateNode';
import NodeDeleteButton from '@/components/admin/nodes/NodeDeleteButton';
import NodeLimitContainer from '@/components/admin/nodes/NodeLimitContainer';
import NodeListenContainer from '@/components/admin/nodes/NodeListenContainer';
import { Context } from '@/components/admin/nodes/NodeRouter';
import NodeSettingsContainer from '@/components/admin/nodes/NodeSettingsContainer';
import Button from '@/components/elements/Button';
import type { ApplicationStore } from '@/state';
interface Values {
name: string;
locationId: number;
databaseHostId: number | null;
fqdn: string;
scheme: string;
behindProxy: string; // Yes, this is technically a boolean.
public: string; // Yes, this is technically a boolean.
daemonBase: string; // This value cannot be updated once a node has been created.
memory: number;
memoryOverallocate: number;
disk: number;
diskOverallocate: number;
listenPortHTTP: number;
publicPortHTTP: number;
listenPortSFTP: number;
publicPortSFTP: number;
}
export default () => {
const navigate = useNavigate();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const node = Context.useStoreState(state => state.node);
const setNode = Context.useStoreActions(actions => actions.setNode);
if (node === undefined) {
return <></>;
}
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('node');
const v = { ...values, behindProxy: values.behindProxy === 'true', public: values.public === 'true' };
updateNode(node.id, v)
.then(() => setNode({ ...node, ...v }))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'node', error });
})
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={{
name: node.name,
locationId: node.locationId,
databaseHostId: node.databaseHostId,
fqdn: node.fqdn,
scheme: node.scheme,
behindProxy: node.behindProxy ? 'true' : 'false',
public: node.public ? 'true' : 'false',
daemonBase: node.daemonBase,
listenPortHTTP: node.listenPortHTTP,
publicPortHTTP: node.publicPortHTTP,
listenPortSFTP: node.listenPortSFTP,
publicPortSFTP: node.publicPortSFTP,
memory: node.memory,
memoryOverallocate: node.memoryOverallocate,
disk: node.disk,
diskOverallocate: node.diskOverallocate,
}}
validationSchema={object().shape({
name: string().required().max(191),
listenPortHTTP: number().required(),
publicPortHTTP: number().required(),
listenPortSFTP: number().required(),
publicPortSFTP: number().required(),
memory: number().required(),
memoryOverallocate: number().required(),
disk: number().required(),
diskOverallocate: number().required(),
})}
>
{({ isSubmitting, isValid }) => (
<Form>
<div css={tw`flex flex-col lg:flex-row`}>
<div css={tw`w-full lg:w-1/2 flex flex-col mr-0 lg:mr-2`}>
<NodeSettingsContainer node={node} />
</div>
<div css={tw`w-full lg:w-1/2 flex flex-col ml-0 lg:ml-2 mt-4 lg:mt-0`}>
<div css={tw`flex w-full`}>
<NodeListenContainer />
</div>
<div css={tw`flex w-full mt-4`}>
<NodeLimitContainer />
</div>
<div css={tw`rounded shadow-md bg-neutral-700 mt-4 py-2 px-6`}>
<div css={tw`flex flex-row`}>
<NodeDeleteButton nodeId={node?.id} onDeleted={() => navigate('/admin/nodes')} />
<Button type={'submit'} css={tw`ml-auto`} disabled={isSubmitting || !isValid}>
Save Changes
</Button>
</div>
</div>
</div>
</div>
</Form>
)}
</Formik>
);
};

View file

@ -0,0 +1,47 @@
import { faMicrochip } 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 SpinnerOverlay from '@/components/elements/SpinnerOverlay';
export default () => {
const { isSubmitting } = useFormikContext();
return (
<AdminBox icon={faMicrochip} title={'Limits'} css={tw`w-full relative`}>
<SpinnerOverlay visible={isSubmitting} />
<div css={tw`md:w-full md:flex md:flex-row mb-6`}>
<div css={tw`md:w-full md:flex md:flex-col md:mr-4 mb-6 md:mb-0`}>
<Field id={'memory'} name={'memory'} label={'Memory'} type={'number'} />
</div>
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mb-6 md:mb-0`}>
<Field
id={'memoryOverallocate'}
name={'memoryOverallocate'}
label={'Memory Overallocate'}
type={'number'}
/>
</div>
</div>
<div css={tw`md:w-full md:flex md:flex-row mb-6`}>
<div css={tw`md:w-full md:flex md:flex-col md:mr-4 mb-6 md:mb-0`}>
<Field id={'disk'} name={'disk'} label={'Disk'} type={'number'} />
</div>
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mb-6 md:mb-0`}>
<Field
id={'diskOverallocate'}
name={'diskOverallocate'}
label={'Disk Overallocate'}
type={'number'}
/>
</div>
</div>
</AdminBox>
);
};

View file

@ -0,0 +1,37 @@
import { faNetworkWired } 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 SpinnerOverlay from '@/components/elements/SpinnerOverlay';
export default () => {
const { isSubmitting } = useFormikContext();
return (
<AdminBox icon={faNetworkWired} title={'Listen'} css={tw`w-full relative`}>
<SpinnerOverlay visible={isSubmitting} />
<div css={tw`mb-6 md:w-full md:flex md:flex-row`}>
<div css={tw`mb-6 md:w-full md:flex md:flex-col md:mr-4 md:mb-0`}>
<Field id={'listenPortHTTP'} name={'listenPortHTTP'} label={'HTTP Listen Port'} type={'number'} />
</div>
<div css={tw`mb-6 md:w-full md:flex md:flex-col md:ml-4 md:mb-0`}>
<Field id={'publicPortHTTP'} name={'publicPortHTTP'} label={'HTTP Public Port'} type={'number'} />
</div>
</div>
<div css={tw`mb-6 md:w-full md:flex md:flex-row`}>
<div css={tw`mb-6 md:w-full md:flex md:flex-col md:mr-4 md:mb-0`}>
<Field id={'listenPortSFTP'} name={'listenPortSFTP'} label={'SFTP Listen Port'} type={'number'} />
</div>
<div css={tw`mb-6 md:w-full md:flex md:flex-col md:ml-4 md:mb-0`}>
<Field id={'publicPortSFTP'} name={'publicPortSFTP'} label={'SFTP Public Port'} type={'number'} />
</div>
</div>
</AdminBox>
);
};

View file

@ -0,0 +1,146 @@
import type { Action, Actions } from 'easy-peasy';
import { action, createContextStore, useStoreActions } from 'easy-peasy';
import { useEffect, useState } from 'react';
import { Route, Routes, useParams } from 'react-router-dom';
import tw from 'twin.macro';
import type { Node } from '@/api/admin/nodes/getNodes';
import getNode from '@/api/admin/nodes/getNode';
import FlashMessageRender from '@/components/FlashMessageRender';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import NodeEditContainer from '@/components/admin/nodes/NodeEditContainer';
import Spinner from '@/components/elements/Spinner';
import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation';
import NodeAboutContainer from '@/components/admin/nodes/NodeAboutContainer';
import NodeConfigurationContainer from '@/components/admin/nodes/NodeConfigurationContainer';
import NodeAllocationContainer from '@/components/admin/nodes/NodeAllocationContainer';
import NodeServers from '@/components/admin/nodes/NodeServers';
import type { ApplicationStore } from '@/state';
interface ctx {
node: Node | undefined;
setNode: Action<ctx, Node | undefined>;
}
export const Context = createContextStore<ctx>({
node: undefined,
setNode: action((state, payload) => {
state.node = payload;
}),
});
const NodeRouter = () => {
const params = useParams<'id'>();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const [loading, setLoading] = useState(true);
const node = Context.useStoreState(state => state.node);
const setNode = Context.useStoreActions(actions => actions.setNode);
useEffect(() => {
clearFlashes('node');
getNode(Number(params.id), ['database_host', 'location'])
.then(node => setNode(node))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'node', error });
})
.then(() => setLoading(false));
}, []);
if (loading || node === undefined) {
return (
<AdminContentBlock>
<FlashMessageRender byKey={'node'} css={tw`mb-4`} />
<div css={tw`w-full flex flex-col items-center justify-center`} style={{ height: '24rem' }}>
<Spinner size={'base'} />
</div>
</AdminContentBlock>
);
}
return (
<AdminContentBlock title={'Node - ' + node.name}>
<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`}>{node.name}</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
{node.uuid}
</p>
</div>
</div>
<FlashMessageRender byKey={'node'} css={tw`mb-4`} />
<SubNavigation>
<SubNavigationLink to={`/admin/nodes/${node.id}`} name={'About'}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
clipRule="evenodd"
fillRule="evenodd"
d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 110 2h-3a1 1 0 01-1-1v-2a1 1 0 00-1-1H9a1 1 0 00-1 1v2a1 1 0 01-1 1H4a1 1 0 110-2V4zm3 1h2v2H7V5zm2 4H7v2h2V9zm2-4h2v2h-2V5zm2 4h-2v2h2V9z"
/>
</svg>
</SubNavigationLink>
<SubNavigationLink to={`/admin/nodes/${node.id}/settings`} name={'Settings'}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
clipRule="evenodd"
fillRule="evenodd"
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
/>
</svg>
</SubNavigationLink>
<SubNavigationLink to={`/admin/nodes/${node.id}/configuration`} name={'Configuration'}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
clipRule="evenodd"
fillRule="evenodd"
d="M12.316 3.051a1 1 0 01.633 1.265l-4 12a1 1 0 11-1.898-.632l4-12a1 1 0 011.265-.633zM5.707 6.293a1 1 0 010 1.414L3.414 10l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm8.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 010-1.414z"
/>
</svg>
</SubNavigationLink>
<SubNavigationLink to={`/admin/nodes/${node.id}/allocations`} name={'Allocations'}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M5.5 16a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 1113.5 16h-8z" />
</svg>
</SubNavigationLink>
<SubNavigationLink to={`/admin/nodes/${node.id}/servers`} name={'Servers'}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
clipRule="evenodd"
fillRule="evenodd"
d="M2 5a2 2 0 012-2h12a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm3.293 1.293a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 01-1.414-1.414L7.586 10 5.293 7.707a1 1 0 010-1.414zM11 12a1 1 0 100 2h3a1 1 0 100-2h-3z"
/>
</svg>
</SubNavigationLink>
</SubNavigation>
<Routes>
<Route path="" element={<NodeAboutContainer />} />
<Route path="settings" element={<NodeEditContainer />} />
<Route path="configuration" element={<NodeConfigurationContainer />} />
<Route path="allocations" element={<NodeAllocationContainer />} />
<Route path="servers" element={<NodeServers />} />
</Routes>
</AdminContentBlock>
);
};
export default () => {
return (
<Context.Provider>
<NodeRouter />
</Context.Provider>
);
};

View file

@ -0,0 +1,10 @@
import { Context } from '@/components/admin/nodes/NodeRouter';
import ServersTable from '@/components/admin/servers/ServersTable';
function NodeServers() {
const node = Context.useStoreState(state => state.node);
return <ServersTable filters={{ node_id: node?.id?.toString() }} />;
}
export default NodeServers;

View file

@ -0,0 +1,95 @@
import { faDatabase } from '@fortawesome/free-solid-svg-icons';
import { Field as FormikField, useFormikContext } from 'formik';
import tw from 'twin.macro';
import type { Node } from '@/api/admin/nodes/getNodes';
import AdminBox from '@/components/admin/AdminBox';
import DatabaseSelect from '@/components/admin/nodes/DatabaseSelect';
import LocationSelect from '@/components/admin/nodes/LocationSelect';
import Label from '@/components/elements/Label';
import Field from '@/components/elements/Field';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
export default function NodeSettingsContainer({ node }: { node?: Node }) {
const { isSubmitting } = useFormikContext();
return (
<AdminBox icon={faDatabase} title={'Settings'} css={tw`w-full relative`}>
<SpinnerOverlay visible={isSubmitting} />
<div css={tw`mb-6`}>
<Field id={'name'} name={'name'} label={'Name'} type={'text'} />
</div>
<div css={tw`mb-6`}>
<LocationSelect selected={node?.relations.location || null} />
</div>
<div css={tw`mb-6`}>
<DatabaseSelect selected={node?.relations.databaseHost || null} />
</div>
<div css={tw`mb-6`}>
<Field id={'fqdn'} name={'fqdn'} label={'FQDN'} type={'text'} />
</div>
<div css={tw`mb-6`}>
<Field
id={'daemonBase'}
name={'daemonBase'}
label={'Data Directory'}
type={'text'}
disabled={node !== undefined}
/>
</div>
<div css={tw`mt-6`}>
<Label htmlFor={'scheme'}>SSL</Label>
<div>
<label css={tw`inline-flex items-center mr-2`}>
<FormikField name={'scheme'} type={'radio'} value={'https'} />
<span css={tw`text-neutral-300 ml-2`}>Enabled</span>
</label>
<label css={tw`inline-flex items-center ml-2`}>
<FormikField name={'scheme'} type={'radio'} value={'http'} />
<span css={tw`text-neutral-300 ml-2`}>Disabled</span>
</label>
</div>
</div>
<div css={tw`mt-6`}>
<Label htmlFor={'behindProxy'}>Behind Proxy</Label>
<div>
<label css={tw`inline-flex items-center mr-2`}>
<FormikField name={'behindProxy'} type={'radio'} value={'false'} />
<span css={tw`text-neutral-300 ml-2`}>No</span>
</label>
<label css={tw`inline-flex items-center ml-2`}>
<FormikField name={'behindProxy'} type={'radio'} value={'true'} />
<span css={tw`text-neutral-300 ml-2`}>Yes</span>
</label>
</div>
</div>
<div css={tw`mt-6`}>
<Label htmlFor={'public'}>Automatic Allocation</Label>
<div>
<label css={tw`inline-flex items-center mr-2`}>
<FormikField name={'public'} type={'radio'} value={'false'} />
<span css={tw`text-neutral-300 ml-2`}>Disabled</span>
</label>
<label css={tw`inline-flex items-center ml-2`}>
<FormikField name={'public'} type={'radio'} value={'true'} />
<span css={tw`text-neutral-300 ml-2`}>Enabled</span>
</label>
</div>
</div>
</AdminBox>
);
}

View file

@ -0,0 +1,271 @@
import type { ChangeEvent } from 'react';
import { useContext, useEffect } from 'react';
import type { Filters } from '@/api/admin/servers/getServers';
import getNodes, { Context as NodesContext } from '@/api/admin/nodes/getNodes';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import { AdminContext } from '@/state/admin';
import { NavLink } from 'react-router-dom';
import tw from 'twin.macro';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import AdminCheckbox from '@/components/admin/AdminCheckbox';
import AdminTable, {
TableBody,
TableHead,
TableHeader,
TableRow,
Pagination,
Loading,
NoItems,
ContentWrapper,
useTableHooks,
} from '@/components/admin/AdminTable';
import Button from '@/components/elements/Button';
import CopyOnClick from '@/components/elements/CopyOnClick';
import { bytesToString, mbToBytes } from '@/lib/formatters';
const RowCheckbox = ({ id }: { id: number }) => {
const isChecked = AdminContext.useStoreState(state => state.nodes.selectedNodes.indexOf(id) >= 0);
const appendSelectedNode = AdminContext.useStoreActions(actions => actions.nodes.appendSelectedNode);
const removeSelectedNode = AdminContext.useStoreActions(actions => actions.nodes.removeSelectedNode);
return (
<AdminCheckbox
name={id.toString()}
checked={isChecked}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
appendSelectedNode(id);
} else {
removeSelectedNode(id);
}
}}
/>
);
};
const NodesContainer = () => {
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(NodesContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: nodes, error, isValidating } = getNodes(['location']);
useEffect(() => {
if (!error) {
clearFlashes('nodes');
return;
}
clearAndAddHttpError({ key: 'nodes', error });
}, [error]);
const length = nodes?.items?.length || 0;
const setSelectedNodes = AdminContext.useStoreActions(actions => actions.nodes.setSelectedNodes);
const selectedNodesLength = AdminContext.useStoreState(state => state.nodes.selectedNodes.length);
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
setSelectedNodes(e.currentTarget.checked ? nodes?.items?.map(node => node.id) || [] : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise(resolve => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ name: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedNodes([]);
}, [page]);
return (
<AdminContentBlock title={'Nodes'}>
<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`}>Nodes</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
All nodes available on the system.
</p>
</div>
<div css={tw`flex ml-auto pl-4`}>
<NavLink to={`/admin/nodes/new`}>
<Button type={'button'} size={'large'} css={tw`h-10 px-4 py-0 whitespace-nowrap`}>
New Node
</Button>
</NavLink>
</div>
</div>
<FlashMessageRender byKey={'nodes'} css={tw`mb-4`} />
<AdminTable>
<ContentWrapper
checked={selectedNodesLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={nodes} onPageSelect={setPage}>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<TableHead>
<TableHeader
name={'ID'}
direction={sort === 'id' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('id')}
/>
<TableHeader
name={'Name'}
direction={sort === 'name' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('name')}
/>
<TableHeader
name={'Location'}
direction={sort === 'location_id' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('location_id')}
/>
<TableHeader
name={'FQDN'}
direction={sort === 'fqdn' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('fqdn')}
/>
<TableHeader
name={'Total Memory'}
direction={sort === 'memory' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('memory')}
/>
<TableHeader
name={'Total Disk'}
direction={sort === 'disk' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('disk')}
/>
<TableHeader />
<TableHeader />
</TableHead>
<TableBody>
{nodes !== undefined &&
!error &&
!isValidating &&
length > 0 &&
nodes.items.map(node => (
<TableRow key={node.id}>
<td css={tw`pl-6`}>
<RowCheckbox id={node.id} />
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={node.id.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{node.id}
</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink
to={`/admin/nodes/${node.id}`}
css={tw`text-primary-400 hover:text-primary-300`}
>
{node.name}
</NavLink>
</td>
{/* TODO: Have permission check for displaying location information. */}
<td css={tw`px-6 text-sm text-left whitespace-nowrap`}>
<NavLink
to={`/admin/locations/${node.relations.location?.id}`}
css={tw`text-primary-400 hover:text-primary-300`}
>
<div css={tw`text-sm text-neutral-200`}>
{node.relations.location?.short}
</div>
<div css={tw`text-sm text-neutral-400`}>
{node.relations.location?.long}
</div>
</NavLink>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={node.fqdn}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{node.fqdn}
</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
{bytesToString(mbToBytes(node.memory))}
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
{bytesToString(mbToBytes(node.disk))}
</td>
<td css={tw`px-6 whitespace-nowrap`}>
{node.scheme === 'https' ? (
<span
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-green-100 text-green-800`}
>
Secure
</span>
) : (
<span
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-red-200 text-red-800`}
>
Non-Secure
</span>
)}
</td>
<td css={tw`px-6 whitespace-nowrap`}>
{/* TODO: Change color based off of online/offline status */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
css={[
tw`h-5 w-5`,
node.scheme === 'https'
? tw`text-green-200`
: tw`text-red-300`,
]}
>
<path
clipRule="evenodd"
fillRule="evenodd"
d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"
/>
</svg>
</td>
</TableRow>
))}
</TableBody>
</table>
{nodes === undefined || (error && isValidating) ? (
<Loading />
) : length < 1 ? (
<NoItems />
) : null}
</div>
</Pagination>
</ContentWrapper>
</AdminTable>
</AdminContentBlock>
);
};
export default () => {
const hooks = useTableHooks<Filters>();
return (
<NodesContext.Provider value={hooks}>
<NodesContainer />
</NodesContext.Provider>
);
};

View file

@ -0,0 +1,216 @@
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/nodes/allocations/getAllocations';
import getAllocations, { Context as AllocationsContext } from '@/api/admin/nodes/allocations/getAllocations';
import AdminCheckbox from '@/components/admin/AdminCheckbox';
import AdminTable, {
ContentWrapper,
Loading,
NoItems,
Pagination,
TableBody,
TableHead,
TableHeader,
useTableHooks,
} from '@/components/admin/AdminTable';
import DeleteAllocationButton from '@/components/admin/nodes/allocations/DeleteAllocationButton';
import CopyOnClick from '@/components/elements/CopyOnClick';
import useFlash from '@/plugins/useFlash';
import { AdminContext } from '@/state/admin';
function RowCheckbox({ id }: { id: number }) {
const isChecked = AdminContext.useStoreState(state => state.allocations.selectedAllocations.indexOf(id) >= 0);
const appendSelectedAllocation = AdminContext.useStoreActions(
actions => actions.allocations.appendSelectedAllocation,
);
const removeSelectedAllocation = AdminContext.useStoreActions(
actions => actions.allocations.removeSelectedAllocation,
);
return (
<AdminCheckbox
name={id.toString()}
checked={isChecked}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
appendSelectedAllocation(id);
} else {
removeSelectedAllocation(id);
}
}}
/>
);
}
interface Props {
nodeId: number;
filters?: Filters;
}
function AllocationsTable({ nodeId, filters }: Props) {
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(AllocationsContext);
const { data: allocations, error, isValidating, mutate } = getAllocations(nodeId, ['server']);
const length = allocations?.items?.length || 0;
const setSelectedAllocations = AdminContext.useStoreActions(actions => actions.allocations.setSelectedAllocations);
const selectedAllocationLength = AdminContext.useStoreState(state => state.allocations.selectedAllocations.length);
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
setSelectedAllocations(
e.currentTarget.checked ? allocations?.items?.map?.(allocation => allocation.id) || [] : [],
);
};
const onSearch = (query: string): Promise<void> => {
return new Promise(resolve => {
if (query.length < 2) {
setFilters(filters || null);
} else {
setFilters({ ...filters, ip: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedAllocations([]);
}, [page]);
useEffect(() => {
if (!error) {
clearFlashes('allocations');
return;
}
clearAndAddHttpError({ key: 'allocations', error });
}, [error]);
return (
<AdminTable>
<ContentWrapper
checked={selectedAllocationLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={allocations} onPageSelect={setPage}>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<TableHead>
<TableHeader
name={'IP Address'}
direction={sort === 'ip' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('ip')}
/>
<TableHeader name={'Alias'} />
<TableHeader
name={'Port'}
direction={sort === 'port' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('port')}
/>
<TableHeader name={'Assigned To'} />
<TableHeader />
</TableHead>
<TableBody>
{allocations !== undefined &&
!error &&
!isValidating &&
length > 0 &&
allocations.items.map(allocation => (
<tr key={allocation.id} css={tw`h-10 hover:bg-neutral-600`}>
<td css={tw`pl-6`}>
<RowCheckbox id={allocation.id} />
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={allocation.ip}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{allocation.ip}
</code>
</CopyOnClick>
</td>
{allocation.alias !== null ? (
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={allocation.alias}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{allocation.alias}
</code>
</CopyOnClick>
</td>
) : (
<td />
)}
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={allocation.port}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{allocation.port}
</code>
</CopyOnClick>
</td>
{allocation.relations.server !== undefined ? (
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink
to={`/admin/servers/${allocation.serverId}`}
css={tw`text-primary-400 hover:text-primary-300`}
>
{allocation.relations.server.name}
</NavLink>
</td>
) : (
<td />
)}
<td>
<DeleteAllocationButton
nodeId={nodeId}
allocationId={allocation.id}
onDeleted={async () => {
await mutate(allocations => ({
pagination: allocations!.pagination,
items: allocations!.items.filter(
a => a.id === allocation.id,
),
}));
// Go back a page if no more items will exist on the current page.
if (allocations?.items.length - (1 % 10) === 0) {
setPage(p => p - 1);
}
}}
/>
</td>
</tr>
))}
</TableBody>
</table>
{allocations === undefined || (error && isValidating) ? (
<Loading />
) : length < 1 ? (
<NoItems />
) : null}
</div>
</Pagination>
</ContentWrapper>
</AdminTable>
);
}
export default (props: Props) => {
const hooks = useTableHooks<Filters>(props.filters);
return (
<AllocationsContext.Provider value={hooks}>
<AllocationsTable {...props} />
</AllocationsContext.Provider>
);
};

View file

@ -0,0 +1,118 @@
import type { FormikHelpers } from 'formik';
import { Form, Formik } from 'formik';
import { useEffect, useState } from 'react';
import tw from 'twin.macro';
import { array, number, object, string } from 'yup';
import createAllocation from '@/api/admin/nodes/allocations/createAllocation';
import getAllocations from '@/api/admin/nodes/getAllocations';
import getAllocations2 from '@/api/admin/nodes/allocations/getAllocations';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import type { Option } from '@/components/elements/SelectField';
import SelectField from '@/components/elements/SelectField';
interface Values {
ips: string[];
ports: number[];
alias: string;
}
const distinct = (value: any, index: any, self: any) => {
return self.indexOf(value) === index;
};
function CreateAllocationForm({ nodeId }: { nodeId: number }) {
const [ips, setIPs] = useState<Option[]>([]);
const [ports] = useState<Option[]>([]);
const { mutate } = getAllocations2(nodeId, ['server']);
useEffect(() => {
getAllocations(nodeId).then(allocations => {
setIPs(
allocations
.map(a => a.ip)
.filter(distinct)
.map(ip => {
return { value: ip, label: ip };
}),
);
});
}, [nodeId]);
const isValidIP = (inputValue: string): boolean => {
// TODO: Better way of checking for a valid ip (and CIDR)
return inputValue.match(/^([0-9a-f.:/]+)$/) !== null;
};
const isValidPort = (inputValue: string): boolean => {
// TODO: Better way of checking for a valid port (and port range)
return inputValue.match(/^([0-9-]+)$/) !== null;
};
const submit = ({ ips, ports, alias }: Values, { setSubmitting }: FormikHelpers<Values>) => {
setSubmitting(false);
ips.forEach(async ip => {
const allocations = await createAllocation(nodeId, { ip, ports, alias }, ['server']);
await mutate(data => ({ ...data!, items: { ...data!.items!, ...allocations } }));
});
};
return (
<Formik
onSubmit={submit}
initialValues={{
ips: [] as string[],
ports: [] as number[],
alias: '',
}}
validationSchema={object().shape({
ips: array(string()).min(1, 'You must select at least one ip address.'),
ports: array(number()).min(1, 'You must select at least one port.'),
})}
>
{({ isSubmitting, isValid }) => (
<Form>
<SelectField
id={'ips'}
name={'ips'}
label={'IPs and CIDRs'}
options={ips}
isValidNewOption={isValidIP}
isMulti
isSearchable
isCreatable
css={tw`mb-6`}
/>
<SelectField
id={'ports'}
name={'ports'}
label={'Ports'}
options={ports}
isValidNewOption={isValidPort}
isMulti
isSearchable
isCreatable
/>
<div css={tw`mt-6`}>
<Field id={'alias'} name={'alias'} label={'Alias'} type={'text'} />
</div>
<div css={tw`w-full flex flex-row items-center mt-6`}>
<div css={tw`flex ml-auto`}>
<Button type={'submit'} disabled={isSubmitting || !isValid}>
Create Allocations
</Button>
</div>
</div>
</Form>
)}
</Formik>
);
}
export default CreateAllocationForm;

View file

@ -0,0 +1,77 @@
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import { useState } from 'react';
import tw from 'twin.macro';
import deleteAllocation from '@/api/admin/nodes/allocations/deleteAllocation';
import Button from '@/components/elements/Button';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import type { ApplicationStore } from '@/state';
interface Props {
nodeId: number;
allocationId: number;
onDeleted?: () => void;
}
export default ({ nodeId, allocationId, onDeleted }: Props) => {
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const onDelete = () => {
setLoading(true);
clearFlashes('allocation');
deleteAllocation(nodeId, allocationId)
.then(() => {
setLoading(false);
setVisible(false);
if (onDeleted !== undefined) {
onDeleted();
}
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'allocation', error });
setLoading(false);
setVisible(false);
});
};
return (
<>
<ConfirmationModal
visible={visible}
title={'Delete allocation?'}
buttonText={'Yes, delete allocation'}
onConfirmed={onDelete}
showSpinnerOverlay={loading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this allocation?
</ConfirmationModal>
<Button type={'button'} size={'inline'} color={'red'} onClick={() => setVisible(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
css={tw`h-5 w-5`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</Button>
</>
);
};