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
56
resources/scripts/components/admin/nodes/DatabaseSelect.tsx
Normal file
56
resources/scripts/components/admin/nodes/DatabaseSelect.tsx
Normal 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>
|
||||
);
|
||||
};
|
56
resources/scripts/components/admin/nodes/LocationSelect.tsx
Normal file
56
resources/scripts/components/admin/nodes/LocationSelect.tsx
Normal 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>
|
||||
);
|
||||
};
|
127
resources/scripts/components/admin/nodes/NewNodeContainer.tsx
Normal file
127
resources/scripts/components/admin/nodes/NewNodeContainer.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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™
|
||||
</AdminBox>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
134
resources/scripts/components/admin/nodes/NodeEditContainer.tsx
Normal file
134
resources/scripts/components/admin/nodes/NodeEditContainer.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
146
resources/scripts/components/admin/nodes/NodeRouter.tsx
Normal file
146
resources/scripts/components/admin/nodes/NodeRouter.tsx
Normal 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>
|
||||
);
|
||||
};
|
10
resources/scripts/components/admin/nodes/NodeServers.tsx
Normal file
10
resources/scripts/components/admin/nodes/NodeServers.tsx
Normal 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;
|
|
@ -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>
|
||||
);
|
||||
}
|
271
resources/scripts/components/admin/nodes/NodesContainer.tsx
Normal file
271
resources/scripts/components/admin/nodes/NodesContainer.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue