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,36 @@
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { ReactNode } from 'react';
import tw from 'twin.macro';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
interface Props {
icon?: IconProp;
isLoading?: boolean;
title: string | ReactNode;
className?: string;
noPadding?: boolean;
children: ReactNode;
button?: ReactNode;
}
const AdminBox = ({ icon, title, className, isLoading, children, button, noPadding }: Props) => (
<div css={tw`relative rounded shadow-md bg-neutral-700`} className={className}>
<SpinnerOverlay visible={isLoading || false} />
<div css={tw`flex flex-row bg-neutral-900 rounded-t px-4 xl:px-5 py-3 border-b border-black`}>
{typeof title === 'string' ? (
<p css={tw`text-sm uppercase`}>
{icon && <FontAwesomeIcon icon={icon} css={tw`mr-2 text-neutral-300`} />}
{title}
</p>
) : (
title
)}
{button}
</div>
<div css={[!noPadding && tw`px-4 xl:px-5 py-5`]}>{children}</div>
</div>
);
export default AdminBox;

View file

@ -0,0 +1,36 @@
import type { ChangeEvent } from 'react';
import tw, { styled } from 'twin.macro';
import Input from '@/components/elements/Input';
export const TableCheckbox = styled(Input)`
&& {
${tw`border-neutral-500 bg-transparent`};
&:not(:checked) {
${tw`hover:border-neutral-300`};
}
}
`;
export default ({
name,
checked,
onChange,
}: {
name: string;
checked: boolean;
onChange(e: ChangeEvent<HTMLInputElement>): void;
}) => {
return (
<div css={tw`flex items-center`}>
<TableCheckbox
type={'checkbox'}
name={'selectedItems'}
value={name}
checked={checked}
onChange={onChange}
/>
</div>
);
};

View file

@ -0,0 +1,42 @@
import type { ReactNode } from 'react';
import { useEffect } from 'react';
// import { CSSTransition } from 'react-transition-group';
import tw from 'twin.macro';
import FlashMessageRender from '@/components/FlashMessageRender';
const AdminContentBlock: React.FC<{
children: ReactNode;
title?: string;
showFlashKey?: string;
className?: string;
}> = ({ children, title, showFlashKey }) => {
useEffect(() => {
if (!title) {
return;
}
document.title = `Admin | ${title}`;
}, [title]);
return (
// <CSSTransition timeout={150} classNames={'fade'} appear in>
<>
{showFlashKey && <FlashMessageRender byKey={showFlashKey} css={tw`mb-4`} />}
{children}
{/* <p css={tw`text-center text-neutral-500 text-xs mt-4`}>
&copy; 2015 - 2021&nbsp;
<a
rel={'noopener nofollow noreferrer'}
href={'https://pterodactyl.io'}
target={'_blank'}
css={tw`no-underline text-neutral-500 hover:text-neutral-300`}
>
Pterodactyl Software
</a>
</p> */}
</>
// </CSSTransition>
);
};
export default AdminContentBlock;

View file

@ -0,0 +1,348 @@
import { debounce } from 'debounce';
import type { ChangeEvent, MouseEvent, ReactNode } from 'react';
import { useCallback, useState } from 'react';
import tw, { styled } from 'twin.macro';
import type { ListContext as TableHooks } from '@/api/admin';
import type { PaginatedResult, PaginationDataSet } from '@/api/http';
import { TableCheckbox } from '@/components/admin/AdminCheckbox';
import Input from '@/components/elements/Input';
import InputSpinner from '@/components/elements/InputSpinner';
import Spinner from '@/components/elements/Spinner';
export function useTableHooks<T>(initialState?: T | (() => T)): TableHooks<T> {
const [page, setPage] = useState<number>(1);
const [filters, setFilters] = useState<T | null>(initialState || null);
const [sort, setSortState] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<boolean>(false);
const setSort = (newSort: string | null) => {
if (sort === newSort) {
setSortDirection(!sortDirection);
} else {
setSortState(newSort);
setSortDirection(false);
}
};
return { page, setPage, filters, setFilters, sort, setSort, sortDirection, setSortDirection };
}
export const TableHeader = ({
name,
onClick,
direction,
}: {
name?: string;
onClick?: (e: MouseEvent) => void;
direction?: number | null;
}) => {
if (!name) {
return <th css={tw`px-6 py-2`} />;
}
return (
<th css={tw`px-6 py-2`} onClick={onClick}>
<span css={tw`flex flex-row items-center cursor-pointer`}>
<span
css={tw`text-xs font-medium tracking-wider uppercase text-neutral-300 whitespace-nowrap select-none`}
>
{name}
</span>
{direction !== undefined ? (
<div css={tw`ml-1`}>
<svg fill="none" viewBox="0 0 20 20" css={tw`w-4 h-4 text-neutral-400`}>
{direction === null || direction === 1 ? (
<path
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
d="M13 7L10 4L7 7"
/>
) : null}
{direction === null || direction === 2 ? (
<path
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
d="M7 13L10 16L13 13"
/>
) : null}
</svg>
</div>
) : null}
</span>
</th>
);
};
export const TableHead = ({ children }: { children: ReactNode }) => {
return (
<thead css={tw`bg-neutral-900 border-t border-b border-neutral-500`}>
<tr>
<TableHeader />
{children}
</tr>
</thead>
);
};
export const TableBody = ({ children }: { children: ReactNode }) => {
return <tbody>{children}</tbody>;
};
export const TableRow = ({ children }: { children: ReactNode }) => {
return <tr css={tw`h-12 hover:bg-neutral-600`}>{children}</tr>;
};
interface Props<T> {
data?: PaginatedResult<T>;
onPageSelect: (page: number) => void;
children: ReactNode;
}
const PaginationButton = styled.button<{ active?: boolean }>`
${tw`relative items-center px-3 py-1 -ml-px text-sm font-normal leading-5 transition duration-150 ease-in-out border border-neutral-500 focus:z-10 focus:outline-none focus:border-primary-300 inline-flex`};
${props =>
props.active ? tw`bg-neutral-500 text-neutral-50` : tw`bg-neutral-600 text-neutral-200 hover:text-neutral-50`};
`;
const PaginationArrow = styled.button`
${tw`relative inline-flex items-center px-1 py-1 text-sm font-medium leading-5 transition duration-150 ease-in-out border border-neutral-500 bg-neutral-600 text-neutral-400 hover:text-neutral-50 focus:z-10 focus:outline-none focus:border-primary-300`};
&:disabled {
${tw`bg-neutral-700`}
}
&:hover:disabled {
${tw`text-neutral-400 cursor-default`};
}
`;
export function Pagination<T>({ data, onPageSelect, children }: Props<T>) {
let pagination: PaginationDataSet;
if (data === undefined) {
pagination = {
total: 0,
count: 0,
perPage: 0,
currentPage: 1,
totalPages: 1,
};
} else {
pagination = data.pagination;
}
const setPage = (page: number) => {
if (page < 1 || page > pagination.totalPages) {
return;
}
onPageSelect(page);
};
const isFirstPage = pagination.currentPage === 1;
const isLastPage = pagination.currentPage >= pagination.totalPages;
const pages = [];
if (pagination.totalPages < 7) {
for (let i = 1; i <= pagination.totalPages; i++) {
pages.push(i);
}
} else {
// Don't ask me how this works, all I know is that this code will always have 7 items in the pagination,
// and keeps the current page centered if it is not too close to the start or end.
let start = Math.max(pagination.currentPage - 3, 1);
const end = Math.min(
pagination.totalPages,
pagination.currentPage + (pagination.currentPage < 4 ? 7 - pagination.currentPage : 3),
);
while (start !== 1 && end - start !== 6) {
start--;
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
}
return (
<>
{children}
<div css={tw`h-12 flex flex-row items-center w-full px-6 py-3 border-t border-neutral-500`}>
<p css={tw`text-sm leading-5 text-neutral-400`}>
Showing{' '}
<span css={tw`text-neutral-300`}>
{(pagination.currentPage - 1) * pagination.perPage + (pagination.total > 0 ? 1 : 0)}
</span>{' '}
to{' '}
<span css={tw`text-neutral-300`}>
{(pagination.currentPage - 1) * pagination.perPage + pagination.count}
</span>{' '}
of <span css={tw`text-neutral-300`}>{pagination.total}</span> results
</p>
{isFirstPage && isLastPage ? null : (
<div css={tw`flex flex-row ml-auto`}>
<nav css={tw`relative z-0 inline-flex shadow-sm`}>
<PaginationArrow
type="button"
css={tw`rounded-l-md`}
aria-label="Previous"
disabled={pagination.currentPage === 1}
onClick={() => setPage(pagination.currentPage - 1)}
>
<svg
css={tw`w-5 h-5`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
clipRule="evenodd"
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
/>
</svg>
</PaginationArrow>
{pages.map(page => (
<PaginationButton
key={page}
type="button"
onClick={() => setPage(page)}
active={pagination.currentPage === page}
>
{page}
</PaginationButton>
))}
<PaginationArrow
type="button"
css={tw`-ml-px rounded-r-md`}
aria-label="Next"
disabled={pagination.currentPage === pagination.totalPages}
onClick={() => setPage(pagination.currentPage + 1)}
>
<svg
css={tw`w-5 h-5`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
clipRule="evenodd"
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
/>
</svg>
</PaginationArrow>
</nav>
</div>
)}
</div>
</>
);
}
export const Loading = () => {
return (
<div css={tw`w-full flex flex-col items-center justify-center`} style={{ height: '3rem' }}>
<Spinner size={'base'} />
</div>
);
};
export const NoItems = ({ className }: { className?: string }) => {
return (
<div css={tw`w-full flex flex-col items-center justify-center py-6 px-8`} className={className}>
<div css={tw`h-48 flex`}>
<img src={'/assets/svgs/not_found.svg'} alt={'No Items'} css={tw`h-full select-none`} />
</div>
<p css={tw`text-lg text-neutral-300 text-center font-normal sm:mt-8`}>
No items could be found, it&apos;s almost like they are hiding.
</p>
</div>
);
};
interface Params {
checked: boolean;
onSelectAllClick: (e: ChangeEvent<HTMLInputElement>) => void;
onSearch?: (query: string) => Promise<void>;
children: ReactNode;
}
export const ContentWrapper = ({ checked, onSelectAllClick, onSearch, children }: Params) => {
const [loading, setLoading] = useState(false);
const [inputText, setInputText] = useState('');
const search = useCallback(
debounce((query: string) => {
if (onSearch === undefined) {
return;
}
setLoading(true);
onSearch(query).then(() => setLoading(false));
}, 200),
[],
);
return (
<>
<div css={tw`flex flex-row items-center h-12 px-6`}>
<div css={tw`flex flex-row items-center`}>
<TableCheckbox type={'checkbox'} name={'selectAll'} checked={checked} onChange={onSelectAllClick} />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
css={tw`w-4 h-4 ml-1 text-neutral-200`}
>
<path
clipRule="evenodd"
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
</div>
<div css={tw`flex flex-row items-center ml-auto`}>
<InputSpinner visible={loading}>
<Input
value={inputText}
css={tw`h-8`}
placeholder="Search..."
onChange={e => {
setInputText(e.currentTarget.value);
search(e.currentTarget.value);
}}
/>
</InputSpinner>
</div>
</div>
{children}
</>
);
};
export default ({ children }: { children: ReactNode }) => {
return (
<div css={tw`flex flex-col w-full`}>
<div css={tw`rounded-lg shadow-md bg-neutral-700`}>{children}</div>
</div>
);
};

View file

@ -0,0 +1,87 @@
import tw, { css, styled } from 'twin.macro';
import { withSubComponents } from '@/components/helpers';
const Wrapper = styled.div`
${tw`w-full flex flex-col px-4`};
& > a {
${tw`h-10 w-full flex flex-row items-center text-neutral-300 cursor-pointer select-none px-4`};
${tw`hover:text-neutral-50`};
& > svg {
${tw`h-6 w-6 flex flex-shrink-0`};
}
& > span {
${tw`font-header font-medium text-lg whitespace-nowrap leading-none ml-3`};
}
&:active,
&.active {
${tw`text-neutral-50 bg-neutral-800 rounded`};
}
}
`;
const Section = styled.div`
${tw`h-[18px] font-header font-medium text-xs text-neutral-300 whitespace-nowrap uppercase ml-4 mb-1 select-none`};
&:not(:first-of-type) {
${tw`mt-4`};
}
`;
const User = styled.div`
${tw`h-16 w-full flex items-center bg-neutral-700 justify-center`};
`;
const Sidebar = styled.div<{ $collapsed?: boolean }>`
${tw`h-screen hidden md:flex flex-col items-center flex-shrink-0 bg-neutral-900 overflow-x-hidden ease-linear`};
${tw`transition-[width] duration-150 ease-in`};
${tw`w-[17.5rem]`};
& > a {
${tw`h-10 w-full flex flex-row items-center text-neutral-300 cursor-pointer select-none px-8`};
${tw`hover:text-neutral-50`};
& > svg {
${tw`transition-none h-6 w-6 flex flex-shrink-0`};
}
& > span {
${tw`font-header font-medium text-lg whitespace-nowrap leading-none ml-3`};
}
}
${props =>
props.$collapsed &&
css`
${tw`w-20`};
${Section} {
${tw`invisible`};
}
${Wrapper} {
${tw`px-5`};
& > a {
${tw`justify-center px-0`};
}
}
& > a {
${tw`justify-center px-4`};
}
& > a > span,
${User} > div,
${User} > a,
${Wrapper} > a > span {
${tw`hidden`};
}
`};
`;
export default withSubComponents(Sidebar, { Section, Wrapper, User });

View file

@ -0,0 +1,42 @@
import type { ComponentType, ReactNode } from 'react';
import { NavLink } from 'react-router-dom';
import tw, { styled } from 'twin.macro';
export const SubNavigation = styled.div`
${tw`flex flex-row items-center flex-shrink-0 h-12 mb-4 border-b border-neutral-700`};
& > a {
${tw`flex flex-row items-center h-full px-4 border-b text-neutral-300 text-base whitespace-nowrap border-transparent`};
& > svg {
${tw`w-6 h-6 mr-2`};
}
&:active,
&.active {
${tw`text-primary-300 border-primary-300`};
}
}
`;
interface Props {
to: string;
name: string;
}
interface PropsWithIcon extends Props {
icon: ComponentType;
children?: never;
}
interface PropsWithoutIcon extends Props {
icon?: never;
children: ReactNode;
}
export const SubNavigationLink = ({ to, name, icon: IconComponent, children }: PropsWithIcon | PropsWithoutIcon) => (
<NavLink to={to}>
{IconComponent ? <IconComponent /> : children}
{name}
</NavLink>
);

View file

@ -0,0 +1,73 @@
import { Actions, useStoreActions } from 'easy-peasy';
import { useState } from 'react';
import tw from 'twin.macro';
import deleteDatabase from '@/api/admin/databases/deleteDatabase';
import Button from '@/components/elements/Button';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import type { ApplicationStore } from '@/state';
interface Props {
databaseId: number;
onDeleted: () => void;
}
export default ({ databaseId, 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('database');
deleteDatabase(databaseId)
.then(() => {
setLoading(false);
onDeleted();
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'database', error });
setLoading(false);
setVisible(false);
});
};
return (
<>
<ConfirmationModal
visible={visible}
title={'Delete database host?'}
buttonText={'Yes, delete database host'}
onConfirmed={onDelete}
showSpinnerOverlay={loading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this database host? This action will delete all knowledge of databases
created on this host but not the databases themselves.
</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,235 @@
import type { Action, Actions } from 'easy-peasy';
import { action, createContextStore, useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { Form, Formik } from 'formik';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import tw from 'twin.macro';
import { number, object, string } from 'yup';
import type { Database } from '@/api/admin/databases/getDatabases';
import getDatabase from '@/api/admin/databases/getDatabase';
import updateDatabase from '@/api/admin/databases/updateDatabase';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender';
import AdminBox from '@/components/admin/AdminBox';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import DatabaseDeleteButton from '@/components/admin/databases/DatabaseDeleteButton';
import type { ApplicationStore } from '@/state';
interface ctx {
database: Database | undefined;
setDatabase: Action<ctx, Database | undefined>;
}
export const Context = createContextStore<ctx>({
database: undefined,
setDatabase: action((state, payload) => {
state.database = payload;
}),
});
export interface Values {
name: string;
host: string;
port: number;
username: string;
password: string;
}
export interface Params {
title: string;
initialValues?: Values;
children?: React.ReactNode;
onSubmit: (values: Values, helpers: FormikHelpers<Values>) => void;
}
export const InformationContainer = ({ title, initialValues, children, onSubmit }: Params) => {
const submit = (values: Values, helpers: FormikHelpers<Values>) => {
onSubmit(values, helpers);
};
if (!initialValues) {
initialValues = {
name: '',
host: '',
port: 3306,
username: '',
password: '',
};
}
return (
<Formik
onSubmit={submit}
initialValues={initialValues}
validationSchema={object().shape({
name: string().required().max(191),
host: string().max(255),
port: number().min(2).max(65534),
username: string().min(1).max(32),
password: string(),
})}
>
{({ isSubmitting, isValid }) => (
<>
<AdminBox title={title} css={tw`relative`}>
<SpinnerOverlay visible={isSubmitting} />
<Form css={tw`mb-0`}>
<div>
<Field id={'name'} name={'name'} label={'Name'} type={'text'} />
</div>
<div css={tw`md:w-full md:flex md:flex-row mt-6`}>
<div css={tw`md:w-full md:flex md:flex-col md:mr-4 mt-6 md:mt-0`}>
<Field id={'host'} name={'host'} label={'Host'} type={'text'} />
</div>
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mt-6 md:mt-0`}>
<Field id={'port'} name={'port'} label={'Port'} type={'text'} />
</div>
</div>
<div css={tw`md:w-full md:flex md:flex-row mt-6`}>
<div css={tw`md:w-full md:flex md:flex-col md:mr-4 mt-6 md:mt-0`}>
<Field id={'username'} name={'username'} label={'Username'} type={'text'} />
</div>
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mt-6 md:mt-0`}>
<Field
id={'password'}
name={'password'}
label={'Password'}
type={'password'}
placeholder={'••••••••'}
/>
</div>
</div>
<div css={tw`w-full flex flex-row items-center mt-6`}>
{children}
<div css={tw`flex ml-auto`}>
<Button type={'submit'} disabled={isSubmitting || !isValid}>
Save Changes
</Button>
</div>
</div>
</Form>
</AdminBox>
</>
)}
</Formik>
);
};
const EditInformationContainer = () => {
const navigate = useNavigate();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const database = Context.useStoreState(state => state.database);
const setDatabase = Context.useStoreActions(actions => actions.setDatabase);
if (database === undefined) {
return <></>;
}
const submit = ({ name, host, port, username, password }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('database');
updateDatabase(database.id, name, host, port, username, password || undefined)
.then(() => setDatabase({ ...database, name, host, port, username }))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'database', error });
})
.then(() => setSubmitting(false));
};
return (
<InformationContainer
title={'Edit Database'}
initialValues={{
name: database.name,
host: database.host,
port: database.port,
username: database.username,
password: '',
}}
onSubmit={submit}
>
<div css={tw`flex`}>
<DatabaseDeleteButton databaseId={database.id} onDeleted={() => navigate('/admin/databases')} />
</div>
</InformationContainer>
);
};
const DatabaseEditContainer = () => {
const params = useParams<'id'>();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const [loading, setLoading] = useState(true);
const database = Context.useStoreState(state => state.database);
const setDatabase = Context.useStoreActions(actions => actions.setDatabase);
useEffect(() => {
clearFlashes('database');
getDatabase(Number(params.id))
.then(database => setDatabase(database))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'database', error });
})
.then(() => setLoading(false));
}, []);
if (loading || database === undefined) {
return (
<AdminContentBlock>
<FlashMessageRender byKey={'database'} 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={'Database - ' + database.name}>
<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`}>{database.name}</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
{database.getAddress()}
</p>
</div>
</div>
<FlashMessageRender byKey={'database'} css={tw`mb-4`} />
<EditInformationContainer />
</AdminContentBlock>
);
};
export default () => {
return (
<Context.Provider>
<DatabaseEditContainer />
</Context.Provider>
);
};

View file

@ -0,0 +1,194 @@
import { useContext, useEffect } from 'react';
import { NavLink } from 'react-router-dom';
import tw from 'twin.macro';
import type { Filters } from '@/api/admin/databases/getDatabases';
import getDatabases, { Context as DatabasesContext } from '@/api/admin/databases/getDatabases';
import useFlash from '@/plugins/useFlash';
import { AdminContext } from '@/state/admin';
import FlashMessageRender from '@/components/FlashMessageRender';
import AdminCheckbox from '@/components/admin/AdminCheckbox';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
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';
const RowCheckbox = ({ id }: { id: number }) => {
const isChecked = AdminContext.useStoreState(state => state.databases.selectedDatabases.indexOf(id) >= 0);
const appendSelectedDatabase = AdminContext.useStoreActions(actions => actions.databases.appendSelectedDatabase);
const removeSelectedDatabase = AdminContext.useStoreActions(actions => actions.databases.removeSelectedDatabase);
return (
<AdminCheckbox
name={id.toString()}
checked={isChecked}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
appendSelectedDatabase(id);
} else {
removeSelectedDatabase(id);
}
}}
/>
);
};
const DatabasesContainer = () => {
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(DatabasesContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: databases, error, isValidating } = getDatabases();
useEffect(() => {
if (!error) {
clearFlashes('databases');
return;
}
clearAndAddHttpError({ key: 'databases', error });
}, [error]);
const length = databases?.items?.length || 0;
const setSelectedDatabases = AdminContext.useStoreActions(actions => actions.databases.setSelectedDatabases);
const selectedDatabasesLength = AdminContext.useStoreState(state => state.databases.selectedDatabases.length);
const onSelectAllClick = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDatabases(e.currentTarget.checked ? databases?.items?.map(database => database.id) || [] : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise(resolve => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ name: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedDatabases([]);
}, [page]);
return (
<AdminContentBlock title={'Databases'}>
<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`}>Database Hosts</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
Database hosts that servers can have databases created on.
</p>
</div>
<div css={tw`flex ml-auto pl-4`}>
<NavLink to="/admin/databases/new">
<Button type={'button'} size={'large'} css={tw`h-10 px-4 py-0 whitespace-nowrap`}>
New Database Host
</Button>
</NavLink>
</div>
</div>
<FlashMessageRender byKey={'databases'} css={tw`mb-4`} />
<AdminTable>
<ContentWrapper
checked={selectedDatabasesLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={databases} 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={'Address'} />
<TableHeader name={'Username'} />
</TableHead>
<TableBody>
{databases !== undefined &&
!error &&
!isValidating &&
length > 0 &&
databases.items.map(database => (
<TableRow key={database.id}>
<td css={tw`pl-6`}>
<RowCheckbox id={database.id} />
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={database.id.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{database.id}
</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink
to={`/admin/databases/${database.id}`}
css={tw`text-primary-400 hover:text-primary-300`}
>
{database.name}
</NavLink>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={database.getAddress()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{database.getAddress()}
</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
{database.username}
</td>
</TableRow>
))}
</TableBody>
</table>
{databases === undefined || (error && isValidating) ? (
<Loading />
) : length < 1 ? (
<NoItems />
) : null}
</div>
</Pagination>
</ContentWrapper>
</AdminTable>
</AdminContentBlock>
);
};
export default () => {
const hooks = useTableHooks<Filters>();
return (
<DatabasesContext.Provider value={hooks}>
<DatabasesContainer />
</DatabasesContext.Provider>
);
};

View file

@ -0,0 +1,48 @@
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { useNavigate } from 'react-router-dom';
import tw from 'twin.macro';
import createDatabase from '@/api/admin/databases/createDatabase';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import { InformationContainer, Values } from '@/components/admin/databases/DatabaseEditContainer';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ApplicationStore } from '@/state';
export default () => {
const navigate = useNavigate();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const submit = ({ name, host, port, username, password }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('database:create');
createDatabase(name, host, port, username, password)
.then(database => navigate(`/admin/databases/${database.id}`))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'database:create', error });
})
.then(() => setSubmitting(false));
};
return (
<AdminContentBlock title={'New Database'}>
<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 Database Host</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
Add a new database host to the panel.
</p>
</div>
</div>
<FlashMessageRender byKey={'database:create'} css={tw`mb-4`} />
<InformationContainer title={'Create Database'} onSubmit={submit} />
</AdminContentBlock>
);
};

View file

@ -0,0 +1,74 @@
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import { useState } from 'react';
import tw from 'twin.macro';
import deleteLocation from '@/api/admin/locations/deleteLocation';
import Button from '@/components/elements/Button';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import type { ApplicationStore } from '@/state';
interface Props {
locationId: number;
onDeleted: () => void;
}
export default ({ locationId, 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('location');
deleteLocation(locationId)
.then(() => {
setLoading(false);
onDeleted();
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'location', error });
setLoading(false);
setVisible(false);
});
};
return (
<>
<ConfirmationModal
visible={visible}
title={'Delete location?'}
buttonText={'Yes, delete location'}
onConfirmed={onDelete}
showSpinnerOverlay={loading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this location? You may only delete a location if no nodes are assigned
to it.
</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,180 @@
import type { Action, Actions } from 'easy-peasy';
import { action, createContextStore, useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { Form, Formik } from 'formik';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import tw from 'twin.macro';
import { object, string } from 'yup';
import type { Location } from '@/api/admin/locations/getLocations';
import getLocation from '@/api/admin/locations/getLocation';
import updateLocation from '@/api/admin/locations/updateLocation';
import AdminBox from '@/components/admin/AdminBox';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import LocationDeleteButton from '@/components/admin/locations/LocationDeleteButton';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import Spinner from '@/components/elements/Spinner';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import FlashMessageRender from '@/components/FlashMessageRender';
import type { ApplicationStore } from '@/state';
interface ctx {
location: Location | undefined;
setLocation: Action<ctx, Location | undefined>;
}
export const Context = createContextStore<ctx>({
location: undefined,
setLocation: action((state, payload) => {
state.location = payload;
}),
});
interface Values {
short: string;
long: string;
}
const EditInformationContainer = () => {
const navigate = useNavigate();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const location = Context.useStoreState(state => state.location);
const setLocation = Context.useStoreActions(actions => actions.setLocation);
if (location === undefined) {
return <></>;
}
const submit = ({ short, long }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('location');
updateLocation(location.id, short, long)
.then(() => setLocation({ ...location, short, long }))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'location', error });
})
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={{
short: location.short,
long: location.long || '',
}}
validationSchema={object().shape({
short: string().required().min(1),
long: string().max(255, ''),
})}
>
{({ isSubmitting, isValid }) => (
<>
<AdminBox title={'Edit Location'} css={tw`relative`}>
<SpinnerOverlay visible={isSubmitting} />
<Form css={tw`mb-0`}>
<div>
<Field id={'short'} name={'short'} label={'Short Name'} type={'text'} />
</div>
<div css={tw`mt-6`}>
<Field id={'long'} name={'long'} label={'Long Name'} type={'text'} />
</div>
<div css={tw`w-full flex flex-row items-center mt-6`}>
<div css={tw`flex`}>
<LocationDeleteButton
locationId={location.id}
onDeleted={() => navigate('/admin/locations')}
/>
</div>
<div css={tw`flex ml-auto`}>
<Button type={'submit'} disabled={isSubmitting || !isValid}>
Save Changes
</Button>
</div>
</div>
</Form>
</AdminBox>
</>
)}
</Formik>
);
};
const LocationEditContainer = () => {
const params = useParams<'id'>();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const [loading, setLoading] = useState(true);
const location = Context.useStoreState(state => state.location);
const setLocation = Context.useStoreActions(actions => actions.setLocation);
useEffect(() => {
clearFlashes('location');
getLocation(Number(params.id))
.then(location => setLocation(location))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'location', error });
})
.then(() => setLoading(false));
}, []);
if (loading || location === undefined) {
return (
<AdminContentBlock>
<FlashMessageRender byKey={'location'} 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={'Location - ' + location.short}>
<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`}>{location.short}</h2>
{(location.long || '').length < 1 ? (
<p css={tw`text-base text-neutral-400`}>
<span css={tw`italic`}>No long name</span>
</p>
) : (
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
{location.long}
</p>
)}
</div>
</div>
<FlashMessageRender byKey={'location'} css={tw`mb-4`} />
<EditInformationContainer />
</AdminContentBlock>
);
};
export default () => {
return (
<Context.Provider>
<LocationEditContainer />
</Context.Provider>
);
};

View file

@ -0,0 +1,186 @@
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/locations/getLocations';
import getLocations, { Context as LocationsContext } from '@/api/admin/locations/getLocations';
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 NewLocationButton from '@/components/admin/locations/NewLocationButton';
import CopyOnClick from '@/components/elements/CopyOnClick';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import { AdminContext } from '@/state/admin';
const RowCheckbox = ({ id }: { id: number }) => {
const isChecked = AdminContext.useStoreState(state => state.locations.selectedLocations.indexOf(id) >= 0);
const appendSelectedLocation = AdminContext.useStoreActions(actions => actions.locations.appendSelectedLocation);
const removeSelectedLocation = AdminContext.useStoreActions(actions => actions.locations.removeSelectedLocation);
return (
<AdminCheckbox
name={id.toString()}
checked={isChecked}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
appendSelectedLocation(id);
} else {
removeSelectedLocation(id);
}
}}
/>
);
};
const LocationsContainer = () => {
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(LocationsContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: locations, error, isValidating } = getLocations();
useEffect(() => {
if (!error) {
clearFlashes('locations');
return;
}
clearAndAddHttpError({ key: 'locations', error });
}, [error]);
const length = locations?.items?.length || 0;
const setSelectedLocations = AdminContext.useStoreActions(actions => actions.locations.setSelectedLocations);
const selectedLocationsLength = AdminContext.useStoreState(state => state.locations.selectedLocations.length);
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
setSelectedLocations(e.currentTarget.checked ? locations?.items?.map(location => location.id) || [] : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise(resolve => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ short: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedLocations([]);
}, [page]);
return (
<AdminContentBlock title={'Locations'}>
<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`}>Locations</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
All locations that nodes can be assigned to for easier categorization.
</p>
</div>
<div css={tw`flex ml-auto pl-4`}>
<NewLocationButton />
</div>
</div>
<FlashMessageRender byKey={'locations'} css={tw`mb-4`} />
<AdminTable>
<ContentWrapper
checked={selectedLocationsLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={locations} 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={'Short Name'}
direction={sort === 'short' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('short')}
/>
<TableHeader
name={'Long Name'}
direction={sort === 'long' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('long')}
/>
</TableHead>
<TableBody>
{locations !== undefined &&
!error &&
!isValidating &&
length > 0 &&
locations.items.map(location => (
<TableRow key={location.id}>
<td css={tw`pl-6`}>
<RowCheckbox id={location.id} />
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={location.id.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{location.id}
</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink
to={`/admin/locations/${location.id}`}
css={tw`text-primary-400 hover:text-primary-300`}
>
{location.short}
</NavLink>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
{location.long}
</td>
</TableRow>
))}
</TableBody>
</table>
{locations === undefined || (error && isValidating) ? (
<Loading />
) : length < 1 ? (
<NoItems />
) : null}
</div>
</Pagination>
</ContentWrapper>
</AdminTable>
</AdminContentBlock>
);
};
export default () => {
const hooks = useTableHooks<Filters>();
return (
<LocationsContext.Provider value={hooks}>
<LocationsContainer />
</LocationsContext.Provider>
);
};

View file

@ -0,0 +1,112 @@
import type { FormikHelpers } from 'formik';
import { Form, Formik } from 'formik';
import { useState } from 'react';
import tw from 'twin.macro';
import { object, string } from 'yup';
import createLocation from '@/api/admin/locations/createLocation';
import getLocations from '@/api/admin/locations/getLocations';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import Modal from '@/components/elements/Modal';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
interface Values {
short: string;
long: string;
}
const schema = object().shape({
short: string()
.required('A location short name must be provided.')
.max(32, 'Location short name must not exceed 32 characters.'),
long: string().max(255, 'Location long name must not exceed 255 characters.'),
});
export default () => {
const [visible, setVisible] = useState(false);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { mutate } = getLocations();
const submit = ({ short, long }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('location:create');
setSubmitting(true);
createLocation(short, long)
.then(async location => {
await mutate(data => ({ ...data!, items: data!.items.concat(location) }), false);
setVisible(false);
})
.catch(error => {
clearAndAddHttpError({ key: 'location:create', error });
setSubmitting(false);
});
};
return (
<>
<Formik onSubmit={submit} initialValues={{ short: '', long: '' }} validationSchema={schema}>
{({ isSubmitting, resetForm }) => (
<Modal
visible={visible}
dismissable={!isSubmitting}
showSpinnerOverlay={isSubmitting}
onDismissed={() => {
resetForm();
setVisible(false);
}}
>
<FlashMessageRender byKey={'location:create'} css={tw`mb-6`} />
<h2 css={tw`mb-6 text-2xl text-neutral-100`}>New Location</h2>
<Form css={tw`m-0`}>
<Field
type={'text'}
id={'short'}
name={'short'}
label={'Short'}
description={'A short name used to identify this location.'}
autoFocus
/>
<div css={tw`mt-6`}>
<Field
type={'text'}
id={'long'}
name={'long'}
label={'Long'}
description={'A long name for this location.'}
/>
</div>
<div css={tw`flex flex-wrap justify-end mt-6`}>
<Button
type={'button'}
isSecondary
css={tw`w-full sm:w-auto sm:mr-2`}
onClick={() => setVisible(false)}
>
Cancel
</Button>
<Button css={tw`w-full mt-4 sm:w-auto sm:mt-0`} type={'submit'}>
Create Location
</Button>
</div>
</Form>
</Modal>
)}
</Formik>
<Button
type={'button'}
size={'large'}
css={tw`h-10 px-4 py-0 whitespace-nowrap`}
onClick={() => setVisible(true)}
>
New Location
</Button>
</>
);
};

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 deleteMount from '@/api/admin/mounts/deleteMount';
import Button from '@/components/elements/Button';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import type { ApplicationStore } from '@/state';
interface Props {
mountId: number;
onDeleted: () => void;
}
export default ({ mountId, 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('mount');
deleteMount(mountId)
.then(() => {
setLoading(false);
onDeleted();
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'mount', error });
setLoading(false);
setVisible(false);
});
};
return (
<>
<ConfirmationModal
visible={visible}
title={'Delete mount?'}
buttonText={'Yes, delete mount'}
onConfirmed={onDelete}
showSpinnerOverlay={loading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this mount? Deleting a mount will not delete files on any nodes.
</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,142 @@
import type { Action, Actions } from 'easy-peasy';
import { action, createContextStore, useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import tw from 'twin.macro';
import type { Mount } from '@/api/admin/mounts/getMounts';
import getMount from '@/api/admin/mounts/getMount';
import updateMount from '@/api/admin/mounts/updateMount';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import MountDeleteButton from '@/components/admin/mounts/MountDeleteButton';
import MountForm from '@/components/admin/mounts/MountForm';
import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender';
import type { ApplicationStore } from '@/state';
interface ctx {
mount: Mount | undefined;
setMount: Action<ctx, Mount | undefined>;
}
export const Context = createContextStore<ctx>({
mount: undefined,
setMount: action((state, payload) => {
state.mount = payload;
}),
});
const MountEditContainer = () => {
const navigate = useNavigate();
const params = useParams<'id'>();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const [loading, setLoading] = useState(true);
const mount = Context.useStoreState(state => state.mount);
const setMount = Context.useStoreActions(actions => actions.setMount);
const submit = (
{ name, description, source, target, readOnly, userMountable }: any,
{ setSubmitting }: FormikHelpers<any>,
) => {
if (mount === undefined) {
return;
}
clearFlashes('mount');
updateMount(mount.id, name, description, source, target, readOnly === '1', userMountable === '1')
.then(() =>
setMount({
...mount,
name,
description,
source,
target,
readOnly: readOnly === '1',
userMountable: userMountable === '1',
}),
)
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'mount', error });
})
.then(() => setSubmitting(false));
};
useEffect(() => {
clearFlashes('mount');
getMount(Number(params.id))
.then(mount => setMount(mount))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'mount', error });
})
.then(() => setLoading(false));
}, []);
if (loading || mount === undefined) {
return (
<AdminContentBlock>
<FlashMessageRender byKey={'mount'} 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={'Mount - ' + mount.name}>
<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`}>{mount.name}</h2>
{(mount.description || '').length < 1 ? (
<p css={tw`text-base text-neutral-400`}>
<span css={tw`italic`}>No description</span>
</p>
) : (
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
{mount.description}
</p>
)}
</div>
</div>
<FlashMessageRender byKey={'mount'} css={tw`mb-4`} />
<MountForm
action={'Save Changes'}
title={'Edit Mount'}
initialValues={{
name: mount.name,
description: mount.description || '',
source: mount.source,
target: mount.target,
readOnly: mount.readOnly ? '1' : '0',
userMountable: mount.userMountable ? '1' : '0',
}}
onSubmit={submit}
>
<div css={tw`flex`}>
<MountDeleteButton mountId={mount.id} onDeleted={() => navigate('/admin/mounts')} />
</div>
</MountForm>
</AdminContentBlock>
);
};
export default () => {
return (
<Context.Provider>
<MountEditContainer />
</Context.Provider>
);
};

View file

@ -0,0 +1,133 @@
import type { FormikHelpers } from 'formik';
import { Field as FormikField, Form, Formik } from 'formik';
import tw from 'twin.macro';
import { boolean, object, string } from 'yup';
import AdminBox from '@/components/admin/AdminBox';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import Label from '@/components/elements/Label';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
interface Values {
name: string;
description: string;
source: string;
target: string;
readOnly: string;
userMountable: string;
}
interface Props {
action: string;
title: string;
initialValues?: Values;
onSubmit: (values: Values, helpers: FormikHelpers<Values>) => void;
children?: React.ReactNode;
}
function MountForm({ action, title, initialValues, children, onSubmit }: Props) {
const submit = (values: Values, helpers: FormikHelpers<Values>) => {
onSubmit(values, helpers);
};
if (!initialValues) {
initialValues = {
name: '',
description: '',
source: '',
target: '',
readOnly: '0',
userMountable: '0',
};
}
return (
<Formik
onSubmit={submit}
initialValues={initialValues}
validationSchema={object().shape({
name: string().required().min(1),
description: string().max(255, ''),
source: string().max(255, ''),
target: string().max(255, ''),
readOnly: boolean(),
userMountable: boolean(),
})}
>
{({ isSubmitting, isValid }) => (
<AdminBox title={title} css={tw`relative`}>
<SpinnerOverlay visible={isSubmitting} />
<Form css={tw`mb-0`}>
<div>
<Field id={'name'} name={'name'} label={'Name'} type={'text'} />
</div>
<div css={tw`mt-6`}>
<Field id={'description'} name={'description'} label={'Description'} type={'text'} />
</div>
<div css={tw`md:w-full md:flex md:flex-row mt-6`}>
<div css={tw`md:w-full md:flex md:flex-col md:mr-4 mt-6 md:mt-0`}>
<Field id={'source'} name={'source'} label={'Source'} type={'text'} />
</div>
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mt-6 md:mt-0`}>
<Field id={'target'} name={'target'} label={'Target'} type={'text'} />
</div>
</div>
<div css={tw`md:w-full md:flex md:flex-row mt-6`}>
<div css={tw`md:w-full md:flex md:flex-col md:mr-4 mt-6 md:mt-0`}>
<Label htmlFor={'readOnly'}>Permissions</Label>
<div>
<label css={tw`inline-flex items-center mr-2`}>
<FormikField name={'readOnly'} type={'radio'} value={'0'} />
<span css={tw`ml-2`}>Writable</span>
</label>
<label css={tw`inline-flex items-center ml-2`}>
<FormikField name={'readOnly'} type={'radio'} value={'1'} />
<span css={tw`ml-2`}>Read Only</span>
</label>
</div>
</div>
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mt-6 md:mt-0`}>
<Label htmlFor={'userMountable'}>User Mountable</Label>
<div>
<label css={tw`inline-flex items-center mr-2`}>
<FormikField name={'userMountable'} type={'radio'} value={'0'} />
<span css={tw`ml-2`}>Admin Only</span>
</label>
<label css={tw`inline-flex items-center ml-2`}>
<FormikField name={'userMountable'} type={'radio'} value={'1'} />
<span css={tw`ml-2`}>Users</span>
</label>
</div>
</div>
</div>
<div css={tw`w-full flex flex-row items-center mt-6`}>
{children}
<div css={tw`flex ml-auto`}>
<Button type={'submit'} disabled={isSubmitting || !isValid}>
{action}
</Button>
</div>
</div>
</Form>
</AdminBox>
)}
</Formik>
);
}
export default MountForm;

View file

@ -0,0 +1,241 @@
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/mounts/getMounts';
import getMounts, { Context as MountsContext } from '@/api/admin/mounts/getMounts';
import AdminCheckbox from '@/components/admin/AdminCheckbox';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
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 FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import { AdminContext } from '@/state/admin';
const RowCheckbox = ({ id }: { id: number }) => {
const isChecked = AdminContext.useStoreState(state => state.mounts.selectedMounts.indexOf(id) >= 0);
const appendSelectedMount = AdminContext.useStoreActions(actions => actions.mounts.appendSelectedMount);
const removeSelectedMount = AdminContext.useStoreActions(actions => actions.mounts.removeSelectedMount);
return (
<AdminCheckbox
name={id.toString()}
checked={isChecked}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
appendSelectedMount(id);
} else {
removeSelectedMount(id);
}
}}
/>
);
};
const MountsContainer = () => {
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(MountsContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: mounts, error, isValidating } = getMounts();
useEffect(() => {
if (!error) {
clearFlashes('mounts');
return;
}
clearAndAddHttpError({ key: 'mounts', error });
}, [error]);
const length = mounts?.items?.length || 0;
const setSelectedMounts = AdminContext.useStoreActions(actions => actions.mounts.setSelectedMounts);
const selectedMountsLength = AdminContext.useStoreState(state => state.mounts.selectedMounts.length);
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
setSelectedMounts(e.currentTarget.checked ? mounts?.items?.map(mount => mount.id) || [] : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise(resolve => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ name: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedMounts([]);
}, [page]);
return (
<AdminContentBlock title={'Mounts'}>
<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`}>Mounts</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
Configure and manage additional mount points for servers.
</p>
</div>
<div css={tw`flex ml-auto pl-4`}>
<NavLink to={`/admin/mounts/new`}>
<Button type={'button'} size={'large'} css={tw`h-10 px-4 py-0 whitespace-nowrap`}>
New Mount
</Button>
</NavLink>
</div>
</div>
<FlashMessageRender byKey={'mounts'} css={tw`mb-4`} />
<AdminTable>
<ContentWrapper
checked={selectedMountsLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={mounts} 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={'Source Path'}
direction={sort === 'source' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('source')}
/>
<TableHeader
name={'Target Path'}
direction={sort === 'target' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('target')}
/>
<th css={tw`px-6 py-2`} />
<th css={tw`px-6 py-2`} />
</TableHead>
<TableBody>
{mounts !== undefined &&
!error &&
!isValidating &&
length > 0 &&
mounts.items.map(mount => (
<TableRow key={mount.id}>
<td css={tw`pl-6`}>
<RowCheckbox id={mount.id} />
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={mount.id.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{mount.id}
</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink
to={`/admin/mounts/${mount.id}`}
css={tw`text-primary-400 hover:text-primary-300`}
>
{mount.name}
</NavLink>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={mount.source.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{mount.source}
</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={mount.target.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{mount.target}
</code>
</CopyOnClick>
</td>
<td css={tw`px-6 whitespace-nowrap`}>
{mount.readOnly ? (
<span
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-green-100 text-green-800`}
>
Read Only
</span>
) : (
<span
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-yellow-200 text-yellow-800`}
>
Writable
</span>
)}
</td>
<td css={tw`px-6 whitespace-nowrap`}>
{mount.userMountable ? (
<span
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-green-100 text-green-800`}
>
Mountable
</span>
) : (
<span
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-yellow-200 text-yellow-800`}
>
Admin Only
</span>
)}
</td>
</TableRow>
))}
</TableBody>
</table>
{mounts === undefined || (error && isValidating) ? (
<Loading />
) : length < 1 ? (
<NoItems />
) : null}
</div>
</Pagination>
</ContentWrapper>
</AdminTable>
</AdminContentBlock>
);
};
export default () => {
const hooks = useTableHooks<Filters>();
return (
<MountsContext.Provider value={hooks}>
<MountsContainer />
</MountsContext.Provider>
);
};

View file

@ -0,0 +1,51 @@
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { useNavigate } from 'react-router-dom';
import tw from 'twin.macro';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import FlashMessageRender from '@/components/FlashMessageRender';
import MountForm from '@/components/admin/mounts/MountForm';
import createMount from '@/api/admin/mounts/createMount';
import type { ApplicationStore } from '@/state';
export default () => {
const navigate = useNavigate();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const submit = (
{ name, description, source, target, readOnly, userMountable }: any,
{ setSubmitting }: FormikHelpers<any>,
) => {
clearFlashes('mount:create');
createMount(name, description, source, target, readOnly === '1', userMountable === '1')
.then(mount => navigate(`/admin/mounts/${mount.id}`))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'mount:create', error });
})
.then(() => setSubmitting(false));
};
return (
<AdminContentBlock title={'New Mount'}>
<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 Mount</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
Add a new mount to the panel.
</p>
</div>
</div>
<FlashMessageRender byKey={'mount:create'} css={tw`mb-4`} />
<MountForm action={'Create'} title={'Create Mount'} onSubmit={submit} />
</AdminContentBlock>
);
};

View file

@ -0,0 +1,82 @@
import getEggs from '@/api/admin/nests/getEggs';
import importEgg from '@/api/admin/nests/importEgg';
import useFlash from '@/plugins/useFlash';
// import { Editor } from '@/components/elements/editor';
import { useState } from 'react';
import Button from '@/components/elements/Button';
import Modal from '@/components/elements/Modal';
import FlashMessageRender from '@/components/FlashMessageRender';
import { useParams } from 'react-router-dom';
import tw from 'twin.macro';
export default ({ className }: { className?: string }) => {
const [visible, setVisible] = useState(false);
const { clearFlashes } = useFlash();
const params = useParams<'nestId'>();
const { mutate } = getEggs(Number(params.nestId));
let fetchFileContent: (() => Promise<string>) | null = null;
const submit = async () => {
clearFlashes('egg:import');
if (fetchFileContent === null) {
return;
}
const egg = await importEgg(Number(params.nestId), await fetchFileContent());
await mutate(data => ({ ...data!, items: [...data!.items!, egg] }));
setVisible(false);
};
return (
<>
<Modal
visible={visible}
onDismissed={() => {
setVisible(false);
}}
>
<FlashMessageRender byKey={'egg:import'} css={tw`mb-6`} />
<h2 css={tw`mb-6 text-2xl text-neutral-100`}>Import Egg</h2>
{/*<Editor*/}
{/* // overrides={tw`h-64 rounded`}*/}
{/* initialContent={''}*/}
{/* // language={jsonLanguage}*/}
{/* fetchContent={value => {*/}
{/* fetchFileContent = value;*/}
{/* }}*/}
{/*/>*/}
<div css={tw`flex flex-wrap justify-end mt-4 sm:mt-6`}>
<Button
type={'button'}
css={tw`w-full sm:w-auto sm:mr-2`}
onClick={() => setVisible(false)}
isSecondary
>
Cancel
</Button>
<Button css={tw`w-full sm:w-auto mt-4 sm:mt-0`} onClick={submit}>
Import Egg
</Button>
</div>
</Modal>
<Button
type={'button'}
size={'large'}
css={tw`h-10 px-4 py-0 whitespace-nowrap`}
className={className}
onClick={() => setVisible(true)}
isSecondary
>
Import
</Button>
</>
);
};

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 deleteNest from '@/api/admin/nests/deleteNest';
import Button from '@/components/elements/Button';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import type { ApplicationStore } from '@/state';
interface Props {
nestId: number;
onDeleted: () => void;
}
export default ({ nestId, 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('nest');
deleteNest(nestId)
.then(() => {
setLoading(false);
onDeleted();
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'nest', error });
setLoading(false);
setVisible(false);
});
};
return (
<>
<ConfirmationModal
visible={visible}
title={'Delete nest?'}
buttonText={'Yes, delete nest'}
onConfirmed={onDelete}
showSpinnerOverlay={loading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this nest? Deleting a nest will delete all eggs assigned to it.
</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,250 @@
import type { Action, Actions } from 'easy-peasy';
import { action, createContextStore, useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { Form, Formik } from 'formik';
import { useEffect, useState } from 'react';
import { NavLink, useNavigate, useParams } from 'react-router-dom';
import tw from 'twin.macro';
import { object, string } from 'yup';
import ImportEggButton from '@/components/admin/nests/ImportEggButton';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender';
import type { Nest } from '@/api/admin/nests/getNests';
import getNest from '@/api/admin/nests/getNest';
import updateNest from '@/api/admin/nests/updateNest';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import AdminBox from '@/components/admin/AdminBox';
import CopyOnClick from '@/components/elements/CopyOnClick';
import Input from '@/components/elements/Input';
import Label from '@/components/elements/Label';
import NestDeleteButton from '@/components/admin/nests/NestDeleteButton';
import NestEggTable from '@/components/admin/nests/NestEggTable';
import type { ApplicationStore } from '@/state';
interface ctx {
nest: Nest | undefined;
setNest: Action<ctx, Nest | undefined>;
selectedEggs: number[];
setSelectedEggs: Action<ctx, number[]>;
appendSelectedEggs: Action<ctx, number>;
removeSelectedEggs: Action<ctx, number>;
}
export const Context = createContextStore<ctx>({
nest: undefined,
setNest: action((state, payload) => {
state.nest = payload;
}),
selectedEggs: [],
setSelectedEggs: action((state, payload) => {
state.selectedEggs = payload;
}),
appendSelectedEggs: action((state, payload) => {
state.selectedEggs = state.selectedEggs.filter(id => id !== payload).concat(payload);
}),
removeSelectedEggs: action((state, payload) => {
state.selectedEggs = state.selectedEggs.filter(id => id !== payload);
}),
});
interface Values {
name: string;
description: string;
}
const EditInformationContainer = () => {
const navigate = useNavigate();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const nest = Context.useStoreState(state => state.nest);
const setNest = Context.useStoreActions(actions => actions.setNest);
if (nest === undefined) {
return <></>;
}
const submit = ({ name, description }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('nest');
updateNest(nest.id, name, description)
.then(() => setNest({ ...nest, name, description }))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'nest', error });
})
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={{
name: nest.name,
description: nest.description || '',
}}
validationSchema={object().shape({
name: string().required().min(1),
description: string().max(255, ''),
})}
>
{({ isSubmitting, isValid }) => (
<>
<AdminBox title={'Edit Nest'} css={tw`flex-1 self-start w-full relative mb-8 lg:mb-0 mr-0 lg:mr-4`}>
<SpinnerOverlay visible={isSubmitting} />
<Form>
<Field id={'name'} name={'name'} label={'Name'} type={'text'} css={tw`mb-6`} />
<Field id={'description'} name={'description'} label={'Description'} type={'text'} />
<div css={tw`w-full flex flex-row items-center mt-6`}>
<div css={tw`flex`}>
<NestDeleteButton nestId={nest.id} onDeleted={() => navigate('/admin/nests')} />
</div>
<div css={tw`flex ml-auto`}>
<Button type={'submit'} disabled={isSubmitting || !isValid}>
Save Changes
</Button>
</div>
</div>
</Form>
</AdminBox>
</>
)}
</Formik>
);
};
const ViewDetailsContainer = () => {
const nest = Context.useStoreState(state => state.nest);
if (nest === undefined) {
return <></>;
}
return (
<AdminBox title={'Nest Details'} css={tw`flex-1 w-full relative ml-0 lg:ml-4`}>
<div>
<div>
<div>
<Label>ID</Label>
<CopyOnClick text={nest.id.toString()}>
<Input type={'text'} value={nest.id} readOnly />
</CopyOnClick>
</div>
<div css={tw`mt-6`}>
<Label>UUID</Label>
<CopyOnClick text={nest.uuid}>
<Input type={'text'} value={nest.uuid} readOnly />
</CopyOnClick>
</div>
<div css={tw`mt-6 mb-2`}>
<Label>Author</Label>
<CopyOnClick text={nest.author}>
<Input type={'text'} value={nest.author} readOnly />
</CopyOnClick>
</div>
</div>
</div>
</AdminBox>
);
};
const NestEditContainer = () => {
const params = useParams<'nestId'>();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const [loading, setLoading] = useState(true);
const nest = Context.useStoreState(state => state.nest);
const setNest = Context.useStoreActions(actions => actions.setNest);
useEffect(() => {
clearFlashes('nest');
getNest(Number(params.nestId), ['eggs'])
.then(nest => setNest(nest))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'nest', error });
})
.then(() => setLoading(false));
}, []);
if (loading || nest === undefined) {
return (
<AdminContentBlock>
<FlashMessageRender byKey={'nest'} 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={'Nests - ' + nest.name}>
<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`}>{nest.name}</h2>
{(nest.description || '').length < 1 ? (
<p css={tw`text-base text-neutral-400`}>
<span css={tw`italic`}>No description</span>
</p>
) : (
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
{nest.description}
</p>
)}
</div>
<div css={tw`flex flex-row ml-auto pl-4`}>
<ImportEggButton css={tw`mr-4`} />
<NavLink to={`/admin/nests/${params.nestId}/new`}>
<Button type={'button'} size={'large'} css={tw`h-10 px-4 py-0 whitespace-nowrap`}>
New Egg
</Button>
</NavLink>
</div>
</div>
<FlashMessageRender byKey={'nest'} css={tw`mb-4`} />
<div css={tw`flex flex-col lg:flex-row mb-8`}>
<EditInformationContainer />
<ViewDetailsContainer />
</div>
<NestEggTable />
</AdminContentBlock>
);
};
export default () => {
return (
<Context.Provider>
<NestEditContainer />
</Context.Provider>
);
};

View file

@ -0,0 +1,160 @@
import type { ChangeEvent } from 'react';
import { useContext, useEffect } from 'react';
import { NavLink, useParams } from 'react-router-dom';
import tw from 'twin.macro';
import type { Filters } from '@/api/admin/nests/getEggs';
import getEggs, { Context as EggsContext } from '@/api/admin/nests/getEggs';
import AdminCheckbox from '@/components/admin/AdminCheckbox';
import AdminTable, {
TableBody,
TableHead,
TableHeader,
TableRow,
Pagination,
Loading,
NoItems,
ContentWrapper,
useTableHooks,
} from '@/components/admin/AdminTable';
import { Context } from '@/components/admin/nests/NestEditContainer';
import CopyOnClick from '@/components/elements/CopyOnClick';
import useFlash from '@/plugins/useFlash';
const RowCheckbox = ({ id }: { id: number }) => {
const isChecked = Context.useStoreState(state => state.selectedEggs.indexOf(id) >= 0);
const appendSelectedEggs = Context.useStoreActions(actions => actions.appendSelectedEggs);
const removeSelectedEggs = Context.useStoreActions(actions => actions.removeSelectedEggs);
return (
<AdminCheckbox
name={id.toString()}
checked={isChecked}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
appendSelectedEggs(id);
} else {
removeSelectedEggs(id);
}
}}
/>
);
};
const EggsTable = () => {
const params = useParams<'nestId' | 'id'>();
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(EggsContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: eggs, error, isValidating } = getEggs(Number(params.nestId));
useEffect(() => {
if (!error) {
clearFlashes('nests');
return;
}
clearAndAddHttpError({ key: 'nests', error });
}, [error]);
const length = eggs?.items?.length || 0;
const setSelectedEggs = Context.useStoreActions(actions => actions.setSelectedEggs);
const selectedEggsLength = Context.useStoreState(state => state.selectedEggs.length);
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
setSelectedEggs(e.currentTarget.checked ? eggs?.items?.map(nest => nest.id) || [] : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise(resolve => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ name: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedEggs([]);
}, [page]);
return (
<AdminTable>
<ContentWrapper
checked={selectedEggsLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={eggs} 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={'Description'} />
</TableHead>
<TableBody>
{eggs !== undefined &&
!error &&
!isValidating &&
length > 0 &&
eggs.items.map(egg => (
<TableRow key={egg.id}>
<td css={tw`pl-6`}>
<RowCheckbox id={egg.id} />
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={egg.id.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{egg.id}
</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink
to={`/admin/nests/${params.nestId}/eggs/${egg.id}`}
css={tw`text-primary-400 hover:text-primary-300`}
>
{egg.name}
</NavLink>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
{egg.description}
</td>
</TableRow>
))}
</TableBody>
</table>
{eggs === undefined || (error && isValidating) ? <Loading /> : length < 1 ? <NoItems /> : null}
</div>
</Pagination>
</ContentWrapper>
</AdminTable>
);
};
export default () => {
const hooks = useTableHooks<Filters>();
return (
<EggsContext.Provider value={hooks}>
<EggsTable />
</EggsContext.Provider>
);
};

View file

@ -0,0 +1,182 @@
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/nests/getNests';
import getNests, { Context as NestsContext } from '@/api/admin/nests/getNests';
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 CopyOnClick from '@/components/elements/CopyOnClick';
import NewNestButton from '@/components/admin/nests/NewNestButton';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import { AdminContext } from '@/state/admin';
const RowCheckbox = ({ id }: { id: number }) => {
const isChecked = AdminContext.useStoreState(state => state.nests.selectedNests.indexOf(id) >= 0);
const appendSelectedNest = AdminContext.useStoreActions(actions => actions.nests.appendSelectedNest);
const removeSelectedNest = AdminContext.useStoreActions(actions => actions.nests.removeSelectedNest);
return (
<AdminCheckbox
name={id.toString()}
checked={isChecked}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
appendSelectedNest(id);
} else {
removeSelectedNest(id);
}
}}
/>
);
};
const NestsContainer = () => {
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(NestsContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: nests, error, isValidating } = getNests();
useEffect(() => {
if (!error) {
clearFlashes('nests');
return;
}
clearAndAddHttpError({ key: 'nests', error });
}, [error]);
const length = nests?.items?.length || 0;
const setSelectedNests = AdminContext.useStoreActions(actions => actions.nests.setSelectedNests);
const selectedNestsLength = AdminContext.useStoreState(state => state.nests.selectedNests.length);
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
setSelectedNests(e.currentTarget.checked ? nests?.items?.map(nest => nest.id) || [] : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise(resolve => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ name: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedNests([]);
}, [page]);
return (
<AdminContentBlock title={'Nests'}>
<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`}>Nests</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
All nests currently available on this system.
</p>
</div>
<div css={tw`flex ml-auto pl-4`}>
<NewNestButton />
</div>
</div>
<FlashMessageRender byKey={'nests'} css={tw`mb-4`} />
<AdminTable>
<ContentWrapper
checked={selectedNestsLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={nests} 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={'Description'} />
</TableHead>
<TableBody>
{nests !== undefined &&
!error &&
!isValidating &&
length > 0 &&
nests.items.map(nest => (
<TableRow key={nest.id}>
<td css={tw`pl-6`}>
<RowCheckbox id={nest.id} />
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={nest.id.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{nest.id}
</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink
to={`/admin/nests/${nest.id}`}
css={tw`text-primary-400 hover:text-primary-300`}
>
{nest.name}
</NavLink>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
{nest.description}
</td>
</TableRow>
))}
</TableBody>
</table>
{nests === undefined || (error && isValidating) ? (
<Loading />
) : length < 1 ? (
<NoItems />
) : null}
</div>
</Pagination>
</ContentWrapper>
</AdminTable>
</AdminContentBlock>
);
};
export default () => {
const hooks = useTableHooks<Filters>();
return (
<NestsContext.Provider value={hooks}>
<NestsContainer />
</NestsContext.Provider>
);
};

View file

@ -0,0 +1,115 @@
import type { FormikHelpers } from 'formik';
import { Form, Formik } from 'formik';
import { useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import tw from 'twin.macro';
import { object } from 'yup';
import createEgg from '@/api/admin/eggs/createEgg';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import {
EggImageContainer,
EggInformationContainer,
EggLifecycleContainer,
EggProcessContainer,
EggProcessContainerRef,
EggStartupContainer,
} from '@/components/admin/nests/eggs/EggSettingsContainer';
import Button from '@/components/elements/Button';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
interface Values {
name: string;
description: string;
startup: string;
dockerImages: string;
configStop: string;
configStartup: string;
configFiles: string;
}
export default () => {
const navigate = useNavigate();
const params = useParams<{ nestId: string }>();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const ref = useRef<EggProcessContainerRef>();
const submit = async (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('egg:create');
const nestId = Number(params.nestId);
values.configStartup = (await ref.current?.getStartupConfiguration()) || '';
values.configFiles = (await ref.current?.getFilesConfiguration()) || '';
createEgg({ ...values, dockerImages: values.dockerImages.split('\n'), nestId })
.then(egg => navigate(`/admin/nests/${nestId}/eggs/${egg.id}`))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'egg:create', error });
})
.then(() => setSubmitting(false));
};
return (
<AdminContentBlock title={'New Egg'}>
<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 Egg</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
Add a new egg to the panel.
</p>
</div>
</div>
<FlashMessageRender key={'egg:create'} css={tw`mb-4`} />
<Formik
onSubmit={submit}
initialValues={{
name: '',
description: '',
startup: '',
dockerImages: '',
configStop: '',
configStartup: '{}',
configFiles: '{}',
}}
validationSchema={object().shape({})}
>
{({ isSubmitting, isValid }) => (
<Form>
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
<EggInformationContainer />
</div>
<EggStartupContainer css={tw`mb-6`} />
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
<EggImageContainer />
<EggLifecycleContainer />
</div>
<EggProcessContainer ref={ref} css={tw`mb-6`} />
<div css={tw`bg-neutral-700 rounded shadow-md py-2 px-6 mb-16`}>
<div css={tw`flex flex-row`}>
<Button
type="submit"
size="small"
css={tw`ml-auto`}
disabled={isSubmitting || !isValid}
>
Create
</Button>
</div>
</div>
</Form>
)}
</Formik>
</AdminContentBlock>
);
};

View file

@ -0,0 +1,112 @@
import React, { useState } from 'react';
import createNest from '@/api/admin/nests/createNest';
import getNests from '@/api/admin/nests/getNests';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import Modal from '@/components/elements/Modal';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import { Form, Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import tw from 'twin.macro';
interface Values {
name: string,
description: string,
}
const schema = object().shape({
name: string()
.required('A nest name must be provided.')
.max(32, 'Nest name must not exceed 32 characters.'),
description: string()
.max(255, 'Nest description must not exceed 255 characters.'),
});
export default () => {
const [ visible, setVisible ] = useState(false);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { mutate } = getNests();
const submit = ({ name, description }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('nest:create');
setSubmitting(true);
createNest(name, description)
.then(async (nest) => {
await mutate(data => ({ ...data!, items: data!.items.concat(nest) }), false);
setVisible(false);
})
.catch(error => {
clearAndAddHttpError({ key: 'nest:create', error });
setSubmitting(false);
});
};
return (
<>
<Formik
onSubmit={submit}
initialValues={{ name: '', description: '' }}
validationSchema={schema}
>
{
({ isSubmitting, resetForm }) => (
<Modal
visible={visible}
dismissable={!isSubmitting}
showSpinnerOverlay={isSubmitting}
onDismissed={() => {
resetForm();
setVisible(false);
}}
>
<FlashMessageRender byKey={'nest:create'} css={tw`mb-6`}/>
<h2 css={tw`mb-6 text-2xl text-neutral-100`}>New Nest</h2>
<Form css={tw`m-0`}>
<Field
type={'text'}
id={'name'}
name={'name'}
label={'Name'}
description={'A short name used to identify this nest.'}
autoFocus
/>
<div css={tw`mt-6`}>
<Field
type={'text'}
id={'description'}
name={'description'}
label={'Description'}
description={'A description for this nest.'}
/>
</div>
<div css={tw`flex flex-wrap justify-end mt-6`}>
<Button
type={'button'}
isSecondary
css={tw`w-full sm:w-auto sm:mr-2`}
onClick={() => setVisible(false)}
>
Cancel
</Button>
<Button css={tw`w-full mt-4 sm:w-auto sm:mt-0`} type={'submit'}>
Create Nest
</Button>
</div>
</Form>
</Modal>
)
}
</Formik>
<Button type={'button'} size={'large'} css={tw`h-10 px-4 py-0 whitespace-nowrap`} onClick={() => setVisible(true)}>
New Nest
</Button>
</>
);
};

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 deleteEgg from '@/api/admin/eggs/deleteEgg';
import Button from '@/components/elements/Button';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import type { ApplicationStore } from '@/state';
interface Props {
eggId: number;
onDeleted: () => void;
}
export default ({ eggId, 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('egg');
deleteEgg(eggId)
.then(() => {
setLoading(false);
onDeleted();
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'egg', error });
setLoading(false);
setVisible(false);
});
};
return (
<>
<ConfirmationModal
visible={visible}
title={'Delete egg?'}
buttonText={'Yes, delete egg'}
onConfirmed={onDelete}
showSpinnerOverlay={loading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this egg? You may only delete an egg with no servers using it.
</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,85 @@
import { exportEgg } from '@/api/admin/egg';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFlash from '@/plugins/useFlash';
// import { jsonLanguage } from '@codemirror/lang-json';
// import Editor from '@/components/elements/Editor';
import { useEffect, useState } from 'react';
import Button from '@/components/elements/Button';
import Modal from '@/components/elements/Modal';
import FlashMessageRender from '@/components/FlashMessageRender';
import { useParams } from 'react-router-dom';
import tw from 'twin.macro';
export default ({ className }: { className?: string }) => {
const params = useParams<'id'>();
const { clearAndAddHttpError, clearFlashes } = useFlash();
const [visible, setVisible] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [_content, setContent] = useState<Record<string, any> | null>(null);
useEffect(() => {
if (!visible) {
return;
}
clearFlashes('egg:export');
setLoading(true);
exportEgg(Number(params.id))
.then(setContent)
.catch(error => clearAndAddHttpError({ key: 'egg:export', error }))
.then(() => setLoading(false));
}, [visible]);
return (
<>
<Modal
visible={visible}
onDismissed={() => {
setVisible(false);
}}
css={tw`relative`}
>
<SpinnerOverlay visible={loading} />
<h2 css={tw`mb-6 text-2xl text-neutral-100`}>Export Egg</h2>
<FlashMessageRender byKey={'egg:export'} css={tw`mb-6`} />
{/*<Editor*/}
{/* overrides={tw`h-[32rem] rounded`}*/}
{/* initialContent={content !== null ? JSON.stringify(content, null, '\t') : ''}*/}
{/* mode={jsonLanguage}*/}
{/*/>*/}
<div css={tw`flex flex-wrap justify-end mt-4 sm:mt-6`}>
<Button
type={'button'}
css={tw`w-full sm:w-auto sm:mr-2`}
onClick={() => setVisible(false)}
isSecondary
>
Close
</Button>
<Button
css={tw`w-full sm:w-auto mt-4 sm:mt-0`}
// onClick={submit}
// TODO: When clicked, save as a JSON file.
>
Save
</Button>
</div>
</Modal>
<Button
type={'button'}
size={'small'}
css={tw`px-4 py-0 whitespace-nowrap`}
className={className}
onClick={() => setVisible(true)}
isSecondary
>
Export
</Button>
</>
);
};

View file

@ -0,0 +1,110 @@
import { useEggFromRoute } from '@/api/admin/egg';
import updateEgg from '@/api/admin/eggs/updateEgg';
import Field from '@/components/elements/Field';
import useFlash from '@/plugins/useFlash';
// import { shell } from '@codemirror/legacy-modes/mode/shell';
import { faScroll } from '@fortawesome/free-solid-svg-icons';
import { Form, Formik, FormikHelpers } from 'formik';
import tw from 'twin.macro';
import AdminBox from '@/components/admin/AdminBox';
import Button from '@/components/elements/Button';
// import Editor from '@/components/elements/Editor';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
interface Values {
scriptContainer: string;
scriptEntry: string;
scriptInstall: string;
}
export default function EggInstallContainer() {
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: egg } = useEggFromRoute();
if (!egg) {
return null;
}
let fetchFileContent: (() => Promise<string>) | null = null;
const submit = async (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
if (fetchFileContent === null) {
return;
}
values.scriptInstall = await fetchFileContent();
clearFlashes('egg');
updateEgg(egg.id, values)
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'egg', error });
})
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={{
scriptContainer: egg.scriptContainer,
scriptEntry: egg.scriptEntry,
scriptInstall: '',
}}
>
{({ isSubmitting, isValid }) => (
<AdminBox icon={faScroll} title={'Install Script'} noPadding>
<div css={tw`relative pb-4`}>
<SpinnerOverlay visible={isSubmitting} />
<Form>
{/*<Editor*/}
{/* overrides={tw`h-96 mb-4`}*/}
{/* initialContent={egg.scriptInstall || ''}*/}
{/* mode={shell}*/}
{/* fetchContent={value => {*/}
{/* fetchFileContent = value;*/}
{/* }}*/}
{/*/>*/}
<div css={tw`mx-6 mb-4`}>
<div css={tw`grid grid-cols-3 gap-x-8 gap-y-6`}>
<Field
id={'scriptContainer'}
name={'scriptContainer'}
label={'Install Container'}
type={'text'}
description={'The Docker image to use for running this installation script.'}
/>
<Field
id={'scriptEntry'}
name={'scriptEntry'}
label={'Install Entrypoint'}
type={'text'}
description={
'The command that should be used to run this script inside of the installation container.'
}
/>
</div>
</div>
<div css={tw`flex flex-row border-t border-neutral-600`}>
<Button
type={'submit'}
size={'small'}
css={tw`ml-auto mr-6 mt-4`}
disabled={isSubmitting || !isValid}
>
Save Changes
</Button>
</div>
</Form>
</div>
</AdminBox>
)}
</Formik>
);
}

View file

@ -0,0 +1,90 @@
import { useEffect } from 'react';
import { Route, Routes, useParams } from 'react-router-dom';
import tw from 'twin.macro';
import { useEggFromRoute } from '@/api/admin/egg';
import EggInstallContainer from '@/components/admin/nests/eggs/EggInstallContainer';
import EggVariablesContainer from '@/components/admin/nests/eggs/EggVariablesContainer';
import useFlash from '@/plugins/useFlash';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender';
import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation';
import EggSettingsContainer from '@/components/admin/nests/eggs/EggSettingsContainer';
const EggRouter = () => {
const { id, nestId } = useParams<'nestId' | 'id'>();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: egg, error, isValidating, mutate } = useEggFromRoute();
useEffect(() => {
mutate();
}, []);
useEffect(() => {
if (!error) clearFlashes('egg');
if (error) clearAndAddHttpError({ key: 'egg', error });
}, [error]);
if (!egg || (error && isValidating)) {
return (
<AdminContentBlock showFlashKey={'egg'}>
<Spinner size={'large'} centered />
</AdminContentBlock>
);
}
return (
<AdminContentBlock title={'Egg - ' + egg.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`}>{egg.name}</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
{egg.uuid}
</p>
</div>
</div>
<FlashMessageRender byKey={'egg'} css={tw`mb-4`} />
<SubNavigation>
<SubNavigationLink to={`/admin/nests/${nestId ?? ''}/eggs/${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/nests/${nestId ?? ''}/eggs/${id ?? ''}/variables`} name={'Variables'}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M5 4a1 1 0 00-2 0v7.268a2 2 0 000 3.464V16a1 1 0 102 0v-1.268a2 2 0 000-3.464V4zM11 4a1 1 0 10-2 0v1.268a2 2 0 000 3.464V16a1 1 0 102 0V8.732a2 2 0 000-3.464V4zM16 3a1 1 0 011 1v7.268a2 2 0 010 3.464V16a1 1 0 11-2 0v-1.268a2 2 0 010-3.464V4a1 1 0 011-1z" />
</svg>
</SubNavigationLink>
<SubNavigationLink to={`/admin/nests/${nestId ?? ''}/eggs/${id ?? ''}/install`} name={'Install Script'}>
<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={<EggSettingsContainer />} />
<Route path="variables" element={<EggVariablesContainer />} />
<Route path="install" element={<EggInstallContainer />} />
</Routes>
</AdminContentBlock>
);
};
export default () => {
return <EggRouter />;
};

View file

@ -0,0 +1,245 @@
import { useEggFromRoute } from '@/api/admin/egg';
import updateEgg from '@/api/admin/eggs/updateEgg';
import EggDeleteButton from '@/components/admin/nests/eggs/EggDeleteButton';
import EggExportButton from '@/components/admin/nests/eggs/EggExportButton';
import Button from '@/components/elements/Button';
// import Editor from '@/components/elements/Editor';
import Field, { TextareaField } from '@/components/elements/Field';
import Input from '@/components/elements/Input';
import Label from '@/components/elements/Label';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFlash from '@/plugins/useFlash';
// import { jsonLanguage } from '@codemirror/lang-json';
import { faDocker } from '@fortawesome/free-brands-svg-icons';
import { faEgg, faFireAlt, faMicrochip, faTerminal } from '@fortawesome/free-solid-svg-icons';
import { forwardRef, useImperativeHandle, useRef } from 'react';
import AdminBox from '@/components/admin/AdminBox';
import { useNavigate } from 'react-router-dom';
import tw from 'twin.macro';
import { object } from 'yup';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
export function EggInformationContainer() {
const { isSubmitting } = useFormikContext();
return (
<AdminBox icon={faEgg} title={'Egg Information'} css={tw`relative`}>
<SpinnerOverlay visible={isSubmitting} />
<Field id={'name'} name={'name'} label={'Name'} type={'text'} css={tw`mb-6`} />
<Field id={'description'} name={'description'} label={'Description'} type={'text'} css={tw`mb-2`} />
</AdminBox>
);
}
function EggDetailsContainer() {
const { data: egg } = useEggFromRoute();
if (!egg) {
return null;
}
return (
<AdminBox icon={faEgg} title={'Egg Details'} css={tw`relative`}>
<div css={tw`mb-6`}>
<Label>UUID</Label>
<Input id={'uuid'} name={'uuid'} type={'text'} value={egg.uuid} readOnly />
</div>
<div css={tw`mb-2`}>
<Label>Author</Label>
<Input id={'author'} name={'author'} type={'text'} value={egg.author} readOnly />
</div>
</AdminBox>
);
}
export function EggStartupContainer({ className }: { className?: string }) {
const { isSubmitting } = useFormikContext();
return (
<AdminBox icon={faTerminal} title={'Startup Command'} css={tw`relative`} className={className}>
<SpinnerOverlay visible={isSubmitting} />
<Field id={'startup'} name={'startup'} label={'Startup Command'} type={'text'} css={tw`mb-1`} />
</AdminBox>
);
}
export function EggImageContainer() {
const { isSubmitting } = useFormikContext();
return (
<AdminBox icon={faDocker} title={'Docker'} css={tw`relative`}>
<SpinnerOverlay visible={isSubmitting} />
<TextareaField id={'dockerImages'} name={'dockerImages'} label={'Docker Images'} rows={5} />
</AdminBox>
);
}
export function EggLifecycleContainer() {
const { isSubmitting } = useFormikContext();
return (
<AdminBox icon={faFireAlt} title={'Lifecycle'} css={tw`relative`}>
<SpinnerOverlay visible={isSubmitting} />
<Field id={'configStop'} name={'configStop'} label={'Stop Command'} type={'text'} css={tw`mb-1`} />
</AdminBox>
);
}
interface EggProcessContainerProps {
className?: string;
}
export interface EggProcessContainerRef {
getStartupConfiguration: () => Promise<string | null>;
getFilesConfiguration: () => Promise<string | null>;
}
export const EggProcessContainer = forwardRef<any, EggProcessContainerProps>(function EggProcessContainer(
{ className },
ref,
) {
// const { isSubmitting, values } = useFormikContext<Values>();
const { isSubmitting } = useFormikContext<Values>();
let fetchStartupConfiguration: (() => Promise<string>) | null = null;
let fetchFilesConfiguration: (() => Promise<string>) | null = null;
useImperativeHandle<EggProcessContainerRef, EggProcessContainerRef>(ref, () => ({
getStartupConfiguration: async () => {
if (fetchStartupConfiguration === null) {
return new Promise<null>(resolve => resolve(null));
}
return await fetchStartupConfiguration();
},
getFilesConfiguration: async () => {
if (fetchFilesConfiguration === null) {
return new Promise<null>(resolve => resolve(null));
}
return await fetchFilesConfiguration();
},
}));
return (
<AdminBox icon={faMicrochip} title={'Process Configuration'} css={tw`relative`} className={className}>
<SpinnerOverlay visible={isSubmitting} />
<div css={tw`mb-5`}>
<Label>Startup Configuration</Label>
{/*<Editor*/}
{/* mode={jsonLanguage}*/}
{/* initialContent={values.configStartup}*/}
{/* overrides={tw`h-32 rounded`}*/}
{/* fetchContent={value => {*/}
{/* fetchStartupConfiguration = value;*/}
{/* }}*/}
{/*/>*/}
</div>
<div css={tw`mb-1`}>
<Label>Configuration Files</Label>
{/*<Editor*/}
{/* mode={jsonLanguage}*/}
{/* initialContent={values.configFiles}*/}
{/* overrides={tw`h-48 rounded`}*/}
{/* fetchContent={value => {*/}
{/* fetchFilesConfiguration = value;*/}
{/* }}*/}
{/*/>*/}
</div>
</AdminBox>
);
});
interface Values {
name: string;
description: string;
startup: string;
dockerImages: string;
configStop: string;
configStartup: string;
configFiles: string;
}
export default function EggSettingsContainer() {
const navigate = useNavigate();
const ref = useRef<EggProcessContainerRef>();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: egg } = useEggFromRoute();
if (!egg) {
return null;
}
const submit = async (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('egg');
values.configStartup = (await ref.current?.getStartupConfiguration()) || '';
values.configFiles = (await ref.current?.getFilesConfiguration()) || '';
updateEgg(egg.id, {
...values,
// TODO
dockerImages: {},
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'egg', error });
})
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={{
name: egg.name,
description: egg.description || '',
startup: egg.startup,
// TODO
dockerImages: egg.dockerImages.toString(),
configStop: egg.configStop || '',
configStartup: JSON.stringify(egg.configStartup, null, '\t') || '',
configFiles: JSON.stringify(egg.configFiles, null, '\t') || '',
}}
validationSchema={object().shape({})}
>
{({ isSubmitting, isValid }) => (
<Form>
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
<EggInformationContainer />
<EggDetailsContainer />
</div>
<EggStartupContainer css={tw`mb-6`} />
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
<EggImageContainer />
<EggLifecycleContainer />
</div>
<EggProcessContainer ref={ref} css={tw`mb-6`} />
<div css={tw`bg-neutral-700 rounded shadow-md px-4 xl:px-5 py-4 mb-16`}>
<div css={tw`flex flex-row`}>
<EggDeleteButton eggId={egg.id} onDeleted={() => navigate('/admin/nests')} />
<EggExportButton css={tw`ml-auto mr-4`} />
<Button type="submit" size="small" disabled={isSubmitting || !isValid}>
Save Changes
</Button>
</div>
</div>
</Form>
)}
</Formik>
);
}

View file

@ -0,0 +1,218 @@
import { TrashIcon } from '@heroicons/react/outline';
import type { FormikHelpers } from 'formik';
import { Form, Formik, useFormikContext } from 'formik';
import { useState } from 'react';
import tw from 'twin.macro';
import { array, boolean, object, string } from 'yup';
import deleteEggVariable from '@/api/admin/eggs/deleteEggVariable';
import updateEggVariables from '@/api/admin/eggs/updateEggVariables';
import { NoItems } from '@/components/admin/AdminTable';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import type { EggVariable } from '@/api/admin/egg';
import { useEggFromRoute } from '@/api/admin/egg';
import NewVariableButton from '@/components/admin/nests/eggs/NewVariableButton';
import AdminBox from '@/components/admin/AdminBox';
import Button from '@/components/elements/Button';
import Checkbox from '@/components/elements/Checkbox';
import Field, { FieldRow, TextareaField } from '@/components/elements/Field';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFlash from '@/plugins/useFlash';
export const validationSchema = object().shape({
name: string().required().min(1).max(191),
description: string(),
environmentVariable: string().required().min(1).max(191),
defaultValue: string(),
isUserViewable: boolean().required(),
isUserEditable: boolean().required(),
rules: string().required(),
});
export function EggVariableForm({ prefix }: { prefix: string }) {
return (
<>
<Field id={`${prefix}name`} name={`${prefix}name`} label={'Name'} type={'text'} css={tw`mb-6`} />
<TextareaField
id={`${prefix}description`}
name={`${prefix}description`}
label={'Description'}
rows={3}
css={tw`mb-4`}
/>
<FieldRow>
<Field
id={`${prefix}environmentVariable`}
name={`${prefix}environmentVariable`}
label={'Environment Variable'}
type={'text'}
/>
<Field
id={`${prefix}defaultValue`}
name={`${prefix}defaultValue`}
label={'Default Value'}
type={'text'}
/>
</FieldRow>
<div css={tw`flex flex-row mb-6`}>
<Checkbox id={`${prefix}isUserViewable`} name={`${prefix}isUserViewable`} label={'User Viewable'} />
<Checkbox
id={`${prefix}isUserEditable`}
name={`${prefix}isUserEditable`}
label={'User Editable'}
css={tw`ml-auto`}
/>
</div>
<Field
id={`${prefix}rules`}
name={`${prefix}rules`}
label={'Validation Rules'}
type={'text'}
css={tw`mb-2`}
/>
</>
);
}
function EggVariableDeleteButton({ onClick }: { onClick: (success: () => void) => void }) {
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
const onDelete = () => {
setLoading(true);
onClick(() => {
//setLoading(false);
});
};
return (
<>
<ConfirmationModal
visible={visible}
title={'Delete variable?'}
buttonText={'Yes, delete variable'}
onConfirmed={onDelete}
showSpinnerOverlay={loading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this variable? Deleting this variable will delete it from every server
using this egg.
</ConfirmationModal>
<button
type={'button'}
css={tw`ml-auto text-neutral-500 hover:text-neutral-300`}
onClick={() => setVisible(true)}
>
<TrashIcon css={tw`h-5 w-5`} />
</button>
</>
);
}
function EggVariableBox({
onDeleteClick,
variable,
prefix,
}: {
onDeleteClick: (success: () => void) => void;
variable: EggVariable;
prefix: string;
}) {
const { isSubmitting } = useFormikContext();
return (
<AdminBox
css={tw`relative w-full`}
title={<p css={tw`text-sm uppercase`}>{variable.name}</p>}
button={<EggVariableDeleteButton onClick={onDeleteClick} />}
>
<SpinnerOverlay visible={isSubmitting} />
<EggVariableForm prefix={prefix} />
</AdminBox>
);
}
export default function EggVariablesContainer() {
const { clearAndAddHttpError } = useFlash();
const { data: egg, mutate } = useEggFromRoute();
if (!egg) {
return null;
}
const submit = (values: EggVariable[], { setSubmitting }: FormikHelpers<EggVariable[]>) => {
updateEggVariables(egg.id, values)
.then(async () => await mutate())
.catch(error => clearAndAddHttpError({ key: 'egg', error }))
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={egg.relationships.variables}
validationSchema={array().of(validationSchema)}
>
{({ isSubmitting, isValid }) => (
<Form>
<div css={tw`flex flex-col mb-16`}>
{egg.relationships.variables?.length === 0 ? (
<NoItems css={tw`bg-neutral-700 rounded-md shadow-md`} />
) : (
<div css={tw`grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-x-8 gap-y-6`}>
{egg.relationships.variables.map((v, i) => (
<EggVariableBox
key={i}
prefix={`[${i}].`}
variable={v}
onDeleteClick={success => {
deleteEggVariable(egg.id, v.id)
.then(async () => {
await mutate(egg => ({
...egg!,
relationships: {
...egg!.relationships,
variables: egg!.relationships.variables!.filter(
v2 => v.id === v2.id,
),
},
}));
success();
})
.catch(error => clearAndAddHttpError({ key: 'egg', error }));
}}
/>
))}
</div>
)}
<div css={tw`bg-neutral-700 rounded shadow-md py-2 px-4 mt-6`}>
<div css={tw`flex flex-row`}>
<NewVariableButton />
<Button
type={'submit'}
size={'small'}
css={tw`ml-auto`}
disabled={isSubmitting || !isValid}
>
Save Changes
</Button>
</div>
</div>
</div>
</Form>
)}
</Formik>
);
}

View file

@ -0,0 +1,103 @@
import type { FormikHelpers } from 'formik';
import { Form, Formik, useFormikContext } from 'formik';
import { useState } from 'react';
import tw from 'twin.macro';
import type { CreateEggVariable } from '@/api/admin/eggs/createEggVariable';
import createEggVariable from '@/api/admin/eggs/createEggVariable';
import { useEggFromRoute } from '@/api/admin/egg';
import { EggVariableForm, validationSchema } from '@/components/admin/nests/eggs/EggVariablesContainer';
import Modal from '@/components/elements/Modal';
import FlashMessageRender from '@/components/FlashMessageRender';
import Button from '@/components/elements/Button';
import useFlash from '@/plugins/useFlash';
export default function NewVariableButton() {
const { setValues } = useFormikContext();
const [visible, setVisible] = useState(false);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: egg, mutate } = useEggFromRoute();
if (!egg) {
return null;
}
const submit = (values: CreateEggVariable, { setSubmitting }: FormikHelpers<CreateEggVariable>) => {
clearFlashes('variable:create');
createEggVariable(egg.id, values)
.then(async variable => {
setValues([...egg.relationships.variables, variable]);
await mutate(egg => ({
...egg!,
relationships: { ...egg!.relationships, variables: [...egg!.relationships.variables, variable] },
}));
setVisible(false);
})
.catch(error => {
clearAndAddHttpError({ key: 'variable:create', error });
setSubmitting(false);
});
};
return (
<>
<Formik
onSubmit={submit}
initialValues={{
name: '',
description: '',
environmentVariable: '',
defaultValue: '',
isUserViewable: false,
isUserEditable: false,
rules: '',
}}
validationSchema={validationSchema}
>
{({ isSubmitting, isValid, resetForm }) => (
<Modal
visible={visible}
dismissable={!isSubmitting}
showSpinnerOverlay={isSubmitting}
onDismissed={() => {
resetForm();
setVisible(false);
}}
>
<FlashMessageRender byKey={'variable:create'} css={tw`mb-6`} />
<h2 css={tw`mb-6 text-2xl text-neutral-100`}>New Variable</h2>
<Form css={tw`m-0`}>
<EggVariableForm prefix={''} />
<div css={tw`flex flex-wrap justify-end mt-6`}>
<Button
type={'button'}
isSecondary
css={tw`w-full sm:w-auto sm:mr-2`}
onClick={() => setVisible(false)}
>
Cancel
</Button>
<Button
css={tw`w-full mt-4 sm:w-auto sm:mt-0`}
type={'submit'}
disabled={isSubmitting || !isValid}
>
Create Variable
</Button>
</div>
</Form>
</Modal>
)}
</Formik>
<Button type={'button'} color={'green'} onClick={() => setVisible(true)}>
New Variable
</Button>
</>
);
}

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

View file

@ -0,0 +1,103 @@
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import tw from 'twin.macro';
import type { VersionData } from '@/api/admin/getVersion';
import getVersion from '@/api/admin/getVersion';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import FlashMessageRender from '@/components/FlashMessageRender';
import Spinner from '@/components/elements/Spinner';
import useFlash from '@/plugins/useFlash';
const Code = ({ children }: { children: ReactNode }) => {
return (
<code css={tw`text-sm font-mono bg-neutral-900 rounded`} style={{ padding: '2px 6px' }}>
{children}
</code>
);
};
export default () => {
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [loading, setLoading] = useState<boolean>(true);
const [versionData, setVersionData] = useState<VersionData | undefined>(undefined);
useEffect(() => {
clearFlashes('overview');
getVersion()
.then(versionData => setVersionData(versionData))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'overview', error });
})
.then(() => setLoading(false));
}, []);
return (
<AdminContentBlock title={'Overview'}>
<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`}>Overview</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
A quick glance at your system.
</p>
</div>
</div>
<FlashMessageRender byKey={'overview'} css={tw`mb-4`} />
<div css={tw`flex flex-col w-full rounded-lg shadow-md bg-neutral-700`}>
{loading ? (
<div css={tw`w-full flex flex-col items-center justify-center`} style={{ height: '16rem' }}>
<Spinner size={'base'} />
</div>
) : (
<div css={tw`rounded shadow-md bg-neutral-700`}>
<div css={tw`bg-neutral-900 rounded-t border-b border-black px-4 py-3`}>
<p css={tw`text-sm uppercase`}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
css={tw`inline-block mr-2`}
style={{ height: '1rem' }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
System Information
</p>
</div>
<div css={tw`px-4 py-4`}>
{versionData?.panel.current === 'canary' ? (
<p css={tw`text-neutral-200`}>
I hope you enjoy living on the edge because you are running a{' '}
<Code>{versionData?.panel.current}</Code> version of Pterodactyl.
</p>
) : versionData?.panel.latest === versionData?.panel.current ? (
<p css={tw`text-neutral-200`}>
Your panel is <span css={tw`text-neutral-100`}>up-to-date</span>. The latest version
is <Code>{versionData?.panel.latest}</Code> and you are running version{' '}
<Code>{versionData?.panel.current}</Code>.
</p>
) : (
<p css={tw`text-neutral-200`}>
Your panel is <span css={tw`text-neutral-100`}>not up-to-date</span>. The latest
version is <Code>{versionData?.panel.latest}</Code> and you are running version{' '}
<Code>{versionData?.panel.current}</Code>.
</p>
)}
</div>
</div>
)}
</div>
</AdminContentBlock>
);
};

View file

@ -0,0 +1,107 @@
import type { FormikHelpers } from 'formik';
import { Form, Formik } from 'formik';
import { useState } from 'react';
import tw from 'twin.macro';
import { object, string } from 'yup';
import { getRoles, createRole } from '@/api/admin/roles';
import FlashMessageRender from '@/components/FlashMessageRender';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import Modal from '@/components/elements/Modal';
import useFlash from '@/plugins/useFlash';
interface Values {
name: string;
description: string;
}
const schema = object().shape({
name: string().required('A role name must be provided.').max(32, 'Role name must not exceed 32 characters.'),
description: string().max(255, 'Role description must not exceed 255 characters.'),
});
export default () => {
const [visible, setVisible] = useState(false);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { mutate } = getRoles();
const submit = ({ name, description }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('role:create');
setSubmitting(true);
createRole(name, description)
.then(async role => {
await mutate(data => ({ ...data!, items: data!.items.concat(role) }), false);
setVisible(false);
})
.catch(error => {
clearAndAddHttpError({ key: 'role:create', error });
setSubmitting(false);
});
};
return (
<>
<Formik onSubmit={submit} initialValues={{ name: '', description: '' }} validationSchema={schema}>
{({ isSubmitting, resetForm }) => (
<Modal
visible={visible}
dismissable={!isSubmitting}
showSpinnerOverlay={isSubmitting}
onDismissed={() => {
resetForm();
setVisible(false);
}}
>
<FlashMessageRender byKey={'role:create'} css={tw`mb-6`} />
<h2 css={tw`mb-6 text-2xl text-neutral-100`}>New Role</h2>
<Form css={tw`m-0`}>
<Field
type={'text'}
id={'name'}
name={'name'}
label={'Name'}
description={'A short name used to identify this role.'}
autoFocus
/>
<div css={tw`mt-6`}>
<Field
type={'text'}
id={'description'}
name={'description'}
label={'Description'}
description={'A description for this role.'}
/>
</div>
<div css={tw`flex flex-wrap justify-end mt-6`}>
<Button
type={'button'}
isSecondary
css={tw`w-full sm:w-auto sm:mr-2`}
onClick={() => setVisible(false)}
>
Cancel
</Button>
<Button css={tw`w-full mt-4 sm:w-auto sm:mt-0`} type={'submit'}>
Create Role
</Button>
</div>
</Form>
</Modal>
)}
</Formik>
<Button
type={'button'}
size={'large'}
css={tw`h-10 px-4 py-0 whitespace-nowrap`}
onClick={() => setVisible(true)}
>
New Role
</Button>
</>
);
};

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 { deleteRole } from '@/api/admin/roles';
import Button from '@/components/elements/Button';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import type { ApplicationStore } from '@/state';
interface Props {
roleId: number;
onDeleted: () => void;
}
export default ({ roleId, 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('role');
deleteRole(roleId)
.then(() => {
setLoading(false);
onDeleted();
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'role', error });
setLoading(false);
setVisible(false);
});
};
return (
<>
<ConfirmationModal
visible={visible}
title={'Delete role?'}
buttonText={'Yes, delete role'}
onConfirmed={onDelete}
showSpinnerOverlay={loading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this role?
</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,176 @@
import type { Action, Actions } from 'easy-peasy';
import { action, createContextStore, useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { Form, Formik } from 'formik';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import tw from 'twin.macro';
import { object, string } from 'yup';
import { getRole, updateRole } from '@/api/admin/roles';
import FlashMessageRender from '@/components/FlashMessageRender';
import AdminBox from '@/components/admin/AdminBox';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import RoleDeleteButton from '@/components/admin/roles/RoleDeleteButton';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import Spinner from '@/components/elements/Spinner';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import type { UserRole } from '@definitions/admin';
import type { ApplicationStore } from '@/state';
interface ctx {
role: UserRole | undefined;
setRole: Action<ctx, UserRole | undefined>;
}
export const Context = createContextStore<ctx>({
role: undefined,
setRole: action((state, payload) => {
state.role = payload;
}),
});
interface Values {
name: string;
description: string;
}
const EditInformationContainer = () => {
const navigate = useNavigate();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const role = Context.useStoreState(state => state.role);
const setRole = Context.useStoreActions(actions => actions.setRole);
if (role === undefined) {
return <></>;
}
const submit = ({ name, description }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('role');
updateRole(role.id, name, description)
.then(() => setRole({ ...role, name, description }))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'role', error });
})
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={{
name: role.name,
description: role.description || '',
}}
validationSchema={object().shape({
name: string().required().min(1),
description: string().max(255, ''),
})}
>
{({ isSubmitting, isValid }) => (
<>
<AdminBox title={'Edit Role'} css={tw`relative`}>
<SpinnerOverlay visible={isSubmitting} />
<Form css={tw`mb-0`}>
<div>
<Field id={'name'} name={'name'} label={'Name'} type={'text'} />
</div>
<div css={tw`mt-6`}>
<Field id={'description'} name={'description'} label={'description'} type={'text'} />
</div>
<div css={tw`w-full flex flex-row items-center mt-6`}>
<div css={tw`flex`}>
<RoleDeleteButton roleId={role.id} onDeleted={() => navigate('/admin/roles')} />
</div>
<div css={tw`flex ml-auto`}>
<Button type={'submit'} disabled={isSubmitting || !isValid}>
Save Changes
</Button>
</div>
</div>
</Form>
</AdminBox>
</>
)}
</Formik>
);
};
const RoleEditContainer = () => {
const params = useParams<'id'>();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const [loading, setLoading] = useState(true);
const role = Context.useStoreState(state => state.role);
const setRole = Context.useStoreActions(actions => actions.setRole);
useEffect(() => {
clearFlashes('role');
getRole(Number(params.id))
.then(role => setRole(role))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'role', error });
})
.then(() => setLoading(false));
}, []);
if (loading || role === undefined) {
return (
<AdminContentBlock>
<FlashMessageRender byKey={'role'} 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={'Role - ' + role.name}>
<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`}>{role.name}</h2>
{(role.description || '').length < 1 ? (
<p css={tw`text-base text-neutral-400`}>
<span css={tw`italic`}>No description</span>
</p>
) : (
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
{role.description}
</p>
)}
</div>
</div>
<FlashMessageRender byKey={'role'} css={tw`mb-4`} />
<EditInformationContainer />
</AdminContentBlock>
);
};
export default () => {
return (
<Context.Provider>
<RoleEditContainer />
</Context.Provider>
);
};

View file

@ -0,0 +1,182 @@
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/roles';
import { getRoles, Context as RolesContext } from '@/api/admin/roles';
import { AdminContext } from '@/state/admin';
import NewRoleButton from '@/components/admin/roles/NewRoleButton';
import FlashMessageRender from '@/components/FlashMessageRender';
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 CopyOnClick from '@/components/elements/CopyOnClick';
import useFlash from '@/plugins/useFlash';
const RowCheckbox = ({ id }: { id: number }) => {
const isChecked = AdminContext.useStoreState(state => state.roles.selectedRoles.indexOf(id) >= 0);
const appendSelectedRole = AdminContext.useStoreActions(actions => actions.roles.appendSelectedRole);
const removeSelectedRole = AdminContext.useStoreActions(actions => actions.roles.removeSelectedRole);
return (
<AdminCheckbox
name={id.toString()}
checked={isChecked}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
appendSelectedRole(id);
} else {
removeSelectedRole(id);
}
}}
/>
);
};
const RolesContainer = () => {
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(RolesContext);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: roles, error, isValidating } = getRoles();
useEffect(() => {
if (!error) {
clearFlashes('roles');
return;
}
clearAndAddHttpError({ key: 'roles', error });
}, [error]);
const length = roles?.items?.length || 0;
const setSelectedRoles = AdminContext.useStoreActions(actions => actions.roles.setSelectedRoles);
const selectedRolesLength = AdminContext.useStoreState(state => state.roles.selectedRoles.length);
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
setSelectedRoles(e.currentTarget.checked ? roles?.items?.map(role => role.id) || [] : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise(resolve => {
if (query.length < 2) {
setFilters(null);
} else {
setFilters({ name: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedRoles([]);
}, [page]);
return (
<AdminContentBlock title={'Roles'}>
<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`}>Roles</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
Soon&trade;
</p>
</div>
<div css={tw`flex ml-auto pl-4`}>
<NewRoleButton />
</div>
</div>
<FlashMessageRender byKey={'roles'} css={tw`mb-4`} />
<AdminTable>
<ContentWrapper
checked={selectedRolesLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={roles} 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={'Description'} />
</TableHead>
<TableBody>
{roles !== undefined &&
!error &&
!isValidating &&
length > 0 &&
roles.items.map(role => (
<TableRow key={role.id}>
<td css={tw`pl-6`}>
<RowCheckbox id={role.id} />
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={role.id.toString()}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{role.id}
</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink
to={`/admin/roles/${role.id}`}
css={tw`text-primary-400 hover:text-primary-300`}
>
{role.name}
</NavLink>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
{role.description}
</td>
</TableRow>
))}
</TableBody>
</table>
{roles === undefined || (error && isValidating) ? (
<Loading />
) : length < 1 ? (
<NoItems />
) : null}
</div>
</Pagination>
</ContentWrapper>
</AdminTable>
</AdminContentBlock>
);
};
export default () => {
const hooks = useTableHooks<Filters>();
return (
<RolesContext.Provider value={hooks}>
<RolesContainer />
</RolesContext.Provider>
);
};

View file

@ -0,0 +1,75 @@
import { useField } from 'formik';
import type { ChangeEvent } from 'react';
import { useEffect, useState } from 'react';
import type { WithRelationships } from '@/api/admin';
import type { Egg } from '@/api/admin/egg';
import { searchEggs } from '@/api/admin/egg';
import Label from '@/components/elements/Label';
import Select from '@/components/elements/Select';
interface Props {
nestId?: number;
selectedEggId?: number;
onEggSelect: (egg: Egg | null) => void;
}
export default ({ nestId, selectedEggId, onEggSelect }: Props) => {
const [, , { setValue, setTouched }] = useField<Record<string, string | undefined>>('environment');
const [eggs, setEggs] = useState<WithRelationships<Egg, 'variables'>[] | null>(null);
const selectEgg = (egg: Egg | null) => {
if (egg === null) {
onEggSelect(null);
return;
}
// Clear values
setValue({});
setTouched(true);
onEggSelect(egg);
const values: Record<string, any> = {};
egg.relationships.variables?.forEach(v => {
values[v.environmentVariable] = v.defaultValue;
});
setValue(values);
setTouched(true);
};
useEffect(() => {
if (!nestId) {
setEggs(null);
return;
}
searchEggs(nestId, {})
.then(eggs => {
setEggs(eggs);
selectEgg(eggs[0] || null);
})
.catch(error => console.error(error));
}, [nestId]);
const onSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
selectEgg(eggs?.find(egg => egg.id.toString() === e.currentTarget.value) || null);
};
return (
<>
<Label>Egg</Label>
<Select id={'eggId'} name={'eggId'} defaultValue={selectedEggId} onChange={onSelectChange}>
{!eggs ? (
<option disabled>Loading...</option>
) : (
eggs.map(v => (
<option key={v.id} value={v.id.toString()}>
{v.name}
</option>
))
)}
</Select>
</>
);
};

View file

@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import type { Nest } from '@/api/admin/nest';
import { searchNests } from '@/api/admin/nest';
import Label from '@/components/elements/Label';
import Select from '@/components/elements/Select';
interface Props {
selectedNestId?: number;
onNestSelect: (nest: number) => void;
}
export default ({ selectedNestId, onNestSelect }: Props) => {
const [nests, setNests] = useState<Nest[] | null>(null);
useEffect(() => {
searchNests({})
.then(nests => {
setNests(nests);
if (selectedNestId === 0 && nests.length > 0) {
// @ts-expect-error go away
onNestSelect(nests[0].id);
}
})
.catch(error => console.error(error));
}, []);
return (
<>
<Label>Nest</Label>
<Select value={selectedNestId} onChange={e => onNestSelect(Number(e.currentTarget.value))}>
{!nests ? (
<option disabled>Loading...</option>
) : (
nests?.map(v => (
<option key={v.uuid} value={v.id.toString()}>
{v.name}
</option>
))
)}
</Select>
</>
);
};

View file

@ -0,0 +1,225 @@
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import type { FormikHelpers } from 'formik';
import { Form, Formik, useFormikContext } from 'formik';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import tw from 'twin.macro';
import { object } from 'yup';
import type { Egg } from '@/api/admin/egg';
import type { CreateServerRequest } from '@/api/admin/servers/createServer';
import createServer from '@/api/admin/servers/createServer';
import type { Allocation, Node } from '@/api/admin/node';
import { getAllocations } from '@/api/admin/node';
import AdminBox from '@/components/admin/AdminBox';
import NodeSelect from '@/components/admin/servers/NodeSelect';
import {
ServerImageContainer,
ServerServiceContainer,
ServerVariableContainer,
} from '@/components/admin/servers/ServerStartupContainer';
import BaseSettingsBox from '@/components/admin/servers/settings/BaseSettingsBox';
import FeatureLimitsBox from '@/components/admin/servers/settings/FeatureLimitsBox';
import ServerResourceBox from '@/components/admin/servers/settings/ServerResourceBox';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import FormikSwitch from '@/components/elements/FormikSwitch';
import Label from '@/components/elements/Label';
import Select from '@/components/elements/Select';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
function InternalForm() {
const {
isSubmitting,
isValid,
setFieldValue,
values: { environment },
} = useFormikContext<CreateServerRequest>();
const [egg, setEgg] = useState<Egg | null>(null);
const [node, setNode] = useState<Node | null>(null);
const [allocations, setAllocations] = useState<Allocation[] | null>(null);
useEffect(() => {
if (egg === null) {
return;
}
setFieldValue('eggId', egg.id);
setFieldValue('startup', '');
setFieldValue('image', egg.dockerImages.length > 0 ? egg.dockerImages[0] : '');
}, [egg]);
useEffect(() => {
if (node === null) {
return;
}
// server_id: 0 filters out assigned allocations
getAllocations(node.id, { filters: { server_id: '0' } }).then(setAllocations);
}, [node]);
return (
<Form>
<div css={tw`grid grid-cols-2 gap-y-6 gap-x-8 mb-16`}>
<div css={tw`grid grid-cols-1 gap-y-6 col-span-2 md:col-span-1`}>
<BaseSettingsBox>
<NodeSelect node={node} setNode={setNode} />
<div css={tw`xl:col-span-2 bg-neutral-800 border border-neutral-900 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'startOnCompletion'}
label={'Start after installation'}
description={'Should the server be automatically started after it has been installed?'}
/>
</div>
</BaseSettingsBox>
<FeatureLimitsBox />
<ServerServiceContainer egg={egg} setEgg={setEgg} nestId={0} />
</div>
<div css={tw`grid grid-cols-1 gap-y-6 col-span-2 md:col-span-1`}>
<AdminBox icon={faNetworkWired} title={'Networking'} isLoading={isSubmitting}>
<div css={tw`grid grid-cols-1 gap-4 lg:gap-6`}>
<div>
<Label htmlFor={'allocation.default'}>Primary Allocation</Label>
<Select
id={'allocation.default'}
name={'allocation.default'}
disabled={node === null}
onChange={e => setFieldValue('allocation.default', Number(e.currentTarget.value))}
>
{node === null ? (
<option value="">Select a node...</option>
) : (
<option value="">Select an allocation...</option>
)}
{allocations?.map(a => (
<option key={a.id} value={a.id.toString()}>
{a.getDisplayText()}
</option>
))}
</Select>
</div>
{/*<div>*/}
{/* /!* TODO: Multi-select *!/*/}
{/* <Label htmlFor={'allocation.additional'}>Additional Allocations</Label>*/}
{/* <Select id={'allocation.additional'} name={'allocation.additional'} disabled={node === null}>*/}
{/* {node === null ? <option value="">Select a node...</option> : <option value="">Select additional allocations...</option>}*/}
{/* {allocations?.map(a => <option key={a.id} value={a.id.toString()}>{a.getDisplayText()}</option>)}*/}
{/* </Select>*/}
{/*</div>*/}
</div>
</AdminBox>
<ServerResourceBox />
<ServerImageContainer />
</div>
<AdminBox title={'Startup Command'} css={tw`relative w-full col-span-2`}>
<SpinnerOverlay visible={isSubmitting} />
<Field
id={'startup'}
name={'startup'}
label={'Startup Command'}
type={'text'}
description={
"Edit your server's startup command here. The following variables are available by default: {{SERVER_MEMORY}}, {{SERVER_IP}}, and {{SERVER_PORT}}."
}
placeholder={egg?.startup || ''}
/>
</AdminBox>
<div css={tw`col-span-2 grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-8`}>
{/* This ensures that no variables are rendered unless the environment has a value for the variable. */}
{egg?.relationships.variables
?.filter(v => Object.keys(environment).find(e => e === v.environmentVariable) !== undefined)
.map((v, i) => (
<ServerVariableContainer key={i} variable={v} />
))}
</div>
<div css={tw`bg-neutral-700 rounded shadow-md px-4 py-3 col-span-2`}>
<div css={tw`flex flex-row`}>
<Button type="submit" size="small" css={tw`ml-auto`} disabled={isSubmitting || !isValid}>
Create Server
</Button>
</div>
</div>
</div>
</Form>
);
}
export default () => {
const navigate = useNavigate();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const submit = (r: CreateServerRequest, { setSubmitting }: FormikHelpers<CreateServerRequest>) => {
clearFlashes('server:create');
createServer(r)
.then(s => navigate(`/admin/servers/${s.id}`))
.catch(error => clearAndAddHttpError({ key: 'server:create', error }))
.then(() => setSubmitting(false));
};
return (
<AdminContentBlock title={'New Server'}>
<div css={tw`w-full flex flex-row items-center mb-8`}>
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>New Server</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
Add a new server to the panel.
</p>
</div>
</div>
<FlashMessageRender byKey={'server:create'} css={tw`mb-4`} />
<Formik
onSubmit={submit}
initialValues={
{
externalId: '',
name: '',
description: '',
ownerId: 0,
nodeId: 0,
limits: {
memory: 1024,
swap: 0,
disk: 4096,
io: 500,
cpu: 0,
threads: '',
// This value is inverted to have the switch be on when the
// OOM Killer is enabled, rather than when disabled.
oomDisabled: false,
},
featureLimits: {
allocations: 1,
backups: 0,
databases: 0,
},
allocation: {
default: 0,
additional: [] as number[],
},
startup: '',
environment: [],
eggId: 0,
image: '',
skipScripts: false,
startOnCompletion: true,
} as CreateServerRequest
}
validationSchema={object().shape({})}
>
<InternalForm />
</Formik>
</AdminContentBlock>
);
};

View file

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

View file

@ -0,0 +1,47 @@
import { useFormikContext } from 'formik';
import { useState } from 'react';
import { searchUserAccounts } from '@/api/admin/users';
import SearchableSelect, { Option } from '@/components/elements/SearchableSelect';
import type { User } from '@definitions/admin';
export default ({ selected }: { selected?: User }) => {
const { setFieldValue } = useFormikContext();
const [user, setUser] = useState<User | null>(selected || null);
const [users, setUsers] = useState<User[] | null>(null);
const onSearch = async (query: string) => {
setUsers(await searchUserAccounts({ filters: { username: query, email: query } }));
};
const onSelect = (user: User | null) => {
setUser(user);
setFieldValue('ownerId', user?.id || null);
};
const getSelectedText = (user: User | null): string => user?.email || '';
return (
<SearchableSelect
id={'ownerId'}
name={'ownerId'}
label={'Owner'}
placeholder={'Select a user...'}
items={users}
selected={user}
setSelected={setUser}
setItems={setUsers}
onSearch={onSearch}
onSelect={onSelect}
getSelectedText={getSelectedText}
nullable
>
{users?.map(d => (
<Option key={d.id} selectId={'ownerId'} id={d.id} item={d} active={d.id === user?.id}>
{d.email}
</Option>
))}
</SearchableSelect>
);
};

View file

@ -0,0 +1,66 @@
import { TrashIcon } from '@heroicons/react/outline';
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import deleteServer from '@/api/admin/servers/deleteServer';
import { useServerFromRoute } from '@/api/admin/server';
import type { ApplicationStore } from '@/state';
export default () => {
const navigate = useNavigate();
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
const { data: server } = useServerFromRoute();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const onDelete = () => {
if (!server) return;
setLoading(true);
clearFlashes('server');
deleteServer(server.id)
.then(() => navigate('/admin/servers'))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'server', error });
setLoading(false);
setVisible(false);
});
};
if (!server) return null;
return (
<>
<ConfirmationModal
visible={visible}
title={'Delete server?'}
buttonText={'Yes, delete server'}
onConfirmed={onDelete}
showSpinnerOverlay={loading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this server?
</ConfirmationModal>
<Button
type={'button'}
size={'small'}
color={'red'}
onClick={() => setVisible(true)}
css={tw`flex items-center justify-center`}
>
<TrashIcon css={tw`w-5 h-5 mr-2`} /> Delete Server
</Button>
</>
);
};

View file

@ -0,0 +1,60 @@
import tw from 'twin.macro';
import { useServerFromRoute } from '@/api/admin/server';
import AdminBox from '@/components/admin/AdminBox';
import Button from '@/components/elements/Button';
export default () => {
const { data: server } = useServerFromRoute();
if (!server) return null;
return (
<div css={tw`grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-x-2 gap-y-2`}>
<div css={tw`h-auto flex flex-col`}>
<AdminBox title={'Reinstall Server'} css={tw`relative w-full`}>
<div css={tw`flex flex-row text-red-500 justify-start items-center mb-4`}>
<div css={tw`w-12 mr-2`}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<p css={tw`text-sm`}>Danger! This could overwrite server data.</p>
</div>
<Button size={'large'} color={'red'} css={tw`w-full`}>
Reinstall Server
</Button>
<p css={tw`text-xs text-neutral-400 mt-2`}>
This will reinstall the server with the assigned service scripts.
</p>
</AdminBox>
</div>
<div css={tw`h-auto flex flex-col`}>
<AdminBox title={'Install Status'} css={tw`relative w-full`}>
<Button size={'large'} color={'primary'} css={tw`w-full`}>
Set Server as Installing
</Button>
<p css={tw`text-xs text-neutral-400 mt-2`}>
If you need to change the install status from uninstalled to installed, or vice versa, you may
do so with the button below.
</p>
</AdminBox>
</div>
<div css={tw`h-auto flex flex-col`}>
<AdminBox title={'Suspend Server '} css={tw`relative w-full`}>
<Button size={'large'} color={'primary'} css={tw`w-full`}>
Suspend Server
</Button>
<p css={tw`text-xs text-neutral-400 mt-2`}>
This will suspend the server, stop any running processes, and immediately block the user from
being able to access their files or otherwise manage the server through the panel or API.
</p>
</AdminBox>
</div>
</div>
);
};

View file

@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { Route, Routes, useParams } from 'react-router-dom';
import tw from 'twin.macro';
import ServerManageContainer from '@/components/admin/servers/ServerManageContainer';
import ServerStartupContainer from '@/components/admin/servers/ServerStartupContainer';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender';
import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation';
import ServerSettingsContainer from '@/components/admin/servers/ServerSettingsContainer';
import useFlash from '@/plugins/useFlash';
import { useServerFromRoute } from '@/api/admin/server';
import { AdjustmentsIcon, CogIcon, DatabaseIcon, FolderIcon, ShieldExclamationIcon } from '@heroicons/react/outline';
export default () => {
const params = useParams<'id'>();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: server, error, isValidating, mutate } = useServerFromRoute();
useEffect(() => {
mutate();
}, []);
useEffect(() => {
if (!error) clearFlashes('server');
if (error) clearAndAddHttpError({ key: 'server', error });
}, [error]);
if (!server || (error && isValidating)) {
return (
<AdminContentBlock showFlashKey={'server'}>
<Spinner size={'large'} centered />
</AdminContentBlock>
);
}
return (
<AdminContentBlock title={'Server - ' + server.name}>
<FlashMessageRender byKey={'backups'} css={tw`mb-4`} />
<div css={tw`w-full flex flex-row items-center mb-4`}>
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>{server.name}</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
{server.uuid}
</p>
</div>
</div>
<FlashMessageRender byKey={'server'} css={tw`mb-4`} />
<SubNavigation>
<SubNavigationLink to={`/admin/servers/${params.id}`} name={'Settings'} icon={CogIcon} />
<SubNavigationLink to={`/admin/servers/${params.id}/startup`} name={'Startup'} icon={AdjustmentsIcon} />
<SubNavigationLink
to={`/admin/servers/${params.id}/databases`}
name={'Databases'}
icon={DatabaseIcon}
/>
<SubNavigationLink to={`/admin/servers/${params.id}/mounts`} name={'Mounts'} icon={FolderIcon} />
<SubNavigationLink
to={`/admin/servers/${params.id}/manage`}
name={'Manage'}
icon={ShieldExclamationIcon}
/>
</SubNavigation>
<Routes>
<Route path="" element={<ServerSettingsContainer />} />
<Route path="startup" element={<ServerStartupContainer />} />
<Route path="manage" element={<ServerManageContainer />} />
</Routes>
</AdminContentBlock>
);
};

View file

@ -0,0 +1,103 @@
import { useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { Form, Formik } from 'formik';
import tw from 'twin.macro';
import { object } from 'yup';
import { useServerFromRoute } from '@/api/admin/server';
import type { Values } from '@/api/admin/servers/updateServer';
import updateServer from '@/api/admin/servers/updateServer';
import ServerDeleteButton from '@/components/admin/servers/ServerDeleteButton';
import BaseSettingsBox from '@/components/admin/servers/settings/BaseSettingsBox';
import FeatureLimitsBox from '@/components/admin/servers/settings/FeatureLimitsBox';
import NetworkingBox from '@/components/admin/servers/settings/NetworkingBox';
import ServerResourceBox from '@/components/admin/servers/settings/ServerResourceBox';
import Button from '@/components/elements/Button';
export default () => {
const { data: server } = useServerFromRoute();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(actions => actions.flashes);
if (!server) return null;
const submit = (values: Values, { setSubmitting, setFieldValue }: FormikHelpers<Values>) => {
clearFlashes('server');
// This value is inverted to have the switch be on when the
// OOM Killer is enabled, rather than when disabled.
values.limits.oomDisabled = !values.limits.oomDisabled;
updateServer(server.id, values)
.then(() => {
// setServer({ ...server, ...s });
// TODO: Figure out how to properly clear react-selects for allocations.
setFieldValue('addAllocations', []);
setFieldValue('removeAllocations', []);
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'server', error });
})
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={{
externalId: server.externalId || '',
name: server.name,
ownerId: server.userId,
limits: {
memory: server.limits.memory,
swap: server.limits.swap,
disk: server.limits.disk,
io: server.limits.io,
cpu: server.limits.cpu,
threads: server.limits.threads || '',
// This value is inverted to have the switch be on when the
// OOM Killer is enabled, rather than when disabled.
oomDisabled: !server.limits.oomDisabled,
},
featureLimits: {
allocations: server.featureLimits.allocations,
backups: server.featureLimits.backups,
databases: server.featureLimits.databases,
},
allocationId: server.allocationId,
addAllocations: [] as number[],
removeAllocations: [] as number[],
}}
validationSchema={object().shape({})}
>
{({ isSubmitting, isValid }) => (
<Form>
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-8 mb-16`}>
<div css={tw`grid grid-cols-1 gap-y-6`}>
<BaseSettingsBox />
<FeatureLimitsBox />
<NetworkingBox />
</div>
<div css={tw`flex flex-col`}>
<ServerResourceBox />
<div css={tw`bg-neutral-700 rounded shadow-md px-4 xl:px-5 py-4 mt-6`}>
<div css={tw`flex flex-row`}>
<ServerDeleteButton />
<Button
type="submit"
size="small"
css={tw`ml-auto`}
disabled={isSubmitting || !isValid}
>
Save Changes
</Button>
</div>
</div>
</div>
</div>
</Form>
)}
</Formik>
);
};

View file

@ -0,0 +1,258 @@
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { Form, Formik, useField, useFormikContext } from 'formik';
import { useEffect, useState } from 'react';
import tw from 'twin.macro';
import { object } from 'yup';
import type { InferModel } from '@/api/admin';
import type { Egg, EggVariable } from '@/api/admin/egg';
import { getEgg } from '@/api/admin/egg';
import type { Server } from '@/api/admin/server';
import { useServerFromRoute } from '@/api/admin/server';
import type { Values } from '@/api/admin/servers/updateServerStartup';
import updateServerStartup from '@/api/admin/servers/updateServerStartup';
import EggSelect from '@/components/admin/servers/EggSelect';
import NestSelector from '@/components/admin/servers/NestSelector';
import FormikSwitch from '@/components/elements/FormikSwitch';
import Button from '@/components/elements/Button';
import Input from '@/components/elements/Input';
import AdminBox from '@/components/admin/AdminBox';
import Field from '@/components/elements/Field';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import Label from '@/components/elements/Label';
import type { ApplicationStore } from '@/state';
function ServerStartupLineContainer({ egg, server }: { egg: Egg | null; server: Server }) {
const { isSubmitting, setFieldValue } = useFormikContext();
useEffect(() => {
if (egg === null) {
return;
}
if (server.eggId === egg.id) {
setFieldValue('image', server.container.image);
setFieldValue('startup', server.container.startup || '');
return;
}
// Whenever the egg is changed, set the server's startup command to the egg's default.
setFieldValue('image', egg.dockerImages.length > 0 ? egg.dockerImages[0] : '');
setFieldValue('startup', '');
}, [egg]);
return (
<AdminBox title={'Startup Command'} css={tw`relative w-full`}>
<SpinnerOverlay visible={isSubmitting} />
<div css={tw`mb-6`}>
<Field
id={'startup'}
name={'startup'}
label={'Startup Command'}
type={'text'}
description={
"Edit your server's startup command here. The following variables are available by default: {{SERVER_MEMORY}}, {{SERVER_IP}}, and {{SERVER_PORT}}."
}
placeholder={egg?.startup || ''}
/>
</div>
<div>
<Label>Default Startup Command</Label>
<Input value={egg?.startup || ''} readOnly />
</div>
</AdminBox>
);
}
export function ServerServiceContainer({
egg,
setEgg,
nestId: _nestId,
}: {
egg: Egg | null;
setEgg: (value: Egg | null) => void;
nestId: number;
}) {
const { isSubmitting } = useFormikContext();
const [nestId, setNestId] = useState<number>(_nestId);
return (
<AdminBox title={'Service Configuration'} isLoading={isSubmitting} css={tw`w-full`}>
<div css={tw`mb-6`}>
<NestSelector selectedNestId={nestId} onNestSelect={setNestId} />
</div>
<div css={tw`mb-6`}>
<EggSelect nestId={nestId} selectedEggId={egg?.id} onEggSelect={setEgg} />
</div>
<div css={tw`bg-neutral-800 border border-neutral-900 shadow-inner p-4 rounded`}>
<FormikSwitch name={'skipScripts'} label={'Skip Egg Install Script'} description={'Soon™'} />
</div>
</AdminBox>
);
}
export function ServerImageContainer() {
const { isSubmitting } = useFormikContext();
return (
<AdminBox title={'Image Configuration'} css={tw`relative w-full`}>
<SpinnerOverlay visible={isSubmitting} />
<div css={tw`md:w-full md:flex md:flex-col`}>
<div>
<Field id={'image'} name={'image'} label={'Docker Image'} type={'text'} />
</div>
</div>
</AdminBox>
);
}
export function ServerVariableContainer({ variable, value }: { variable: EggVariable; value?: string }) {
const key = 'environment.' + variable.environmentVariable;
const [, , { setValue, setTouched }] = useField<string | undefined>(key);
const { isSubmitting } = useFormikContext();
useEffect(() => {
if (value === undefined) {
return;
}
setValue(value);
setTouched(true);
}, [value]);
return (
<AdminBox css={tw`relative w-full`} title={<p css={tw`text-sm uppercase`}>{variable.name}</p>}>
<SpinnerOverlay visible={isSubmitting} />
<Field
id={key}
name={key}
type={'text'}
placeholder={variable.defaultValue}
description={variable.description}
/>
</AdminBox>
);
}
function ServerStartupForm({
egg,
setEgg,
server,
}: {
egg: Egg | null;
setEgg: (value: Egg | null) => void;
server: Server;
}) {
const {
isSubmitting,
isValid,
values: { environment },
} = useFormikContext<Values>();
return (
<Form>
<div css={tw`flex flex-col mb-16`}>
<div css={tw`flex flex-row mb-6`}>
<ServerStartupLineContainer egg={egg} server={server} />
</div>
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
<div css={tw`flex`}>
<ServerServiceContainer egg={egg} setEgg={setEgg} nestId={server.nestId} />
</div>
<div css={tw`flex`}>
<ServerImageContainer />
</div>
</div>
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-8`}>
{/* This ensures that no variables are rendered unless the environment has a value for the variable. */}
{egg?.relationships.variables
?.filter(v => Object.keys(environment).find(e => e === v.environmentVariable) !== undefined)
.map((v, i) => (
<ServerVariableContainer
key={i}
variable={v}
value={
server.relationships.variables?.find(
v2 => v.eggId === v2.eggId && v.environmentVariable === v2.environmentVariable,
)?.serverValue
}
/>
))}
</div>
<div css={tw`bg-neutral-700 rounded shadow-md py-2 pr-6 mt-6`}>
<div css={tw`flex flex-row`}>
<Button type="submit" size="small" css={tw`ml-auto`} disabled={isSubmitting || !isValid}>
Save Changes
</Button>
</div>
</div>
</div>
</Form>
);
}
export default () => {
const { data: server } = useServerFromRoute();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const [egg, setEgg] = useState<InferModel<typeof getEgg> | null>(null);
useEffect(() => {
if (!server) return;
getEgg(server.eggId)
.then(egg => setEgg(egg))
.catch(error => console.error(error));
}, [server?.eggId]);
if (!server) return null;
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('server');
updateServerStartup(server.id, values)
// .then(s => {
// mutate(data => { ...data, ...s });
// })
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'server', error });
})
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={{
startup: server.container.startup || '',
environment: [] as Record<string, any>,
image: server.container.image,
eggId: server.eggId,
skipScripts: false,
}}
validationSchema={object().shape({})}
>
<ServerStartupForm
egg={egg}
// @ts-ignore
setEgg={setEgg}
server={server}
/>
</Formik>
);
};

View file

@ -0,0 +1,36 @@
import { NavLink } from 'react-router-dom';
import tw from 'twin.macro';
import FlashMessageRender from '@/components/FlashMessageRender';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import ServersTable from '@/components/admin/servers/ServersTable';
import Button from '@/components/elements/Button';
function ServersContainer() {
return (
<AdminContentBlock title={'Servers'}>
<div css={tw`w-full flex flex-row items-center mb-8`}>
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>Servers</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
All servers available on the system.
</p>
</div>
<div css={tw`flex ml-auto pl-4`}>
<NavLink to={`/admin/servers/new`}>
<Button type={'button'} size={'large'} css={tw`h-10 px-4 py-0 whitespace-nowrap`}>
New Server
</Button>
</NavLink>
</div>
</div>
<FlashMessageRender byKey={'servers'} css={tw`mb-4`} />
<ServersTable />
</AdminContentBlock>
);
}
export default ServersContainer;

View file

@ -0,0 +1,236 @@
import type { ChangeEvent } from 'react';
import { useContext, useEffect } from 'react';
import { NavLink } from 'react-router-dom';
import tw from 'twin.macro';
import type { Filters } from '@/api/admin/servers/getServers';
import getServers, { Context as ServersContext } from '@/api/admin/servers/getServers';
import AdminCheckbox from '@/components/admin/AdminCheckbox';
import AdminTable, {
ContentWrapper,
Loading,
NoItems,
Pagination,
TableBody,
TableHead,
TableHeader,
useTableHooks,
} from '@/components/admin/AdminTable';
import CopyOnClick from '@/components/elements/CopyOnClick';
import { AdminContext } from '@/state/admin';
import useFlash from '@/plugins/useFlash';
function RowCheckbox({ id }: { id: number }) {
const isChecked = AdminContext.useStoreState(state => state.servers.selectedServers.indexOf(id) >= 0);
const appendSelectedServer = AdminContext.useStoreActions(actions => actions.servers.appendSelectedServer);
const removeSelectedServer = AdminContext.useStoreActions(actions => actions.servers.removeSelectedServer);
return (
<AdminCheckbox
name={id.toString()}
checked={isChecked}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
appendSelectedServer(id);
} else {
removeSelectedServer(id);
}
}}
/>
);
}
interface Props {
filters?: Filters;
}
function ServersTable({ filters }: Props) {
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(ServersContext);
const { data: servers, error, isValidating } = getServers(['node', 'user']);
const length = servers?.items?.length || 0;
const setSelectedServers = AdminContext.useStoreActions(actions => actions.servers.setSelectedServers);
const selectedServerLength = AdminContext.useStoreState(state => state.servers.selectedServers.length);
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
setSelectedServers(e.currentTarget.checked ? servers?.items?.map(server => server.id) || [] : []);
};
const onSearch = (query: string): Promise<void> => {
return new Promise(resolve => {
if (query.length < 2) {
setFilters(filters || null);
} else {
setFilters({ ...filters, name: query });
}
return resolve();
});
};
useEffect(() => {
setSelectedServers([]);
}, [page]);
useEffect(() => {
if (!error) {
clearFlashes('servers');
return;
}
clearAndAddHttpError({ key: 'servers', error });
}, [error]);
return (
<AdminTable>
<ContentWrapper
checked={selectedServerLength === (length === 0 ? -1 : length)}
onSelectAllClick={onSelectAllClick}
onSearch={onSearch}
>
<Pagination data={servers} onPageSelect={setPage}>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<TableHead>
<TableHeader
name={'Identifier'}
direction={sort === 'uuidShort' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('uuidShort')}
/>
<TableHeader
name={'Name'}
direction={sort === 'name' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('name')}
/>
<TableHeader
name={'Owner'}
direction={sort === 'owner_id' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('owner_id')}
/>
<TableHeader
name={'Node'}
direction={sort === 'node_id' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('node_id')}
/>
<TableHeader
name={'Status'}
direction={sort === 'status' ? (sortDirection ? 1 : 2) : null}
onClick={() => setSort('status')}
/>
</TableHead>
<TableBody>
{servers !== undefined &&
!error &&
!isValidating &&
length > 0 &&
servers.items.map(server => (
<tr key={server.id} css={tw`h-14 hover:bg-neutral-600`}>
<td css={tw`pl-6`}>
<RowCheckbox id={server.id} />
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<CopyOnClick text={server.identifier}>
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
{server.identifier}
</code>
</CopyOnClick>
</td>
<td css={tw`px-6 text-sm text-left whitespace-nowrap`}>
<NavLink
to={`/admin/servers/${server.id}`}
css={tw`text-primary-400 hover:text-primary-300`}
>
{server.name}
</NavLink>
</td>
{/* TODO: Have permission check for displaying user information. */}
<td css={tw`px-6 text-sm text-left whitespace-nowrap`}>
<NavLink
to={`/admin/users/${server.relations.user?.id}`}
css={tw`text-primary-400 hover:text-primary-300`}
>
<div css={tw`text-sm text-neutral-200`}>
{server.relations.user?.email}
</div>
<div css={tw`text-sm text-neutral-400`}>
{server.relations.user?.uuid.split('-')[0]}
</div>
</NavLink>
</td>
{/* TODO: Have permission check for displaying node information. */}
<td css={tw`px-6 text-sm text-left whitespace-nowrap`}>
<NavLink
to={`/admin/nodes/${server.relations.node?.id}`}
css={tw`text-primary-400 hover:text-primary-300`}
>
<div css={tw`text-sm text-neutral-200`}>
{server.relations.node?.name}
</div>
<div css={tw`text-sm text-neutral-400`}>
{server.relations.node?.fqdn}
</div>
</NavLink>
</td>
<td css={tw`px-6 whitespace-nowrap`}>
{server.status === 'installing' ? (
<span
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-yellow-200 text-yellow-800`}
>
Installing
</span>
) : server.status === 'transferring' ? (
<span
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-yellow-200 text-yellow-800`}
>
Transferring
</span>
) : server.status === 'suspended' ? (
<span
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-red-200 text-red-800`}
>
Suspended
</span>
) : (
<span
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-green-100 text-green-800`}
>
Active
</span>
)}
</td>
</tr>
))}
</TableBody>
</table>
{servers === undefined || (error && isValidating) ? (
<Loading />
) : length < 1 ? (
<NoItems />
) : null}
</div>
</Pagination>
</ContentWrapper>
</AdminTable>
);
}
export default ({ filters }: Props) => {
const hooks = useTableHooks<Filters>(filters);
return (
<ServersContext.Provider value={hooks}>
<ServersTable />
</ServersContext.Provider>
);
};

View file

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

View file

@ -0,0 +1,38 @@
import { faConciergeBell } from '@fortawesome/free-solid-svg-icons';
import { useFormikContext } from 'formik';
import tw from 'twin.macro';
import AdminBox from '@/components/admin/AdminBox';
import Field from '@/components/elements/Field';
export default () => {
const { isSubmitting } = useFormikContext();
return (
<AdminBox icon={faConciergeBell} title={'Feature Limits'} isLoading={isSubmitting}>
<div css={tw`grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6`}>
<Field
id={'featureLimits.allocations'}
name={'featureLimits.allocations'}
label={'Allocation Limit'}
type={'number'}
description={'The total number of allocations a user is allowed to create for this server.'}
/>
<Field
id={'featureLimits.backups'}
name={'featureLimits.backups'}
label={'Backup Limit'}
type={'number'}
description={'The total number of backups that can be created for this server.'}
/>
<Field
id={'featureLimits.databases'}
name={'featureLimits.databases'}
label={'Database Limit'}
type={'number'}
description={'The total number of databases a user is allowed to create for this server.'}
/>
</div>
</AdminBox>
);
};

View file

@ -0,0 +1,68 @@
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import { useFormikContext } from 'formik';
import tw from 'twin.macro';
import getAllocations from '@/api/admin/nodes/getAllocations';
import { useServerFromRoute } from '@/api/admin/server';
import AdminBox from '@/components/admin/AdminBox';
import Label from '@/components/elements/Label';
import Select from '@/components/elements/Select';
import type { Option } from '@/components/elements/SelectField';
import SelectField, { AsyncSelectField } from '@/components/elements/SelectField';
export default () => {
const { isSubmitting } = useFormikContext();
const { data: server } = useServerFromRoute();
const loadOptions = async (inputValue: string, callback: (options: Option[]) => void) => {
if (!server) {
// eslint-disable-next-line node/no-callback-literal
callback([] as Option[]);
return;
}
const allocations = await getAllocations(server.nodeId, { ip: inputValue, server_id: '0' });
callback(
allocations.map(a => {
return { value: a.id.toString(), label: a.getDisplayText() };
}),
);
};
return (
<AdminBox icon={faNetworkWired} title={'Networking'} isLoading={isSubmitting}>
<div css={tw`grid grid-cols-1 gap-4 lg:gap-6`}>
<div>
<Label htmlFor={'allocationId'}>Primary Allocation</Label>
<Select id={'allocationId'} name={'allocationId'}>
{server?.relationships.allocations?.map(a => (
<option key={a.id} value={a.id}>
{a.getDisplayText()}
</option>
))}
</Select>
</div>
<AsyncSelectField
id={'addAllocations'}
name={'addAllocations'}
label={'Add Allocations'}
loadOptions={loadOptions}
isMulti
/>
<SelectField
id={'removeAllocations'}
name={'removeAllocations'}
label={'Remove Allocations'}
options={
server?.relationships.allocations?.map(a => {
return { value: a.id.toString(), label: a.getDisplayText() };
}) || []
}
isMulti
isSearchable
/>
</div>
</AdminBox>
);
};

View file

@ -0,0 +1,73 @@
import { faBalanceScale } from '@fortawesome/free-solid-svg-icons';
import { useFormikContext } from 'formik';
import tw from 'twin.macro';
import AdminBox from '@/components/admin/AdminBox';
import Field from '@/components/elements/Field';
import FormikSwitch from '@/components/elements/FormikSwitch';
export default () => {
const { isSubmitting } = useFormikContext();
return (
<AdminBox icon={faBalanceScale} title={'Resources'} isLoading={isSubmitting}>
<div css={tw`grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6`}>
<Field
id={'limits.cpu'}
name={'limits.cpu'}
label={'CPU Limit'}
type={'text'}
description={
'Each thread on the system is considered to be 100%. Setting this value to 0 will allow the server to use CPU time without restriction.'
}
/>
<Field
id={'limits.threads'}
name={'limits.threads'}
label={'CPU Pinning'}
type={'text'}
description={
'Advanced: Enter the specific CPU cores that this server can run on, or leave blank to allow all cores. This can be a single number, and or a comma seperated list, and or a dashed range. Example: 0, 0-1,3, or 0,1,3,4. It is recommended to leave this value blank and let the CPU handle balancing the load.'
}
/>
<Field
id={'limits.memory'}
name={'limits.memory'}
label={'Memory Limit'}
type={'number'}
description={
'The maximum amount of memory allowed for this container. Setting this to 0 will allow unlimited memory in a container.'
}
/>
<Field id={'limits.swap'} name={'limits.swap'} label={'Swap Limit'} type={'number'} />
<Field
id={'limits.disk'}
name={'limits.disk'}
label={'Disk Limit'}
type={'number'}
description={
'This server will not be allowed to boot if it is using more than this amount of space. If a server goes over this limit while running it will be safely stopped and locked until enough space is available. Set to 0 to allow unlimited disk usage.'
}
/>
<Field
id={'limits.io'}
name={'limits.io'}
label={'Block IO Proportion'}
type={'number'}
description={
'Advanced: The IO performance of this server relative to other running containers on the system. Value should be between 10 and 1000.'
}
/>
<div css={tw`xl:col-span-2 bg-neutral-800 border border-neutral-900 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'limits.oomDisabled'}
label={'Out of Memory Killer'}
description={
'Enabling the Out of Memory Killer may cause server processes to exit unexpectedly.'
}
/>
</div>
</div>
</AdminBox>
);
};

View file

@ -0,0 +1,37 @@
import { Form, Formik } from 'formik';
import tw from 'twin.macro';
import AdminBox from '@/components/admin/AdminBox';
import Field, { FieldRow } from '@/components/elements/Field';
export default () => {
const submit = () => {
//
};
return (
<Formik onSubmit={submit} initialValues={{}}>
<Form>
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6`}>
<AdminBox title="Branding">
<FieldRow>
<Field id={'appName'} name={'appName'} type={'text'} label={'App Name'} description={''} />
</FieldRow>
</AdminBox>
<AdminBox title="Analytics">
<FieldRow>
<Field
id={'googleAnalytics'}
name={'googleAnalytics'}
type={'text'}
label={'Google Analytics'}
description={''}
/>
</FieldRow>
</AdminBox>
</div>
</Form>
</Formik>
);
};

View file

@ -0,0 +1,102 @@
import { Form, Formik } from 'formik';
import tw from 'twin.macro';
import AdminBox from '@/components/admin/AdminBox';
import Button from '@/components/elements/Button';
import Field, { FieldRow } from '@/components/elements/Field';
import Label from '@/components/elements/Label';
import Select from '@/components/elements/Select';
export default () => {
const submit = () => {
//
};
return (
<Formik
onSubmit={submit}
initialValues={{
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpEncryption: 'tls',
username: '',
password: '',
mailFrom: 'no-reply@example.com',
mailFromName: 'Pterodactyl Panel',
}}
>
{({ isSubmitting, isValid }) => (
<Form>
<AdminBox title="Mail">
<FieldRow css={tw`lg:grid-cols-3`}>
<Field
id={'smtpHost'}
name={'smtpHost'}
type={'text'}
label={'SMTP Host'}
description={''}
/>
<Field
id={'smtpPort'}
name={'smtpPort'}
type={'number'}
label={'SMTP Port'}
description={''}
/>
<div>
<Label>Encryption</Label>
<Select id={'smtpEncryption'} name={'smtpEncryption'} defaultValue={'tls'}>
<option value="">None</option>
<option value="ssl">Secure Sockets Layer (SSL)</option>
<option value="tls">Transport Layer Security (TLS)</option>
</Select>
</div>
</FieldRow>
<FieldRow>
<Field
id={'username'}
name={'username'}
type={'text'}
label={'Username'}
description={''}
/>
<Field
id={'password'}
name={'password'}
type={'password'}
label={'Password'}
description={''}
/>
</FieldRow>
<FieldRow>
<Field
id={'mailFrom'}
name={'mailFrom'}
type={'text'}
label={'Mail From'}
description={''}
/>
<Field
id={'mailFromName'}
name={'mailFromName'}
type={'text'}
label={'Mail From Name'}
description={''}
/>
</FieldRow>
</AdminBox>
<div css={tw`bg-neutral-700 rounded shadow-md px-4 xl:px-5 py-4 mt-6`}>
<div css={tw`flex flex-row`}>
<Button type="submit" size="small" css={tw`ml-auto`} disabled={isSubmitting || !isValid}>
Save Changes
</Button>
</div>
</div>
</Form>
)}
</Formik>
);
};

View file

@ -0,0 +1,52 @@
import { AdjustmentsIcon, ChipIcon, CodeIcon, MailIcon, ShieldCheckIcon } from '@heroicons/react/outline';
import { Route, Routes } from 'react-router-dom';
import tw from 'twin.macro';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import MailSettings from '@/components/admin/settings/MailSettings';
import FlashMessageRender from '@/components/FlashMessageRender';
import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation';
import GeneralSettings from '@/components/admin/settings/GeneralSettings';
export default () => {
return (
<AdminContentBlock title={'Settings'}>
<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`}>Settings</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
Configure and manage settings for Pterodactyl.
</p>
</div>
</div>
<FlashMessageRender byKey={'admin:settings'} css={tw`mb-4`} />
<SubNavigation>
<SubNavigationLink to="/admin/settings" name="General">
<ChipIcon />
</SubNavigationLink>
<SubNavigationLink to="/admin/settings/mail" name="Mail">
<MailIcon />
</SubNavigationLink>
<SubNavigationLink to="/admin/settings/security" name="Security">
<ShieldCheckIcon />
</SubNavigationLink>
<SubNavigationLink to="/admin/settings/features" name="Features">
<AdjustmentsIcon />
</SubNavigationLink>
<SubNavigationLink to="/admin/settings/advanced" name="Advanced">
<CodeIcon />
</SubNavigationLink>
</SubNavigation>
<Routes>
<Route path="/admin/settings" element={<GeneralSettings />} />
<Route path="/admin/settings/mail" element={<MailSettings />} />
<Route path="/admin/settings/security" element={<p>Security</p>} />
<Route path="/admin/settings/features" element={<p>Features</p>} />
<Route path="/admin/settings/advanced" element={<p>Advanced</p>} />
</Routes>
</AdminContentBlock>
);
};

View file

@ -0,0 +1,49 @@
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { useNavigate } from 'react-router-dom';
import tw from 'twin.macro';
import type { UpdateUserValues } from '@/api/admin/users';
import { createUser } from '@/api/admin/users';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import UserForm from '@/components/admin/users/UserForm';
import FlashMessageRender from '@/components/FlashMessageRender';
import type { ApplicationStore } from '@/state';
export default () => {
const navigate = useNavigate();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const submit = (values: UpdateUserValues, { setSubmitting }: FormikHelpers<UpdateUserValues>) => {
clearFlashes('user:create');
createUser(values)
.then(user => navigate(`/admin/users/${user.id}`))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'user:create', error });
})
.then(() => setSubmitting(false));
};
return (
<AdminContentBlock title={'New User'}>
<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 User</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
Add a new user to the panel.
</p>
</div>
</div>
<FlashMessageRender byKey={'user:create'} css={tw`mb-4`} />
<UserForm title={'Create User'} onSubmit={submit} role={null} />
</AdminContentBlock>
);
};

View file

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

View file

@ -0,0 +1,62 @@
import type { Actions } from 'easy-peasy';
import { useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { useNavigate } from 'react-router-dom';
import type { UpdateUserValues } from '@/api/admin/users';
import { updateUser } from '@/api/admin/users';
import UserDeleteButton from '@/components/admin/users/UserDeleteButton';
import UserForm from '@/components/admin/users/UserForm';
import { Context } from '@/components/admin/users/UserRouter';
import type { ApplicationStore } from '@/state';
import tw from 'twin.macro';
const UserAboutContainer = () => {
const navigate = useNavigate();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const user = Context.useStoreState(state => state.user);
const setUser = Context.useStoreActions(actions => actions.setUser);
if (user === undefined) {
return <></>;
}
const submit = (values: UpdateUserValues, { setSubmitting }: FormikHelpers<UpdateUserValues>) => {
clearFlashes('user');
updateUser(user.id, values)
.then(() => setUser({ ...user, ...values }))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'user', error });
})
.then(() => setSubmitting(false));
};
return (
<UserForm
title={'Edit User'}
initialValues={{
externalId: user.externalId,
username: user.username,
email: user.email,
adminRoleId: user.adminRoleId,
password: '',
rootAdmin: user.isRootAdmin,
}}
onSubmit={submit}
uuid={user.uuid}
role={user.relationships.role || null}
>
<div css={tw`flex`}>
<UserDeleteButton userId={user.id} onDeleted={() => navigate('/admin/users')} />
</div>
</UserForm>
);
};
export default UserAboutContainer;

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 { deleteUser } from '@/api/admin/users';
import Button from '@/components/elements/Button';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import type { ApplicationStore } from '@/state';
interface Props {
userId: number;
onDeleted: () => void;
}
export default ({ userId, 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('user');
deleteUser(userId)
.then(() => {
setLoading(false);
onDeleted();
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'user', error });
setLoading(false);
setVisible(false);
});
};
return (
<>
<ConfirmationModal
visible={visible}
title={'Delete user?'}
buttonText={'Yes, delete user'}
onConfirmed={onDelete}
showSpinnerOverlay={loading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this user?
</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,148 @@
import type { Action } from 'easy-peasy';
import { action, createContextStore } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { Form, Formik } from 'formik';
import tw from 'twin.macro';
import { bool, object, string } from 'yup';
import type { UpdateUserValues } from '@/api/admin/users';
import AdminBox from '@/components/admin/AdminBox';
import RoleSelect from '@/components/admin/users/RoleSelect';
import CopyOnClick from '@/components/elements/CopyOnClick';
import FormikSwitch from '@/components/elements/FormikSwitch';
import Input from '@/components/elements/Input';
import Label from '@/components/elements/Label';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import Button from '@/components/elements/Button';
import Field, { FieldRow } from '@/components/elements/Field';
import type { User, UserRole } from '@definitions/admin';
interface ctx {
user: User | undefined;
setUser: Action<ctx, User | undefined>;
}
export const Context = createContextStore<ctx>({
user: undefined,
setUser: action((state, payload) => {
state.user = payload;
}),
});
export interface Params {
title: string;
initialValues?: UpdateUserValues;
children?: React.ReactNode;
onSubmit: (values: UpdateUserValues, helpers: FormikHelpers<UpdateUserValues>) => void;
uuid?: string;
role: UserRole | null;
}
export default function UserForm({ title, initialValues, children, onSubmit, uuid, role }: Params) {
const submit = (values: UpdateUserValues, helpers: FormikHelpers<UpdateUserValues>) => {
onSubmit(values, helpers);
};
if (!initialValues) {
initialValues = {
externalId: '',
username: '',
email: '',
password: '',
adminRoleId: null,
rootAdmin: false,
};
}
return (
<Formik
onSubmit={submit}
initialValues={initialValues}
validationSchema={object().shape({
username: string().min(1).max(32),
email: string(),
rootAdmin: bool().required(),
})}
>
{({ isSubmitting, isValid }) => (
<>
<AdminBox title={title} css={tw`relative`}>
<SpinnerOverlay visible={isSubmitting} />
<Form css={tw`mb-0`}>
<FieldRow>
{uuid && (
<div>
<Label>UUID</Label>
<CopyOnClick text={uuid}>
<Input type={'text'} value={uuid} readOnly />
</CopyOnClick>
</div>
)}
<Field
id={'externalId'}
name={'externalId'}
label={'External ID'}
type={'text'}
description={
'Used by external integrations, this field should not be modified unless you know what you are doing.'
}
/>
<Field
id={'username'}
name={'username'}
label={'Username'}
type={'text'}
description={"The user's username, what else would go here?"}
/>
<Field
id={'email'}
name={'email'}
label={'Email Address'}
type={'email'}
description={"The user's email address, what else would go here?"}
/>
<Field
id={'password'}
name={'password'}
label={'Password'}
type={'password'}
placeholder={'••••••••'}
autoComplete={'new-password'}
/* TODO: Change description depending on if user is being created or updated. */
description={
'Leave empty to email the user a link where they will be required to set a password.'
}
/>
<RoleSelect selected={role} />
</FieldRow>
{/* TODO: Remove toggle once role permissions are implemented. */}
<div css={tw`w-full flex flex-row mb-6`}>
<div css={tw`w-full bg-neutral-800 border border-neutral-900 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'rootAdmin'}
label={'Root Admin'}
description={'Should this user be a root administrator?'}
/>
</div>
</div>
<div css={tw`w-full flex flex-row items-center mt-6`}>
{children}
<div css={tw`flex ml-auto`}>
<Button type={'submit'} disabled={isSubmitting || !isValid}>
Save Changes
</Button>
</div>
</div>
</Form>
</AdminBox>
</>
)}
</Formik>
);
}

View file

@ -0,0 +1,114 @@
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 { getUser } from '@/api/admin/users';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation';
import UserAboutContainer from '@/components/admin/users/UserAboutContainer';
import UserServers from '@/components/admin/users/UserServers';
import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender';
import type { ApplicationStore } from '@/state';
import type { User } from '@definitions/admin';
interface ctx {
user: User | undefined;
setUser: Action<ctx, User | undefined>;
}
export const Context = createContextStore<ctx>({
user: undefined,
setUser: action((state, payload) => {
state.user = payload;
}),
});
const UserRouter = () => {
const params = useParams<'id'>();
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const [loading, setLoading] = useState(true);
const user = Context.useStoreState(state => state.user);
const setUser = Context.useStoreActions(actions => actions.setUser);
useEffect(() => {
clearFlashes('user');
getUser(Number(params.id), ['role'])
.then(user => setUser(user))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'user', error });
})
.then(() => setLoading(false));
}, []);
if (loading || user === undefined) {
return (
<AdminContentBlock>
<FlashMessageRender byKey={'user'} 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={'User - ' + user.id}>
<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`}>{user.email}</h2>
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
{user.uuid}
</p>
</div>
</div>
<FlashMessageRender byKey={'user'} css={tw`mb-4`} />
<SubNavigation>
<SubNavigationLink to={`/admin/users/${params.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/users/${params.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={<UserAboutContainer />} />
<Route path="servers" element={<UserServers />} />
</Routes>
</AdminContentBlock>
);
};
export default () => {
return (
<Context.Provider>
<UserRouter />
</Context.Provider>
);
};

View file

@ -0,0 +1,10 @@
import ServersTable from '@/components/admin/servers/ServersTable';
import { Context } from '@/components/admin/users/UserRouter';
function UserServers() {
const user = Context.useStoreState(state => state.user);
return <ServersTable filters={{ owner_id: user?.id?.toString?.() }} />;
}
export default UserServers;

View file

@ -0,0 +1,79 @@
import { BanIcon, DotsVerticalIcon, LockOpenIcon, PencilIcon, SupportIcon, TrashIcon } from '@heroicons/react/solid';
import { useState } from 'react';
import Checkbox from '@/components/elements/inputs/Checkbox';
import { Dropdown } from '@/components/elements/dropdown';
import { Dialog } from '@/components/elements/dialog';
import { Button } from '@/components/elements/button';
import { User } from '@definitions/admin';
interface Props {
user: User;
selected?: boolean;
onRowChange: (user: User, selected: boolean) => void;
}
const UserTableRow = ({ user, selected, onRowChange }: Props) => {
const [visible, setVisible] = useState(false);
return (
<>
<Dialog title={'Delete account'} visible={visible} onDismissed={() => setVisible(false)}>
<Dialog.Icon type={'danger'} />
This account will be permanently deleted.
<Dialog.Buttons>
<Button.Text onClick={() => setVisible(false)}>Cancel</Button.Text>
<Button.Danger>Delete</Button.Danger>
</Dialog.Buttons>
</Dialog>
<tr>
<td className={'whitespace-nowrap'}>
<div className={'flex justify-end items-center w-8'}>
<Checkbox checked={selected} onChange={e => onRowChange(user, e.currentTarget.checked)} />
</div>
</td>
<td className={'pl-6 py-4 whitespace-nowrap'}>
<div className={'flex items-center'}>
<div className={'w-10 h-10'}>
<img src={user.avatarUrl} className={'w-10 h-10 rounded-full'} alt={'User avatar'} />
</div>
<div className={'ml-4'}>
<p className={'font-medium'}>{user.email}</p>
<p className={'text-sm text-neutral-400'}>{user.uuid}</p>
</div>
</div>
</td>
<td className={'pl-2 py-4 whitespace-nowrap'}>
{user.isUsingTwoFactor && (
<span
className={
'bg-green-100 uppercase text-green-700 font-semibold text-xs px-2 py-0.5 rounded'
}
>
2-FA Enabled
</span>
)}
</td>
<td className={'px-6 py-4 whitespace-nowrap'}>
<Dropdown>
<Dropdown.Button className={'px-2'}>
<DotsVerticalIcon />
</Dropdown.Button>
<Dropdown.Item icon={<PencilIcon />}>Edit</Dropdown.Item>
<Dropdown.Item icon={<SupportIcon />}>Reset Password</Dropdown.Item>
<Dropdown.Item icon={<LockOpenIcon />} disabled={!user.isUsingTwoFactor}>
Disable 2-FA
</Dropdown.Item>
<Dropdown.Item icon={<BanIcon />}>Suspend</Dropdown.Item>
<Dropdown.Gap />
<Dropdown.Item icon={<TrashIcon />} onClick={() => setVisible(true)} danger>
Delete Account
</Dropdown.Item>
</Dropdown>
</td>
</tr>
</>
);
};
export default UserTableRow;

View file

@ -0,0 +1,119 @@
import { LockOpenIcon, PlusIcon, SupportIcon, TrashIcon } from '@heroicons/react/solid';
import { Fragment, useEffect, useState } from 'react';
import { useGetUsers } from '@/api/admin/users';
import type { UUID } from '@/api/definitions';
import { Transition } from '@/components/elements/transitions';
import { Button } from '@/components/elements/button/index';
import Checkbox from '@/components/elements/inputs/Checkbox';
import InputField from '@/components/elements/inputs/InputField';
import UserTableRow from '@/components/admin/users/UserTableRow';
import TFootPaginated from '@/components/elements/table/TFootPaginated';
import type { User } from '@definitions/admin';
import extractSearchFilters from '@/helpers/extractSearchFilters';
import useDebouncedState from '@/plugins/useDebouncedState';
const filters = ['id', 'uuid', 'external_id', 'username', 'email'] as const;
const UsersContainer = () => {
const [search, setSearch] = useDebouncedState('', 500);
const [selected, setSelected] = useState<UUID[]>([]);
const { data: users } = useGetUsers(
extractSearchFilters(search, filters, {
splitUnmatched: true,
returnUnmatched: true,
}),
);
useEffect(() => {
document.title = 'Admin | Users';
}, []);
const onRowChange = (user: User, checked: boolean) => {
setSelected(state => {
return checked ? [...state, user.uuid] : selected.filter(uuid => uuid !== user.uuid);
});
};
const selectAllChecked = users && users.items.length > 0 && selected.length > 0;
const onSelectAll = () =>
setSelected(state => (state.length > 0 ? [] : users?.items.map(({ uuid }) => uuid) || []));
return (
<div>
<div className={'flex justify-end mb-4'}>
<Button className={'shadow focus:ring-offset-2 focus:ring-offset-neutral-800'}>
Add User <PlusIcon className={'ml-2 w-5 h-5'} />
</Button>
</div>
<div className={'relative flex items-center rounded-t bg-neutral-700 px-4 py-2'}>
<div className={'mr-6'}>
<Checkbox
checked={selectAllChecked}
disabled={!users?.items.length}
indeterminate={selected.length !== users?.items.length}
onChange={onSelectAll}
/>
</div>
<div className={'flex-1'}>
<InputField
type={'text'}
name={'filter'}
placeholder={'Begin typing to filter...'}
className={'w-56 focus:w-96'}
onChange={e => setSearch(e.currentTarget.value)}
/>
</div>
<Transition.Fade as={Fragment} show={selected.length > 0} duration={'duration-75'}>
<div
className={
'absolute rounded-t bg-neutral-700 w-full h-full top-0 left-0 flex items-center justify-end space-x-4 px-4'
}
>
<div className={'flex-1'}>
<Checkbox
checked={selectAllChecked}
indeterminate={selected.length !== users?.items.length}
onChange={onSelectAll}
/>
</div>
<Button.Text square>
<SupportIcon className={'w-4 h-4'} />
</Button.Text>
<Button.Text square>
<LockOpenIcon className={'w-4 h-4'} />
</Button.Text>
<Button.Text square>
<TrashIcon className={'w-4 h-4'} />
</Button.Text>
</div>
</Transition.Fade>
</div>
<table className={'min-w-full rounded bg-neutral-700'}>
<thead className={'bg-neutral-900'}>
<tr>
<th scope={'col'} className={'w-8'} />
<th scope={'col'} className={'text-left px-6 py-2 w-full'}>
Email
</th>
<th scope={'col'} />
<th scope={'col'} />
</tr>
</thead>
<tbody>
{users?.items.map(user => (
<UserTableRow
key={user.uuid}
user={user}
selected={selected.includes(user.uuid)}
onRowChange={onRowChange}
/>
))}
</tbody>
{users && <TFootPaginated span={4} pagination={users.pagination} />}
</table>
</div>
);
};
export default UsersContainer;