Apply new eslint rules; default to prettier for styling
This commit is contained in:
parent
f22cce8881
commit
dc84af9937
218 changed files with 3876 additions and 3564 deletions
|
@ -15,9 +15,9 @@ import { ServerContext } from '@/state/server';
|
|||
import '@/assets/tailwind.css';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
|
||||
const DashboardRouter = lazy(() => import(/* webpackChunkName: "dashboard" */'@/routers/DashboardRouter'));
|
||||
const ServerRouter = lazy(() => import(/* webpackChunkName: "server" */'@/routers/ServerRouter'));
|
||||
const AuthenticationRouter = lazy(() => import(/* webpackChunkName: "auth" */'@/routers/AuthenticationRouter'));
|
||||
const DashboardRouter = lazy(() => import(/* webpackChunkName: "dashboard" */ '@/routers/DashboardRouter'));
|
||||
const ServerRouter = lazy(() => import(/* webpackChunkName: "server" */ '@/routers/ServerRouter'));
|
||||
const AuthenticationRouter = lazy(() => import(/* webpackChunkName: "auth" */ '@/routers/AuthenticationRouter'));
|
||||
|
||||
interface ExtendedWindow extends Window {
|
||||
SiteConfiguration?: SiteSettings;
|
||||
|
@ -38,7 +38,7 @@ interface ExtendedWindow extends Window {
|
|||
setupInterceptors(history);
|
||||
|
||||
const App = () => {
|
||||
const { PterodactylUser, SiteConfiguration } = (window as ExtendedWindow);
|
||||
const { PterodactylUser, SiteConfiguration } = window as ExtendedWindow;
|
||||
if (PterodactylUser && !store.getState().user.data) {
|
||||
store.getActions().user.setUserData({
|
||||
uuid: PterodactylUser.uuid,
|
||||
|
@ -58,31 +58,31 @@ const App = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<GlobalStylesheet/>
|
||||
<GlobalStylesheet />
|
||||
<StoreProvider store={store}>
|
||||
<ProgressBar/>
|
||||
<ProgressBar />
|
||||
<div css={tw`mx-auto w-auto`}>
|
||||
<Router history={history}>
|
||||
<Switch>
|
||||
<Route path={'/auth'}>
|
||||
<Spinner.Suspense>
|
||||
<AuthenticationRouter/>
|
||||
<AuthenticationRouter />
|
||||
</Spinner.Suspense>
|
||||
</Route>
|
||||
<AuthenticatedRoute path={'/server/:id'}>
|
||||
<Spinner.Suspense>
|
||||
<ServerContext.Provider>
|
||||
<ServerRouter/>
|
||||
<ServerRouter />
|
||||
</ServerContext.Provider>
|
||||
</Spinner.Suspense>
|
||||
</AuthenticatedRoute>
|
||||
<AuthenticatedRoute path={'/'}>
|
||||
<Spinner.Suspense>
|
||||
<DashboardRouter/>
|
||||
<DashboardRouter />
|
||||
</Spinner.Suspense>
|
||||
</AuthenticatedRoute>
|
||||
<Route path={'*'}>
|
||||
<NotFound/>
|
||||
<NotFound />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
|
|
|
@ -2,20 +2,18 @@ import React from 'react';
|
|||
import BoringAvatar, { AvatarProps } from 'boring-avatars';
|
||||
import { useStoreState } from '@/state/hooks';
|
||||
|
||||
const palette = [ '#FFAD08', '#EDD75A', '#73B06F', '#0C8F8F', '#587291' ];
|
||||
const palette = ['#FFAD08', '#EDD75A', '#73B06F', '#0C8F8F', '#587291'];
|
||||
|
||||
type Props = Omit<AvatarProps, 'colors'>;
|
||||
|
||||
const _Avatar = ({ variant = 'beam', ...props }: AvatarProps) => (
|
||||
<BoringAvatar colors={palette} variant={variant} {...props}/>
|
||||
<BoringAvatar colors={palette} variant={variant} {...props} />
|
||||
);
|
||||
|
||||
const _UserAvatar = ({ variant = 'beam', ...props }: Omit<Props, 'name'>) => {
|
||||
const uuid = useStoreState(state => state.user.data?.uuid);
|
||||
const uuid = useStoreState((state) => state.user.data?.uuid);
|
||||
|
||||
return (
|
||||
<BoringAvatar colors={palette} name={uuid || 'system'} variant={variant} {...props} />
|
||||
);
|
||||
return <BoringAvatar colors={palette} name={uuid || 'system'} variant={variant} {...props} />;
|
||||
};
|
||||
|
||||
_Avatar.displayName = 'Avatar';
|
||||
|
|
|
@ -9,27 +9,22 @@ type Props = Readonly<{
|
|||
}>;
|
||||
|
||||
const FlashMessageRender = ({ byKey, className }: Props) => {
|
||||
const flashes = useStoreState(state => state.flashes.items.filter(
|
||||
flash => byKey ? flash.key === byKey : true,
|
||||
));
|
||||
|
||||
return (
|
||||
flashes.length ?
|
||||
<div className={className}>
|
||||
{
|
||||
flashes.map((flash, index) => (
|
||||
<React.Fragment key={flash.id || flash.type + flash.message}>
|
||||
{index > 0 && <div css={tw`mt-2`}></div>}
|
||||
<MessageBox type={flash.type} title={flash.title}>
|
||||
{flash.message}
|
||||
</MessageBox>
|
||||
</React.Fragment>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
:
|
||||
null
|
||||
const flashes = useStoreState((state) =>
|
||||
state.flashes.items.filter((flash) => (byKey ? flash.key === byKey : true))
|
||||
);
|
||||
|
||||
return flashes.length ? (
|
||||
<div className={className}>
|
||||
{flashes.map((flash, index) => (
|
||||
<React.Fragment key={flash.id || flash.type + flash.message}>
|
||||
{index > 0 && <div css={tw`mt-2`}></div>}
|
||||
<MessageBox type={flash.type} title={flash.title}>
|
||||
{flash.message}
|
||||
</MessageBox>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default FlashMessageRender;
|
||||
|
|
|
@ -42,26 +42,24 @@ const getBackground = (type?: FlashMessageType): TwStyle | string => {
|
|||
|
||||
const Container = styled.div<{ $type?: FlashMessageType }>`
|
||||
${tw`p-2 border items-center leading-normal rounded flex w-full text-sm text-white`};
|
||||
${props => styling(props.$type)};
|
||||
${(props) => styling(props.$type)};
|
||||
`;
|
||||
Container.displayName = 'MessageBox.Container';
|
||||
|
||||
const MessageBox = ({ title, children, type }: Props) => (
|
||||
<Container css={tw`lg:inline-flex`} $type={type} role={'alert'}>
|
||||
{title &&
|
||||
<span
|
||||
className={'title'}
|
||||
css={[
|
||||
tw`flex rounded-full uppercase px-2 py-1 text-xs font-bold mr-3 leading-none`,
|
||||
getBackground(type),
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
}
|
||||
<span css={tw`mr-2 text-left flex-auto`}>
|
||||
{children}
|
||||
</span>
|
||||
{title && (
|
||||
<span
|
||||
className={'title'}
|
||||
css={[
|
||||
tw`flex rounded-full uppercase px-2 py-1 text-xs font-bold mr-3 leading-none`,
|
||||
getBackground(type),
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
<span css={tw`mr-2 text-left flex-auto`}>{children}</span>
|
||||
</Container>
|
||||
);
|
||||
MessageBox.displayName = 'MessageBox';
|
||||
|
|
|
@ -14,14 +14,19 @@ import Tooltip from '@/components/elements/tooltip/Tooltip';
|
|||
import Avatar from '@/components/Avatar';
|
||||
|
||||
const RightNavigation = styled.div`
|
||||
& > a, & > button, & > .navigation-link {
|
||||
& > a,
|
||||
& > button,
|
||||
& > .navigation-link {
|
||||
${tw`flex items-center h-full no-underline text-neutral-300 px-6 cursor-pointer transition-all duration-150`};
|
||||
|
||||
&:active, &:hover {
|
||||
&:active,
|
||||
&:hover {
|
||||
${tw`text-neutral-100 bg-black`};
|
||||
}
|
||||
|
||||
&:active, &:hover, &.active {
|
||||
&:active,
|
||||
&:hover,
|
||||
&.active {
|
||||
box-shadow: inset 0 -2px ${theme`colors.cyan.600`.toString()};
|
||||
}
|
||||
}
|
||||
|
@ -30,7 +35,7 @@ const RightNavigation = styled.div`
|
|||
export default () => {
|
||||
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
|
||||
const rootAdmin = useStoreState((state: ApplicationStore) => state.user.data!.rootAdmin);
|
||||
const [ isLoggingOut, setIsLoggingOut ] = useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
const onTriggerLogout = () => {
|
||||
setIsLoggingOut(true);
|
||||
|
@ -42,30 +47,32 @@ export default () => {
|
|||
|
||||
return (
|
||||
<div className={'w-full bg-neutral-900 shadow-md overflow-x-auto'}>
|
||||
<SpinnerOverlay visible={isLoggingOut}/>
|
||||
<SpinnerOverlay visible={isLoggingOut} />
|
||||
<div className={'mx-auto w-full flex items-center h-[3.5rem] max-w-[1200px]'}>
|
||||
<div id={'logo'} className={'flex-1'}>
|
||||
<Link
|
||||
to={'/'}
|
||||
className={'text-2xl font-header px-4 no-underline text-neutral-200 hover:text-neutral-100 transition-colors duration-150'}
|
||||
className={
|
||||
'text-2xl font-header px-4 no-underline text-neutral-200 hover:text-neutral-100 transition-colors duration-150'
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</div>
|
||||
<RightNavigation className={'flex h-full items-center justify-center'}>
|
||||
<SearchContainer/>
|
||||
<SearchContainer />
|
||||
<Tooltip placement={'bottom'} content={'Dashboard'}>
|
||||
<NavLink to={'/'} exact>
|
||||
<FontAwesomeIcon icon={faLayerGroup}/>
|
||||
<FontAwesomeIcon icon={faLayerGroup} />
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
{rootAdmin &&
|
||||
{rootAdmin && (
|
||||
<Tooltip placement={'bottom'} content={'Admin'}>
|
||||
<a href={'/admin'} rel={'noreferrer'}>
|
||||
<FontAwesomeIcon icon={faCogs}/>
|
||||
<FontAwesomeIcon icon={faCogs} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
}
|
||||
)}
|
||||
<Tooltip placement={'bottom'} content={'Account Settings'}>
|
||||
<NavLink to={'/account'}>
|
||||
<span className={'flex items-center w-5 h-5'}>
|
||||
|
@ -75,7 +82,7 @@ export default () => {
|
|||
</Tooltip>
|
||||
<Tooltip placement={'bottom'} content={'Sign Out'}>
|
||||
<button onClick={onTriggerLogout}>
|
||||
<FontAwesomeIcon icon={faSignOutAlt}/>
|
||||
<FontAwesomeIcon icon={faSignOutAlt} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</RightNavigation>
|
||||
|
|
|
@ -19,10 +19,10 @@ interface Values {
|
|||
|
||||
export default () => {
|
||||
const ref = useRef<Reaptcha>(null);
|
||||
const [ token, setToken ] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
|
||||
const { clearFlashes, addFlash } = useFlash();
|
||||
const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha);
|
||||
const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha);
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes();
|
||||
|
@ -34,7 +34,7 @@ export default () => {
|
|||
// If there is no token in the state yet, request the token and then abort this submit request
|
||||
// since it will be re-submitted when the recaptcha data is returned by the component.
|
||||
if (recaptchaEnabled && !token) {
|
||||
ref.current!.execute().catch(error => {
|
||||
ref.current!.execute().catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
setSubmitting(false);
|
||||
|
@ -45,11 +45,11 @@ export default () => {
|
|||
}
|
||||
|
||||
requestPasswordResetEmail(email, token)
|
||||
.then(response => {
|
||||
.then((response) => {
|
||||
resetForm();
|
||||
addFlash({ type: 'success', title: 'Success', message: response });
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
|
||||
})
|
||||
|
@ -66,47 +66,42 @@ export default () => {
|
|||
onSubmit={handleSubmission}
|
||||
initialValues={{ email: '' }}
|
||||
validationSchema={object().shape({
|
||||
email: string().email('A valid email address must be provided to continue.')
|
||||
email: string()
|
||||
.email('A valid email address must be provided to continue.')
|
||||
.required('A valid email address must be provided to continue.'),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting, setSubmitting, submitForm }) => (
|
||||
<LoginFormContainer
|
||||
title={'Request Password Reset'}
|
||||
css={tw`w-full flex`}
|
||||
>
|
||||
<LoginFormContainer title={'Request Password Reset'} css={tw`w-full flex`}>
|
||||
<Field
|
||||
light
|
||||
label={'Email'}
|
||||
description={'Enter your account email address to receive instructions on resetting your password.'}
|
||||
description={
|
||||
'Enter your account email address to receive instructions on resetting your password.'
|
||||
}
|
||||
name={'email'}
|
||||
type={'email'}
|
||||
/>
|
||||
<div css={tw`mt-6`}>
|
||||
<Button
|
||||
type={'submit'}
|
||||
size={'xlarge'}
|
||||
disabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
<Button type={'submit'} size={'xlarge'} disabled={isSubmitting} isLoading={isSubmitting}>
|
||||
Send Email
|
||||
</Button>
|
||||
</div>
|
||||
{recaptchaEnabled &&
|
||||
<Reaptcha
|
||||
ref={ref}
|
||||
size={'invisible'}
|
||||
sitekey={siteKey || '_invalid_key'}
|
||||
onVerify={response => {
|
||||
setToken(response);
|
||||
submitForm();
|
||||
}}
|
||||
onExpire={() => {
|
||||
setSubmitting(false);
|
||||
setToken('');
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{recaptchaEnabled && (
|
||||
<Reaptcha
|
||||
ref={ref}
|
||||
size={'invisible'}
|
||||
sitekey={siteKey || '_invalid_key'}
|
||||
onVerify={(response) => {
|
||||
setToken(response);
|
||||
submitForm();
|
||||
}}
|
||||
onExpire={() => {
|
||||
setSubmitting(false);
|
||||
setToken('');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div css={tw`mt-6 text-center`}>
|
||||
<Link
|
||||
to={'/auth/login'}
|
||||
|
|
|
@ -13,18 +13,18 @@ import Button from '@/components/elements/Button';
|
|||
|
||||
interface Values {
|
||||
code: string;
|
||||
recoveryCode: '',
|
||||
recoveryCode: '';
|
||||
}
|
||||
|
||||
type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string }>
|
||||
type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string }>;
|
||||
|
||||
type Props = OwnProps & {
|
||||
clearAndAddHttpError: ActionCreator<FlashStore['clearAndAddHttpError']['payload']>;
|
||||
}
|
||||
};
|
||||
|
||||
const LoginCheckpointContainer = () => {
|
||||
const { isSubmitting, setFieldValue } = useFormikContext<Values>();
|
||||
const [ isMissingDevice, setIsMissingDevice ] = useState(false);
|
||||
const [isMissingDevice, setIsMissingDevice] = useState(false);
|
||||
|
||||
return (
|
||||
<LoginFormContainer title={'Device Checkpoint'} css={tw`w-full flex`}>
|
||||
|
@ -44,12 +44,7 @@ const LoginCheckpointContainer = () => {
|
|||
/>
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Button
|
||||
size={'xlarge'}
|
||||
type={'submit'}
|
||||
disabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
<Button size={'xlarge'} type={'submit'} disabled={isSubmitting} isLoading={isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -58,11 +53,11 @@ const LoginCheckpointContainer = () => {
|
|||
onClick={() => {
|
||||
setFieldValue('code', '');
|
||||
setFieldValue('recoveryCode', '');
|
||||
setIsMissingDevice(s => !s);
|
||||
setIsMissingDevice((s) => !s);
|
||||
}}
|
||||
css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
|
||||
>
|
||||
{!isMissingDevice ? 'I\'ve Lost My Device' : 'I Have My Device'}
|
||||
{!isMissingDevice ? "I've Lost My Device" : 'I Have My Device'}
|
||||
</span>
|
||||
</div>
|
||||
<div css={tw`mt-6 text-center`}>
|
||||
|
@ -80,7 +75,7 @@ const LoginCheckpointContainer = () => {
|
|||
const EnhancedForm = withFormik<Props, Values>({
|
||||
handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { clearAndAddHttpError, location } }) => {
|
||||
loginCheckpoint(location.state?.token || '', code, recoveryCode)
|
||||
.then(response => {
|
||||
.then((response) => {
|
||||
if (response.complete) {
|
||||
// @ts-ignore
|
||||
window.location = response.intended || '/';
|
||||
|
@ -89,7 +84,7 @@ const EnhancedForm = withFormik<Props, Values>({
|
|||
|
||||
setSubmitting(false);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setSubmitting(false);
|
||||
clearAndAddHttpError({ error });
|
||||
|
@ -111,10 +106,7 @@ export default ({ history, location, ...props }: OwnProps) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
return <EnhancedForm
|
||||
clearAndAddHttpError={clearAndAddHttpError}
|
||||
history={history}
|
||||
location={location}
|
||||
{...props}
|
||||
/>;
|
||||
return (
|
||||
<EnhancedForm clearAndAddHttpError={clearAndAddHttpError} history={history} location={location} {...props} />
|
||||
);
|
||||
};
|
||||
|
|
|
@ -18,10 +18,10 @@ interface Values {
|
|||
|
||||
const LoginContainer = ({ history }: RouteComponentProps) => {
|
||||
const ref = useRef<Reaptcha>(null);
|
||||
const [ token, setToken ] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha);
|
||||
const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha);
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes();
|
||||
|
@ -33,7 +33,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
|
|||
// If there is no token in the state yet, request the token and then abort this submit request
|
||||
// since it will be re-submitted when the recaptcha data is returned by the component.
|
||||
if (recaptchaEnabled && !token) {
|
||||
ref.current!.execute().catch(error => {
|
||||
ref.current!.execute().catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
setSubmitting(false);
|
||||
|
@ -44,7 +44,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
|
|||
}
|
||||
|
||||
login({ ...values, recaptchaData: token })
|
||||
.then(response => {
|
||||
.then((response) => {
|
||||
if (response.complete) {
|
||||
// @ts-ignore
|
||||
window.location = response.intended || '/';
|
||||
|
@ -53,7 +53,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
|
|||
|
||||
history.replace('/auth/login/checkpoint', { token: response.confirmationToken });
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
setToken('');
|
||||
|
@ -75,42 +75,30 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
|
|||
>
|
||||
{({ isSubmitting, setSubmitting, submitForm }) => (
|
||||
<LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`}>
|
||||
<Field
|
||||
light
|
||||
type={'text'}
|
||||
label={'Username or Email'}
|
||||
name={'username'}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Field light type={'text'} label={'Username or Email'} name={'username'} disabled={isSubmitting} />
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
light
|
||||
type={'password'}
|
||||
label={'Password'}
|
||||
name={'password'}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Field light type={'password'} label={'Password'} name={'password'} disabled={isSubmitting} />
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting} disabled={isSubmitting}>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
{recaptchaEnabled &&
|
||||
<Reaptcha
|
||||
ref={ref}
|
||||
size={'invisible'}
|
||||
sitekey={siteKey || '_invalid_key'}
|
||||
onVerify={response => {
|
||||
setToken(response);
|
||||
submitForm();
|
||||
}}
|
||||
onExpire={() => {
|
||||
setSubmitting(false);
|
||||
setToken('');
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{recaptchaEnabled && (
|
||||
<Reaptcha
|
||||
ref={ref}
|
||||
size={'invisible'}
|
||||
sitekey={siteKey || '_invalid_key'}
|
||||
onVerify={(response) => {
|
||||
setToken(response);
|
||||
submitForm();
|
||||
}}
|
||||
onExpire={() => {
|
||||
setSubmitting(false);
|
||||
setToken('');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div css={tw`mt-6 text-center`}>
|
||||
<Link
|
||||
to={'/auth/password'}
|
||||
|
|
|
@ -7,7 +7,7 @@ import tw from 'twin.macro';
|
|||
|
||||
type Props = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
|
||||
title?: string;
|
||||
}
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
${breakpoint('sm')`
|
||||
|
@ -30,24 +30,18 @@ const Container = styled.div`
|
|||
|
||||
export default forwardRef<HTMLFormElement, Props>(({ title, ...props }, ref) => (
|
||||
<Container>
|
||||
{title &&
|
||||
<h2 css={tw`text-3xl text-center text-neutral-100 font-medium py-4`}>
|
||||
{title}
|
||||
</h2>
|
||||
}
|
||||
<FlashMessageRender css={tw`mb-2 px-1`}/>
|
||||
{title && <h2 css={tw`text-3xl text-center text-neutral-100 font-medium py-4`}>{title}</h2>}
|
||||
<FlashMessageRender css={tw`mb-2 px-1`} />
|
||||
<Form {...props} ref={ref}>
|
||||
<div css={tw`md:flex w-full bg-white shadow-lg rounded-lg p-6 md:pl-0 mx-1`}>
|
||||
<div css={tw`flex-none select-none mb-6 md:mb-0 self-center`}>
|
||||
<img src={'/assets/svgs/pterodactyl.svg'} css={tw`block w-48 md:w-64 mx-auto`}/>
|
||||
</div>
|
||||
<div css={tw`flex-1`}>
|
||||
{props.children}
|
||||
<img src={'/assets/svgs/pterodactyl.svg'} css={tw`block w-48 md:w-64 mx-auto`} />
|
||||
</div>
|
||||
<div css={tw`flex-1`}>{props.children}</div>
|
||||
</div>
|
||||
</Form>
|
||||
<p css={tw`text-center text-neutral-500 text-xs mt-4`}>
|
||||
© 2015 - {(new Date()).getFullYear()}
|
||||
© 2015 - {new Date().getFullYear()}
|
||||
<a
|
||||
rel={'noopener nofollow noreferrer'}
|
||||
href={'https://pterodactyl.io'}
|
||||
|
|
|
@ -20,7 +20,7 @@ interface Values {
|
|||
}
|
||||
|
||||
export default ({ match, location }: RouteComponentProps<{ token: string }>) => {
|
||||
const [ email, setEmail ] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
|
@ -36,7 +36,7 @@ export default ({ match, location }: RouteComponentProps<{ token: string }>) =>
|
|||
// @ts-ignore
|
||||
window.location = '/';
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
setSubmitting(false);
|
||||
|
@ -52,22 +52,20 @@ export default ({ match, location }: RouteComponentProps<{ token: string }>) =>
|
|||
passwordConfirmation: '',
|
||||
}}
|
||||
validationSchema={object().shape({
|
||||
password: string().required('A new password is required.')
|
||||
password: string()
|
||||
.required('A new password is required.')
|
||||
.min(8, 'Your new password should be at least 8 characters in length.'),
|
||||
passwordConfirmation: string()
|
||||
.required('Your new password does not match.')
|
||||
// @ts-ignore
|
||||
.oneOf([ ref('password'), null ], 'Your new password does not match.'),
|
||||
.oneOf([ref('password'), null], 'Your new password does not match.'),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<LoginFormContainer
|
||||
title={'Reset Password'}
|
||||
css={tw`w-full flex`}
|
||||
>
|
||||
<LoginFormContainer title={'Reset Password'} css={tw`w-full flex`}>
|
||||
<div>
|
||||
<label>Email</label>
|
||||
<Input value={email} isLight disabled/>
|
||||
<Input value={email} isLight disabled />
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
|
@ -79,20 +77,10 @@ export default ({ match, location }: RouteComponentProps<{ token: string }>) =>
|
|||
/>
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
light
|
||||
label={'Confirm New Password'}
|
||||
name={'passwordConfirmation'}
|
||||
type={'password'}
|
||||
/>
|
||||
<Field light label={'Confirm New Password'} name={'passwordConfirmation'} type={'password'} />
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Button
|
||||
size={'xlarge'}
|
||||
type={'submit'}
|
||||
disabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
<Button size={'xlarge'} type={'submit'} disabled={isSubmitting} isLoading={isSubmitting}>
|
||||
Reset Password
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -16,16 +16,16 @@ import { useFlashKey } from '@/plugins/useFlash';
|
|||
import Code from '@/components/elements/Code';
|
||||
|
||||
export default () => {
|
||||
const [ deleteIdentifier, setDeleteIdentifier ] = useState('');
|
||||
const [ keys, setKeys ] = useState<ApiKey[]>([]);
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [deleteIdentifier, setDeleteIdentifier] = useState('');
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { clearAndAddHttpError } = useFlashKey('account');
|
||||
|
||||
useEffect(() => {
|
||||
getApiKeys()
|
||||
.then(keys => setKeys(keys))
|
||||
.then((keys) => setKeys(keys))
|
||||
.then(() => setLoading(false))
|
||||
.catch(error => clearAndAddHttpError(error));
|
||||
.catch((error) => clearAndAddHttpError(error));
|
||||
}, []);
|
||||
|
||||
const doDeletion = (identifier: string) => {
|
||||
|
@ -33,10 +33,8 @@ export default () => {
|
|||
|
||||
clearAndAddHttpError();
|
||||
deleteApiKey(identifier)
|
||||
.then(() => setKeys(s => ([
|
||||
...(s || []).filter(key => key.identifier !== identifier),
|
||||
])))
|
||||
.catch(error => clearAndAddHttpError(error))
|
||||
.then(() => setKeys((s) => [...(s || []).filter((key) => key.identifier !== identifier)]))
|
||||
.catch((error) => clearAndAddHttpError(error))
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
setDeleteIdentifier('');
|
||||
|
@ -45,13 +43,13 @@ export default () => {
|
|||
|
||||
return (
|
||||
<PageContentBlock title={'Account API'}>
|
||||
<FlashMessageRender byKey={'account'}/>
|
||||
<FlashMessageRender byKey={'account'} />
|
||||
<div css={tw`md:flex flex-nowrap my-10`}>
|
||||
<ContentBox title={'Create API Key'} css={tw`flex-none w-full md:w-1/2`}>
|
||||
<CreateApiKeyForm onKeyCreated={key => setKeys(s => ([ ...s!, key ]))}/>
|
||||
<CreateApiKeyForm onKeyCreated={(key) => setKeys((s) => [...s!, key])} />
|
||||
</ContentBox>
|
||||
<ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
|
||||
<SpinnerOverlay visible={loading}/>
|
||||
<SpinnerOverlay visible={loading} />
|
||||
<Dialog.Confirm
|
||||
title={'Delete API Key'}
|
||||
confirm={'Delete Key'}
|
||||
|
@ -61,42 +59,36 @@ export default () => {
|
|||
>
|
||||
All requests using the <Code>{deleteIdentifier}</Code> key will be invalidated.
|
||||
</Dialog.Confirm>
|
||||
{
|
||||
keys.length === 0 ?
|
||||
<p css={tw`text-center text-sm`}>
|
||||
{loading ? 'Loading...' : 'No API keys exist for this account.'}
|
||||
</p>
|
||||
:
|
||||
keys.map((key, index) => (
|
||||
<GreyRowBox
|
||||
key={key.identifier}
|
||||
css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}
|
||||
>
|
||||
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`}/>
|
||||
<div css={tw`ml-4 flex-1 overflow-hidden`}>
|
||||
<p css={tw`text-sm break-words`}>{key.description}</p>
|
||||
<p css={tw`text-2xs text-neutral-300 uppercase`}>
|
||||
Last used:
|
||||
{key.lastUsedAt ? format(key.lastUsedAt, 'MMM do, yyyy HH:mm') : 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
<p css={tw`text-sm ml-4 hidden md:block`}>
|
||||
<code css={tw`font-mono py-1 px-2 bg-neutral-900 rounded`}>
|
||||
{key.identifier}
|
||||
</code>
|
||||
{keys.length === 0 ? (
|
||||
<p css={tw`text-center text-sm`}>
|
||||
{loading ? 'Loading...' : 'No API keys exist for this account.'}
|
||||
</p>
|
||||
) : (
|
||||
keys.map((key, index) => (
|
||||
<GreyRowBox
|
||||
key={key.identifier}
|
||||
css={[tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2`]}
|
||||
>
|
||||
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`} />
|
||||
<div css={tw`ml-4 flex-1 overflow-hidden`}>
|
||||
<p css={tw`text-sm break-words`}>{key.description}</p>
|
||||
<p css={tw`text-2xs text-neutral-300 uppercase`}>
|
||||
Last used:
|
||||
{key.lastUsedAt ? format(key.lastUsedAt, 'MMM do, yyyy HH:mm') : 'Never'}
|
||||
</p>
|
||||
<button
|
||||
css={tw`ml-4 p-2 text-sm`}
|
||||
onClick={() => setDeleteIdentifier(key.identifier)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faTrashAlt}
|
||||
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
|
||||
/>
|
||||
</button>
|
||||
</GreyRowBox>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<p css={tw`text-sm ml-4 hidden md:block`}>
|
||||
<code css={tw`font-mono py-1 px-2 bg-neutral-900 rounded`}>{key.identifier}</code>
|
||||
</p>
|
||||
<button css={tw`ml-4 p-2 text-sm`} onClick={() => setDeleteIdentifier(key.identifier)}>
|
||||
<FontAwesomeIcon
|
||||
icon={faTrashAlt}
|
||||
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
|
||||
/>
|
||||
</button>
|
||||
</GreyRowBox>
|
||||
))
|
||||
)}
|
||||
</ContentBox>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
|
|
|
@ -11,19 +11,19 @@ import MessageBox from '@/components/MessageBox';
|
|||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const Container = styled.div`
|
||||
${tw`flex flex-wrap`};
|
||||
${tw`flex flex-wrap`};
|
||||
|
||||
& > div {
|
||||
${tw`w-full`};
|
||||
& > div {
|
||||
${tw`w-full`};
|
||||
|
||||
${breakpoint('sm')`
|
||||
${breakpoint('sm')`
|
||||
width: calc(50% - 1rem);
|
||||
`}
|
||||
|
||||
${breakpoint('md')`
|
||||
${breakpoint('md')`
|
||||
${tw`w-auto flex-1`};
|
||||
`}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default () => {
|
||||
|
@ -31,28 +31,23 @@ export default () => {
|
|||
|
||||
return (
|
||||
<PageContentBlock title={'Account Overview'}>
|
||||
{state?.twoFactorRedirect &&
|
||||
<MessageBox title={'2-Factor Required'} type={'error'}>
|
||||
Your account must have two-factor authentication enabled in order to continue.
|
||||
</MessageBox>
|
||||
}
|
||||
{state?.twoFactorRedirect && (
|
||||
<MessageBox title={'2-Factor Required'} type={'error'}>
|
||||
Your account must have two-factor authentication enabled in order to continue.
|
||||
</MessageBox>
|
||||
)}
|
||||
|
||||
<Container css={[ tw`lg:grid lg:grid-cols-3 mb-10`, state?.twoFactorRedirect ? tw`mt-4` : tw`mt-10` ]}>
|
||||
<Container css={[tw`lg:grid lg:grid-cols-3 mb-10`, state?.twoFactorRedirect ? tw`mt-4` : tw`mt-10`]}>
|
||||
<ContentBox title={'Update Password'} showFlashes={'account:password'}>
|
||||
<UpdatePasswordForm/>
|
||||
<UpdatePasswordForm />
|
||||
</ContentBox>
|
||||
<ContentBox
|
||||
css={tw`mt-8 sm:mt-0 sm:ml-8`}
|
||||
title={'Update Email Address'}
|
||||
showFlashes={'account:email'}
|
||||
>
|
||||
<UpdateEmailAddressForm/>
|
||||
<ContentBox css={tw`mt-8 sm:mt-0 sm:ml-8`} title={'Update Email Address'} showFlashes={'account:email'}>
|
||||
<UpdateEmailAddressForm />
|
||||
</ContentBox>
|
||||
<ContentBox css={tw`md:ml-8 mt-8 md:mt-0`} title={'Configure Two Factor'}>
|
||||
<ConfigureTwoFactorForm/>
|
||||
<ConfigureTwoFactorForm />
|
||||
</ContentBox>
|
||||
</Container>
|
||||
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -20,7 +20,9 @@ const ApiKeyModal = ({ apiKey }: Props) => {
|
|||
shown again.
|
||||
</p>
|
||||
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
|
||||
<CopyOnClick text={apiKey}><code css={tw`font-mono`}>{apiKey}</code></CopyOnClick>
|
||||
<CopyOnClick text={apiKey}>
|
||||
<code css={tw`font-mono`}>{apiKey}</code>
|
||||
</CopyOnClick>
|
||||
</pre>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button type={'button'} onClick={() => dismiss()}>
|
||||
|
|
|
@ -18,15 +18,15 @@ export default () => {
|
|||
const { search } = useLocation();
|
||||
const defaultPage = Number(new URLSearchParams(search).get('page') || '1');
|
||||
|
||||
const [ page, setPage ] = useState((!isNaN(defaultPage) && defaultPage > 0) ? defaultPage : 1);
|
||||
const [page, setPage] = useState(!isNaN(defaultPage) && defaultPage > 0 ? defaultPage : 1);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const uuid = useStoreState(state => state.user.data!.uuid);
|
||||
const rootAdmin = useStoreState(state => state.user.data!.rootAdmin);
|
||||
const [ showOnlyAdmin, setShowOnlyAdmin ] = usePersistedState(`${uuid}:show_all_servers`, false);
|
||||
const uuid = useStoreState((state) => state.user.data!.uuid);
|
||||
const rootAdmin = useStoreState((state) => state.user.data!.rootAdmin);
|
||||
const [showOnlyAdmin, setShowOnlyAdmin] = usePersistedState(`${uuid}:show_all_servers`, false);
|
||||
|
||||
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
|
||||
[ '/api/client/servers', (showOnlyAdmin && rootAdmin), page ],
|
||||
() => getServers({ page, type: (showOnlyAdmin && rootAdmin) ? 'admin' : undefined }),
|
||||
['/api/client/servers', showOnlyAdmin && rootAdmin, page],
|
||||
() => getServers({ page, type: showOnlyAdmin && rootAdmin ? 'admin' : undefined })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -34,58 +34,53 @@ export default () => {
|
|||
if (servers.pagination.currentPage > 1 && !servers.items.length) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [ servers?.pagination.currentPage ]);
|
||||
}, [servers?.pagination.currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't use react-router to handle changing this part of the URL, otherwise it
|
||||
// triggers a needless re-render. We just want to track this in the URL incase the
|
||||
// user refreshes the page.
|
||||
window.history.replaceState(null, document.title, `/${page <= 1 ? '' : `?page=${page}`}`);
|
||||
}, [ page ]);
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) clearAndAddHttpError({ key: 'dashboard', error });
|
||||
if (!error) clearFlashes('dashboard');
|
||||
}, [ error ]);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Dashboard'} showFlashKey={'dashboard'}>
|
||||
{rootAdmin &&
|
||||
<div css={tw`mb-2 flex justify-end items-center`}>
|
||||
<p css={tw`uppercase text-xs text-neutral-400 mr-2`}>
|
||||
{showOnlyAdmin ? 'Showing others\' servers' : 'Showing your servers'}
|
||||
</p>
|
||||
<Switch
|
||||
name={'show_all_servers'}
|
||||
defaultChecked={showOnlyAdmin}
|
||||
onChange={() => setShowOnlyAdmin(s => !s)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{!servers ?
|
||||
<Spinner centered size={'large'}/>
|
||||
:
|
||||
{rootAdmin && (
|
||||
<div css={tw`mb-2 flex justify-end items-center`}>
|
||||
<p css={tw`uppercase text-xs text-neutral-400 mr-2`}>
|
||||
{showOnlyAdmin ? "Showing others' servers" : 'Showing your servers'}
|
||||
</p>
|
||||
<Switch
|
||||
name={'show_all_servers'}
|
||||
defaultChecked={showOnlyAdmin}
|
||||
onChange={() => setShowOnlyAdmin((s) => !s)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!servers ? (
|
||||
<Spinner centered size={'large'} />
|
||||
) : (
|
||||
<Pagination data={servers} onPageSelect={setPage}>
|
||||
{({ items }) => (
|
||||
items.length > 0 ?
|
||||
{({ items }) =>
|
||||
items.length > 0 ? (
|
||||
items.map((server, index) => (
|
||||
<ServerRow
|
||||
key={server.uuid}
|
||||
server={server}
|
||||
css={index > 0 ? tw`mt-2` : undefined}
|
||||
/>
|
||||
<ServerRow key={server.uuid} server={server} css={index > 0 ? tw`mt-2` : undefined} />
|
||||
))
|
||||
:
|
||||
) : (
|
||||
<p css={tw`text-center text-sm text-neutral-400`}>
|
||||
{showOnlyAdmin ?
|
||||
'There are no other servers to display.'
|
||||
:
|
||||
'There are no servers associated with your account.'
|
||||
}
|
||||
{showOnlyAdmin
|
||||
? 'There are no other servers to display.'
|
||||
: 'There are no servers associated with your account.'}
|
||||
</p>
|
||||
)}
|
||||
)
|
||||
}
|
||||
</Pagination>
|
||||
}
|
||||
)}
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,15 +13,18 @@ import isEqual from 'react-fast-compare';
|
|||
|
||||
// Determines if the current value is in an alarm threshold so we can show it in red rather
|
||||
// than the more faded default style.
|
||||
const isAlarmState = (current: number, limit: number): boolean => limit > 0 && (current / (limit * 1024 * 1024) >= 0.90);
|
||||
const isAlarmState = (current: number, limit: number): boolean => limit > 0 && current / (limit * 1024 * 1024) >= 0.9;
|
||||
|
||||
const Icon = memo(styled(FontAwesomeIcon)<{ $alarm: boolean }>`
|
||||
${props => props.$alarm ? tw`text-red-400` : tw`text-neutral-500`};
|
||||
`, isEqual);
|
||||
const Icon = memo(
|
||||
styled(FontAwesomeIcon)<{ $alarm: boolean }>`
|
||||
${(props) => (props.$alarm ? tw`text-red-400` : tw`text-neutral-500`)};
|
||||
`,
|
||||
isEqual
|
||||
);
|
||||
|
||||
const IconDescription = styled.p<{ $alarm: boolean }>`
|
||||
${tw`text-sm ml-2`};
|
||||
${props => props.$alarm ? tw`text-white` : tw`text-neutral-400`};
|
||||
${(props) => (props.$alarm ? tw`text-white` : tw`text-neutral-400`)};
|
||||
`;
|
||||
|
||||
const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | undefined }>`
|
||||
|
@ -31,7 +34,12 @@ const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | unde
|
|||
${tw`w-2 bg-red-500 absolute right-0 z-20 rounded-full m-1 opacity-50 transition-all duration-150`};
|
||||
height: calc(100% - 0.5rem);
|
||||
|
||||
${({ $status }) => (!$status || $status === 'offline') ? tw`bg-red-500` : ($status === 'running' ? tw`bg-green-500` : tw`bg-yellow-500`)};
|
||||
${({ $status }) =>
|
||||
!$status || $status === 'offline'
|
||||
? tw`bg-red-500`
|
||||
: $status === 'running'
|
||||
? tw`bg-green-500`
|
||||
: tw`bg-yellow-500`};
|
||||
}
|
||||
|
||||
&:hover .status-bar {
|
||||
|
@ -41,16 +49,17 @@ const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | unde
|
|||
|
||||
export default ({ server, className }: { server: Server; className?: string }) => {
|
||||
const interval = useRef<number>(null);
|
||||
const [ isSuspended, setIsSuspended ] = useState(server.status === 'suspended');
|
||||
const [ stats, setStats ] = useState<ServerStats | null>(null);
|
||||
const [isSuspended, setIsSuspended] = useState(server.status === 'suspended');
|
||||
const [stats, setStats] = useState<ServerStats | null>(null);
|
||||
|
||||
const getStats = () => getServerResourceUsage(server.uuid)
|
||||
.then(data => setStats(data))
|
||||
.catch(error => console.error(error));
|
||||
const getStats = () =>
|
||||
getServerResourceUsage(server.uuid)
|
||||
.then((data) => setStats(data))
|
||||
.catch((error) => console.error(error));
|
||||
|
||||
useEffect(() => {
|
||||
setIsSuspended(stats?.isSuspended || server.status === 'suspended');
|
||||
}, [ stats?.isSuspended, server.status ]);
|
||||
}, [stats?.isSuspended, server.status]);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't waste a HTTP request if there is nothing important to show to the user because
|
||||
|
@ -65,11 +74,11 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
|||
return () => {
|
||||
interval.current && clearInterval(interval.current);
|
||||
};
|
||||
}, [ isSuspended ]);
|
||||
}, [isSuspended]);
|
||||
|
||||
const alarms = { cpu: false, memory: false, disk: false };
|
||||
if (stats) {
|
||||
alarms.cpu = server.limits.cpu === 0 ? false : (stats.cpuUsagePercent >= (server.limits.cpu * 0.9));
|
||||
alarms.cpu = server.limits.cpu === 0 ? false : stats.cpuUsagePercent >= server.limits.cpu * 0.9;
|
||||
alarms.memory = isAlarmState(stats.memoryUsageInBytes, server.limits.memory);
|
||||
alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk);
|
||||
}
|
||||
|
@ -82,60 +91,57 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
|||
<StatusIndicatorBox as={Link} to={`/server/${server.id}`} className={className} $status={stats?.status}>
|
||||
<div css={tw`flex items-center col-span-12 sm:col-span-5 lg:col-span-6`}>
|
||||
<div className={'icon'} css={tw`mr-4`}>
|
||||
<FontAwesomeIcon icon={faServer}/>
|
||||
<FontAwesomeIcon icon={faServer} />
|
||||
</div>
|
||||
<div>
|
||||
<p css={tw`text-lg break-words`}>{server.name}</p>
|
||||
{!!server.description &&
|
||||
<p css={tw`text-sm text-neutral-300 break-words line-clamp-2`}>{server.description}</p>
|
||||
}
|
||||
{!!server.description && (
|
||||
<p css={tw`text-sm text-neutral-300 break-words line-clamp-2`}>{server.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`flex-1 ml-4 lg:block lg:col-span-2 hidden`}>
|
||||
<div css={tw`flex justify-center`}>
|
||||
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`}/>
|
||||
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`} />
|
||||
<p css={tw`text-sm text-neutral-400 ml-2`}>
|
||||
{
|
||||
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
|
||||
{server.allocations
|
||||
.filter((alloc) => alloc.isDefault)
|
||||
.map((allocation) => (
|
||||
<React.Fragment key={allocation.ip + allocation.port.toString()}>
|
||||
{allocation.alias || ip(allocation.ip)}:{allocation.port}
|
||||
</React.Fragment>
|
||||
))
|
||||
}
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`hidden col-span-7 lg:col-span-4 sm:flex items-baseline justify-center`}>
|
||||
{(!stats || isSuspended) ?
|
||||
isSuspended ?
|
||||
{!stats || isSuspended ? (
|
||||
isSuspended ? (
|
||||
<div css={tw`flex-1 text-center`}>
|
||||
<span css={tw`bg-red-500 rounded px-2 py-1 text-red-100 text-xs`}>
|
||||
{server.status === 'suspended' ? 'Suspended' : 'Connection Error'}
|
||||
</span>
|
||||
</div>
|
||||
:
|
||||
(server.isTransferring || server.status) ?
|
||||
<div css={tw`flex-1 text-center`}>
|
||||
<span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
|
||||
{server.isTransferring ?
|
||||
'Transferring'
|
||||
:
|
||||
server.status === 'installing' ? 'Installing' : (
|
||||
server.status === 'restoring_backup' ?
|
||||
'Restoring Backup'
|
||||
:
|
||||
'Unavailable'
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
:
|
||||
<Spinner size={'small'}/>
|
||||
:
|
||||
) : server.isTransferring || server.status ? (
|
||||
<div css={tw`flex-1 text-center`}>
|
||||
<span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
|
||||
{server.isTransferring
|
||||
? 'Transferring'
|
||||
: server.status === 'installing'
|
||||
? 'Installing'
|
||||
: server.status === 'restoring_backup'
|
||||
? 'Restoring Backup'
|
||||
: 'Unavailable'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Spinner size={'small'} />
|
||||
)
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<div css={tw`flex-1 ml-4 sm:block hidden`}>
|
||||
<div css={tw`flex justify-center`}>
|
||||
<Icon icon={faMicrochip} $alarm={alarms.cpu}/>
|
||||
<Icon icon={faMicrochip} $alarm={alarms.cpu} />
|
||||
<IconDescription $alarm={alarms.cpu}>
|
||||
{stats.cpuUsagePercent.toFixed(2)} %
|
||||
</IconDescription>
|
||||
|
@ -144,7 +150,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
|||
</div>
|
||||
<div css={tw`flex-1 ml-4 sm:block hidden`}>
|
||||
<div css={tw`flex justify-center`}>
|
||||
<Icon icon={faMemory} $alarm={alarms.memory}/>
|
||||
<Icon icon={faMemory} $alarm={alarms.memory} />
|
||||
<IconDescription $alarm={alarms.memory}>
|
||||
{bytesToString(stats.memoryUsageInBytes)}
|
||||
</IconDescription>
|
||||
|
@ -153,7 +159,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
|||
</div>
|
||||
<div css={tw`flex-1 ml-4 sm:block hidden`}>
|
||||
<div css={tw`flex justify-center`}>
|
||||
<Icon icon={faHdd} $alarm={alarms.disk}/>
|
||||
<Icon icon={faHdd} $alarm={alarms.disk} />
|
||||
<IconDescription $alarm={alarms.disk}>
|
||||
{bytesToString(stats.diskUsageInBytes)}
|
||||
</IconDescription>
|
||||
|
@ -161,9 +167,9 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
|||
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {diskLimit}</p>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className={'status-bar'}/>
|
||||
<div className={'status-bar'} />
|
||||
</StatusIndicatorBox>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -16,53 +16,57 @@ import useLocationHash from '@/plugins/useLocationHash';
|
|||
export default () => {
|
||||
const { hash } = useLocationHash();
|
||||
const { clearAndAddHttpError } = useFlashKey('account');
|
||||
const [ filters, setFilters ] = useState<ActivityLogFilters>({ page: 1, sorts: { timestamp: -1 } });
|
||||
const [filters, setFilters] = useState<ActivityLogFilters>({ page: 1, sorts: { timestamp: -1 } });
|
||||
const { data, isValidating, error } = useActivityLogs(filters, {
|
||||
revalidateOnMount: true,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(value => ({ ...value, filters: { ip: hash.ip, event: hash.event } }));
|
||||
}, [ hash ]);
|
||||
setFilters((value) => ({ ...value, filters: { ip: hash.ip, event: hash.event } }));
|
||||
}, [hash]);
|
||||
|
||||
useEffect(() => {
|
||||
clearAndAddHttpError(error);
|
||||
}, [ error ]);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Account Activity Log'}>
|
||||
<FlashMessageRender byKey={'account'}/>
|
||||
{(filters.filters?.event || filters.filters?.ip) &&
|
||||
<FlashMessageRender byKey={'account'} />
|
||||
{(filters.filters?.event || filters.filters?.ip) && (
|
||||
<div className={'flex justify-end mb-2'}>
|
||||
<Link
|
||||
to={'#'}
|
||||
className={classNames(btnStyles.button, btnStyles.text, 'w-full sm:w-auto')}
|
||||
onClick={() => setFilters(value => ({ ...value, filters: {} }))}
|
||||
onClick={() => setFilters((value) => ({ ...value, filters: {} }))}
|
||||
>
|
||||
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'}/>
|
||||
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'} />
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
{!data && isValidating ?
|
||||
<Spinner centered/>
|
||||
:
|
||||
)}
|
||||
{!data && isValidating ? (
|
||||
<Spinner centered />
|
||||
) : (
|
||||
<div className={'bg-gray-700'}>
|
||||
{data?.items.map((activity) => (
|
||||
<ActivityLogEntry key={activity.timestamp.toString() + activity.event} activity={activity}>
|
||||
{typeof activity.properties.useragent === 'string' &&
|
||||
{typeof activity.properties.useragent === 'string' && (
|
||||
<Tooltip content={activity.properties.useragent} placement={'top'}>
|
||||
<span><DesktopComputerIcon/></span>
|
||||
<span>
|
||||
<DesktopComputerIcon />
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
)}
|
||||
</ActivityLogEntry>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
{data && <PaginationFooter
|
||||
pagination={data.pagination}
|
||||
onPageSelect={page => setFilters(value => ({ ...value, page }))}
|
||||
/>}
|
||||
)}
|
||||
{data && (
|
||||
<PaginationFooter
|
||||
pagination={data.pagination}
|
||||
onPageSelect={(page) => setFilters((value) => ({ ...value, page }))}
|
||||
/>
|
||||
)}
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,23 +7,21 @@ import tw from 'twin.macro';
|
|||
import Button from '@/components/elements/Button';
|
||||
|
||||
export default () => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const isEnabled = useStoreState((state: ApplicationStore) => state.user.data!.useTotp);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{visible && (
|
||||
isEnabled ?
|
||||
<DisableTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)}/>
|
||||
:
|
||||
<SetupTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)}/>
|
||||
)}
|
||||
{visible &&
|
||||
(isEnabled ? (
|
||||
<DisableTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)} />
|
||||
) : (
|
||||
<SetupTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)} />
|
||||
))}
|
||||
<p css={tw`text-sm`}>
|
||||
{isEnabled ?
|
||||
'Two-factor authentication is currently enabled on your account.'
|
||||
:
|
||||
'You do not currently have two-factor authentication enabled on your account. Click the button below to begin configuring it.'
|
||||
}
|
||||
{isEnabled
|
||||
? 'Two-factor authentication is currently enabled on your account.'
|
||||
: 'You do not currently have two-factor authentication enabled on your account. Click the button below to begin configuring it.'}
|
||||
</p>
|
||||
<div css={tw`mt-6`}>
|
||||
<Button color={'red'} isSecondary onClick={() => setVisible(true)}>
|
||||
|
|
|
@ -19,10 +19,12 @@ interface Values {
|
|||
allowedIps: string;
|
||||
}
|
||||
|
||||
const CustomTextarea = styled(Textarea)`${tw`h-32`}`;
|
||||
const CustomTextarea = styled(Textarea)`
|
||||
${tw`h-32`}
|
||||
`;
|
||||
|
||||
export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
||||
const [ apiKey, setApiKey ] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
const submit = (values: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
|
||||
|
@ -34,7 +36,7 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
|||
setApiKey(`${key.identifier}${secretToken}`);
|
||||
onKeyCreated(key);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
addError({ key: 'account', message: httpErrorToHuman(error) });
|
||||
|
@ -44,11 +46,7 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ApiKeyModal
|
||||
visible={apiKey.length > 0}
|
||||
onModalDismissed={() => setApiKey('')}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
<ApiKeyModal visible={apiKey.length > 0} onModalDismissed={() => setApiKey('')} apiKey={apiKey} />
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ description: '', allowedIps: '' }}
|
||||
|
@ -59,21 +57,23 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
|||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<SpinnerOverlay visible={isSubmitting}/>
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
<FormikFieldWrapper
|
||||
label={'Description'}
|
||||
name={'description'}
|
||||
description={'A description of this API key.'}
|
||||
css={tw`mb-6`}
|
||||
>
|
||||
<Field name={'description'} as={Input}/>
|
||||
<Field name={'description'} as={Input} />
|
||||
</FormikFieldWrapper>
|
||||
<FormikFieldWrapper
|
||||
label={'Allowed IPs'}
|
||||
name={'allowedIps'}
|
||||
description={'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'}
|
||||
description={
|
||||
'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'
|
||||
}
|
||||
>
|
||||
<Field name={'allowedIps'} as={CustomTextarea}/>
|
||||
<Field name={'allowedIps'} as={CustomTextarea} />
|
||||
</FormikFieldWrapper>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button>Create</Button>
|
||||
|
|
|
@ -27,7 +27,7 @@ const DisableTwoFactorModal = () => {
|
|||
updateUserData({ useTotp: false });
|
||||
dismiss();
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
clearAndAddHttpError({ error, key: 'account:two-factor' });
|
||||
|
@ -48,13 +48,15 @@ const DisableTwoFactorModal = () => {
|
|||
>
|
||||
{({ isValid }) => (
|
||||
<Form className={'mb-0'}>
|
||||
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
|
||||
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'} />
|
||||
<Field
|
||||
id={'password'}
|
||||
name={'password'}
|
||||
type={'password'}
|
||||
label={'Current Password'}
|
||||
description={'In order to disable two-factor authentication you will need to provide your account password.'}
|
||||
description={
|
||||
'In order to disable two-factor authentication you will need to provide your account password.'
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
<div css={tw`mt-6 text-right`}>
|
||||
|
|
|
@ -19,8 +19,8 @@ interface Values {
|
|||
}
|
||||
|
||||
const SetupTwoFactorModal = () => {
|
||||
const [ token, setToken ] = useState<TwoFactorTokenData | null>(null);
|
||||
const [ recoveryTokens, setRecoveryTokens ] = useState<string[]>([]);
|
||||
const [token, setToken] = useState<TwoFactorTokenData | null>(null);
|
||||
const [recoveryTokens, setRecoveryTokens] = useState<string[]>([]);
|
||||
|
||||
const { dismiss, setPropOverrides } = useContext(ModalContext);
|
||||
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
|
||||
|
@ -29,31 +29,31 @@ const SetupTwoFactorModal = () => {
|
|||
useEffect(() => {
|
||||
getTwoFactorTokenData()
|
||||
.then(setToken)
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ error, key: 'account:two-factor' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const submit = ({ code }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
setPropOverrides(state => ({ ...state, showSpinnerOverlay: true }));
|
||||
setPropOverrides((state) => ({ ...state, showSpinnerOverlay: true }));
|
||||
enableAccountTwoFactor(code)
|
||||
.then(tokens => {
|
||||
.then((tokens) => {
|
||||
setRecoveryTokens(tokens);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
clearAndAddHttpError({ error, key: 'account:two-factor' });
|
||||
})
|
||||
.then(() => {
|
||||
setSubmitting(false);
|
||||
setPropOverrides(state => ({ ...state, showSpinnerOverlay: false }));
|
||||
setPropOverrides((state) => ({ ...state, showSpinnerOverlay: false }));
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPropOverrides(state => ({
|
||||
setPropOverrides((state) => ({
|
||||
...state,
|
||||
closeOnEscape: !recoveryTokens.length,
|
||||
closeOnBackground: !recoveryTokens.length,
|
||||
|
@ -64,7 +64,7 @@ const SetupTwoFactorModal = () => {
|
|||
updateUserData({ useTotp: true });
|
||||
}
|
||||
};
|
||||
}, [ recoveryTokens ]);
|
||||
}, [recoveryTokens]);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
|
@ -76,20 +76,24 @@ const SetupTwoFactorModal = () => {
|
|||
.matches(/^(\d){6}$/, 'Authenticator code must be 6 digits.'),
|
||||
})}
|
||||
>
|
||||
{recoveryTokens.length > 0 ?
|
||||
{recoveryTokens.length > 0 ? (
|
||||
<>
|
||||
<h2 css={tw`text-2xl mb-4`}>Two-factor authentication enabled</h2>
|
||||
<p css={tw`text-neutral-300`}>
|
||||
Two-factor authentication has been enabled on your account. Should you lose access to
|
||||
your authenticator device, you'll need to use one of the codes displayed below in order to access your
|
||||
account.
|
||||
Two-factor authentication has been enabled on your account. Should you lose access to your
|
||||
authenticator device, you'll need to use one of the codes displayed below in order to
|
||||
access your account.
|
||||
</p>
|
||||
<p css={tw`text-neutral-300 mt-4`}>
|
||||
<strong>These codes will not be displayed again.</strong> Please take note of them now
|
||||
by storing them in a secure repository such as a password manager.
|
||||
<strong>These codes will not be displayed again.</strong> Please take note of them now by
|
||||
storing them in a secure repository such as a password manager.
|
||||
</p>
|
||||
<pre css={tw`text-sm mt-4 rounded font-mono bg-neutral-900 p-4`}>
|
||||
{recoveryTokens.map(token => <code key={token} css={tw`block mb-1`}>{token}</code>)}
|
||||
{recoveryTokens.map((token) => (
|
||||
<code key={token} css={tw`block mb-1`}>
|
||||
{token}
|
||||
</code>
|
||||
))}
|
||||
</pre>
|
||||
<div css={tw`text-right`}>
|
||||
<Button css={tw`mt-6`} onClick={dismiss}>
|
||||
|
@ -97,24 +101,26 @@ const SetupTwoFactorModal = () => {
|
|||
</Button>
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
) : (
|
||||
<Form css={tw`mb-0`}>
|
||||
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
|
||||
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'} />
|
||||
<div css={tw`flex flex-wrap`}>
|
||||
<div css={tw`w-full md:flex-1`}>
|
||||
<div css={tw`w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto`}>
|
||||
{!token ?
|
||||
{!token ? (
|
||||
<img
|
||||
src={'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='}
|
||||
src={
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||
}
|
||||
css={tw`w-64 h-64 rounded`}
|
||||
/>
|
||||
:
|
||||
) : (
|
||||
<QRCode
|
||||
renderAs={'svg'}
|
||||
value={token.image_url_data}
|
||||
css={tw`w-full h-full shadow-none rounded-none`}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col`}>
|
||||
|
@ -124,20 +130,20 @@ const SetupTwoFactorModal = () => {
|
|||
name={'code'}
|
||||
type={'text'}
|
||||
title={'Code From Authenticator'}
|
||||
description={'Enter the code from your authenticator device after scanning the QR image.'}
|
||||
description={
|
||||
'Enter the code from your authenticator device after scanning the QR image.'
|
||||
}
|
||||
/>
|
||||
{token &&
|
||||
<div css={tw`mt-4 pt-4 border-t border-neutral-500 text-neutral-200`}>
|
||||
Alternatively, enter the following token into your authenticator application:
|
||||
<CopyOnClick text={token.secret}>
|
||||
<div css={tw`text-sm bg-neutral-900 rounded mt-2 py-2 px-4 font-mono`}>
|
||||
<code css={tw`font-mono`}>
|
||||
{token.secret}
|
||||
</code>
|
||||
</div>
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
}
|
||||
{token && (
|
||||
<div css={tw`mt-4 pt-4 border-t border-neutral-500 text-neutral-200`}>
|
||||
Alternatively, enter the following token into your authenticator application:
|
||||
<CopyOnClick text={token.secret}>
|
||||
<div css={tw`text-sm bg-neutral-900 rounded mt-2 py-2 px-4 font-mono`}>
|
||||
<code css={tw`font-mono`}>{token.secret}</code>
|
||||
</div>
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div css={tw`mt-6 md:mt-0 text-right`}>
|
||||
<Button>Setup</Button>
|
||||
|
@ -145,7 +151,7 @@ const SetupTwoFactorModal = () => {
|
|||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
}
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -29,17 +29,21 @@ export default () => {
|
|||
clearFlashes('account:email');
|
||||
|
||||
updateEmail({ ...values })
|
||||
.then(() => addFlash({
|
||||
type: 'success',
|
||||
key: 'account:email',
|
||||
message: 'Your primary email has been updated.',
|
||||
}))
|
||||
.catch(error => addFlash({
|
||||
type: 'error',
|
||||
key: 'account:email',
|
||||
title: 'Error',
|
||||
message: httpErrorToHuman(error),
|
||||
}))
|
||||
.then(() =>
|
||||
addFlash({
|
||||
type: 'success',
|
||||
key: 'account:email',
|
||||
message: 'Your primary email has been updated.',
|
||||
})
|
||||
)
|
||||
.catch((error) =>
|
||||
addFlash({
|
||||
type: 'error',
|
||||
key: 'account:email',
|
||||
title: 'Error',
|
||||
message: httpErrorToHuman(error),
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
|
@ -47,39 +51,28 @@ export default () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
validationSchema={schema}
|
||||
initialValues={{ email: user!.email, password: '' }}
|
||||
>
|
||||
{
|
||||
({ isSubmitting, isValid }) => (
|
||||
<React.Fragment>
|
||||
<SpinnerOverlay size={'large'} visible={isSubmitting}/>
|
||||
<Form css={tw`m-0`}>
|
||||
<Formik onSubmit={submit} validationSchema={schema} initialValues={{ email: user!.email, password: '' }}>
|
||||
{({ isSubmitting, isValid }) => (
|
||||
<React.Fragment>
|
||||
<SpinnerOverlay size={'large'} visible={isSubmitting} />
|
||||
<Form css={tw`m-0`}>
|
||||
<Field id={'current_email'} type={'email'} name={'email'} label={'Email'} />
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
id={'current_email'}
|
||||
type={'email'}
|
||||
name={'email'}
|
||||
label={'Email'}
|
||||
id={'confirm_password'}
|
||||
type={'password'}
|
||||
name={'password'}
|
||||
label={'Confirm Password'}
|
||||
/>
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
id={'confirm_password'}
|
||||
type={'password'}
|
||||
name={'password'}
|
||||
label={'Confirm Password'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Button size={'small'} disabled={isSubmitting || !isValid}>
|
||||
Update Email
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Button size={'small'} disabled={isSubmitting || !isValid}>
|
||||
Update Email
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -19,9 +19,13 @@ interface Values {
|
|||
const schema = Yup.object().shape({
|
||||
current: Yup.string().min(1).required('You must provide your current password.'),
|
||||
password: Yup.string().min(8).required(),
|
||||
confirmPassword: Yup.string().test('password', 'Password confirmation does not match the password you entered.', function (value) {
|
||||
return value === this.parent.password;
|
||||
}),
|
||||
confirmPassword: Yup.string().test(
|
||||
'password',
|
||||
'Password confirmation does not match the password you entered.',
|
||||
function (value) {
|
||||
return value === this.parent.password;
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
export default () => {
|
||||
|
@ -39,12 +43,14 @@ export default () => {
|
|||
// @ts-ignore
|
||||
window.location = '/auth/login';
|
||||
})
|
||||
.catch(error => addFlash({
|
||||
key: 'account:password',
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: httpErrorToHuman(error),
|
||||
}))
|
||||
.catch((error) =>
|
||||
addFlash({
|
||||
key: 'account:password',
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: httpErrorToHuman(error),
|
||||
})
|
||||
)
|
||||
.then(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
|
@ -55,43 +61,43 @@ export default () => {
|
|||
validationSchema={schema}
|
||||
initialValues={{ current: '', password: '', confirmPassword: '' }}
|
||||
>
|
||||
{
|
||||
({ isSubmitting, isValid }) => (
|
||||
<React.Fragment>
|
||||
<SpinnerOverlay size={'large'} visible={isSubmitting}/>
|
||||
<Form css={tw`m-0`}>
|
||||
{({ isSubmitting, isValid }) => (
|
||||
<React.Fragment>
|
||||
<SpinnerOverlay size={'large'} visible={isSubmitting} />
|
||||
<Form css={tw`m-0`}>
|
||||
<Field
|
||||
id={'current_password'}
|
||||
type={'password'}
|
||||
name={'current'}
|
||||
label={'Current Password'}
|
||||
/>
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
id={'current_password'}
|
||||
id={'new_password'}
|
||||
type={'password'}
|
||||
name={'current'}
|
||||
label={'Current Password'}
|
||||
name={'password'}
|
||||
label={'New Password'}
|
||||
description={
|
||||
'Your new password should be at least 8 characters in length and unique to this website.'
|
||||
}
|
||||
/>
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
id={'new_password'}
|
||||
type={'password'}
|
||||
name={'password'}
|
||||
label={'New Password'}
|
||||
description={'Your new password should be at least 8 characters in length and unique to this website.'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
id={'confirm_new_password'}
|
||||
type={'password'}
|
||||
name={'confirmPassword'}
|
||||
label={'Confirm New Password'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Button size={'small'} disabled={isSubmitting || !isValid}>
|
||||
Update Password
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
id={'confirm_new_password'}
|
||||
type={'password'}
|
||||
name={'confirmPassword'}
|
||||
label={'Confirm New Password'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Button size={'small'} disabled={isSubmitting || !isValid}>
|
||||
Update Password
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
|
@ -6,10 +6,10 @@ import SearchModal from '@/components/dashboard/search/SearchModal';
|
|||
import Tooltip from '@/components/elements/tooltip/Tooltip';
|
||||
|
||||
export default () => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if ([ 'input', 'textarea' ].indexOf(((e.target as HTMLElement).tagName || 'input').toLowerCase()) < 0) {
|
||||
if (['input', 'textarea'].indexOf(((e.target as HTMLElement).tagName || 'input').toLowerCase()) < 0) {
|
||||
if (!visible && e.metaKey && e.key.toLowerCase() === '/') {
|
||||
setVisible(true);
|
||||
}
|
||||
|
@ -18,16 +18,10 @@ export default () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{visible &&
|
||||
<SearchModal
|
||||
appear
|
||||
visible={visible}
|
||||
onDismissed={() => setVisible(false)}
|
||||
/>
|
||||
}
|
||||
{visible && <SearchModal appear visible={visible} onDismissed={() => setVisible(false)} />}
|
||||
<Tooltip placement={'bottom'} content={'Search'}>
|
||||
<div className={'navigation-link'} onClick={() => setVisible(true)}>
|
||||
<FontAwesomeIcon icon={faSearch}/>
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
|
|
@ -40,24 +40,26 @@ const SearchWatcher = () => {
|
|||
if (values.term.length >= 3) {
|
||||
submitForm();
|
||||
}
|
||||
}, [ values.term ]);
|
||||
}, [values.term]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ({ ...props }: Props) => {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const isAdmin = useStoreState(state => state.user.data!.rootAdmin);
|
||||
const [ servers, setServers ] = useState<Server[]>([]);
|
||||
const { clearAndAddHttpError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const isAdmin = useStoreState((state) => state.user.data!.rootAdmin);
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const { clearAndAddHttpError, clearFlashes } = useStoreActions(
|
||||
(actions: Actions<ApplicationStore>) => actions.flashes
|
||||
);
|
||||
|
||||
const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('search');
|
||||
|
||||
// if (ref.current) ref.current.focus();
|
||||
getServers({ query: term, type: isAdmin ? 'admin-all' : undefined })
|
||||
.then(servers => setServers(servers.items.filter((_, index) => index < 5)))
|
||||
.catch(error => {
|
||||
.then((servers) => setServers(servers.items.filter((_, index) => index < 5)))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'search', error });
|
||||
})
|
||||
|
@ -69,10 +71,10 @@ export default ({ ...props }: Props) => {
|
|||
if (props.visible) {
|
||||
if (ref.current) ref.current.focus();
|
||||
}
|
||||
}, [ props.visible ]);
|
||||
}, [props.visible]);
|
||||
|
||||
// Formik does not support an innerRef on custom components.
|
||||
const InputWithRef = (props: any) => <Input autoFocus {...props} ref={ref}/>;
|
||||
const InputWithRef = (props: any) => <Input autoFocus {...props} ref={ref} />;
|
||||
|
||||
return (
|
||||
<Formik
|
||||
|
@ -90,16 +92,15 @@ export default ({ ...props }: Props) => {
|
|||
label={'Search term'}
|
||||
description={'Enter a server name, uuid, or allocation to begin searching.'}
|
||||
>
|
||||
<SearchWatcher/>
|
||||
<SearchWatcher />
|
||||
<InputSpinner visible={isSubmitting}>
|
||||
<Field as={InputWithRef} name={'term'}/>
|
||||
<Field as={InputWithRef} name={'term'} />
|
||||
</InputSpinner>
|
||||
</FormikFieldWrapper>
|
||||
</Form>
|
||||
{servers.length > 0 &&
|
||||
<div css={tw`mt-6`}>
|
||||
{
|
||||
servers.map(server => (
|
||||
{servers.length > 0 && (
|
||||
<div css={tw`mt-6`}>
|
||||
{servers.map((server) => (
|
||||
<ServerResult
|
||||
key={server.uuid}
|
||||
to={`/server/${server.id}`}
|
||||
|
@ -108,11 +109,13 @@ export default ({ ...props }: Props) => {
|
|||
<div css={tw`flex-1 mr-4`}>
|
||||
<p css={tw`text-sm`}>{server.name}</p>
|
||||
<p css={tw`mt-1 text-xs text-neutral-400`}>
|
||||
{
|
||||
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
|
||||
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || ip(allocation.ip)}:{allocation.port}</span>
|
||||
))
|
||||
}
|
||||
{server.allocations
|
||||
.filter((alloc) => alloc.isDefault)
|
||||
.map((allocation) => (
|
||||
<span key={allocation.ip + allocation.port.toString()}>
|
||||
{allocation.alias || ip(allocation.ip)}:{allocation.port}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
<div css={tw`flex-none text-right`}>
|
||||
|
@ -121,10 +124,9 @@ export default ({ ...props }: Props) => {
|
|||
</span>
|
||||
</div>
|
||||
</ServerResult>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
|
|
|
@ -22,43 +22,40 @@ export default () => {
|
|||
|
||||
useEffect(() => {
|
||||
clearAndAddHttpError(error);
|
||||
}, [ error ]);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Account API'}>
|
||||
<FlashMessageRender byKey={'account'}/>
|
||||
<FlashMessageRender byKey={'account'} />
|
||||
<div css={tw`md:flex flex-nowrap my-10`}>
|
||||
<ContentBox title={'Add SSH Key'} css={tw`flex-none w-full md:w-1/2`}>
|
||||
<CreateSSHKeyForm/>
|
||||
<CreateSSHKeyForm />
|
||||
</ContentBox>
|
||||
<ContentBox title={'SSH Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
|
||||
<SpinnerOverlay visible={!data && isValidating}/>
|
||||
{
|
||||
!data || !data.length ?
|
||||
<p css={tw`text-center text-sm`}>
|
||||
{!data ? 'Loading...' : 'No SSH Keys exist for this account.'}
|
||||
</p>
|
||||
:
|
||||
data.map((key, index) => (
|
||||
<GreyRowBox
|
||||
key={key.fingerprint}
|
||||
css={[ tw`bg-neutral-600 flex space-x-4 items-center`, index > 0 && tw`mt-2` ]}
|
||||
>
|
||||
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`}/>
|
||||
<div css={tw`flex-1`}>
|
||||
<p css={tw`text-sm break-words font-medium`}>{key.name}</p>
|
||||
<p css={tw`text-xs mt-1 font-mono truncate`}>
|
||||
SHA256:{key.fingerprint}
|
||||
</p>
|
||||
<p css={tw`text-xs mt-1 text-neutral-300 uppercase`}>
|
||||
Added on:
|
||||
{format(key.createdAt, 'MMM do, yyyy HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
<DeleteSSHKeyButton name={key.name} fingerprint={key.fingerprint} />
|
||||
</GreyRowBox>
|
||||
))
|
||||
}
|
||||
<SpinnerOverlay visible={!data && isValidating} />
|
||||
{!data || !data.length ? (
|
||||
<p css={tw`text-center text-sm`}>
|
||||
{!data ? 'Loading...' : 'No SSH Keys exist for this account.'}
|
||||
</p>
|
||||
) : (
|
||||
data.map((key, index) => (
|
||||
<GreyRowBox
|
||||
key={key.fingerprint}
|
||||
css={[tw`bg-neutral-600 flex space-x-4 items-center`, index > 0 && tw`mt-2`]}
|
||||
>
|
||||
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`} />
|
||||
<div css={tw`flex-1`}>
|
||||
<p css={tw`text-sm break-words font-medium`}>{key.name}</p>
|
||||
<p css={tw`text-xs mt-1 font-mono truncate`}>SHA256:{key.fingerprint}</p>
|
||||
<p css={tw`text-xs mt-1 text-neutral-300 uppercase`}>
|
||||
Added on:
|
||||
{format(key.createdAt, 'MMM do, yyyy HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
<DeleteSSHKeyButton name={key.name} fingerprint={key.fingerprint} />
|
||||
</GreyRowBox>
|
||||
))
|
||||
)}
|
||||
</ContentBox>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
|
|
|
@ -15,7 +15,9 @@ interface Values {
|
|||
publicKey: string;
|
||||
}
|
||||
|
||||
const CustomTextarea = styled(Textarea)`${tw`h-32`}`;
|
||||
const CustomTextarea = styled(Textarea)`
|
||||
${tw`h-32`}
|
||||
`;
|
||||
|
||||
export default () => {
|
||||
const { clearAndAddHttpError } = useFlashKey('account');
|
||||
|
@ -45,16 +47,16 @@ export default () => {
|
|||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<SpinnerOverlay visible={isSubmitting}/>
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
<FormikFieldWrapper label={'SSH Key Name'} name={'name'} css={tw`mb-6`}>
|
||||
<Field name={'name'} as={Input}/>
|
||||
<Field name={'name'} as={Input} />
|
||||
</FormikFieldWrapper>
|
||||
<FormikFieldWrapper
|
||||
label={'Public Key'}
|
||||
name={'publicKey'}
|
||||
description={'Enter your public SSH key.'}
|
||||
>
|
||||
<Field name={'publicKey'} as={CustomTextarea}/>
|
||||
<Field name={'publicKey'} as={CustomTextarea} />
|
||||
</FormikFieldWrapper>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button>Save</Button>
|
||||
|
|
|
@ -9,7 +9,7 @@ import Code from '@/components/elements/Code';
|
|||
|
||||
export default ({ name, fingerprint }: { name: string; fingerprint: string }) => {
|
||||
const { clearAndAddHttpError } = useFlashKey('account');
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { mutate } = useSSHKeys();
|
||||
|
||||
const onClick = () => {
|
||||
|
@ -18,11 +18,10 @@ export default ({ name, fingerprint }: { name: string; fingerprint: string }) =>
|
|||
Promise.all([
|
||||
mutate((data) => data?.filter((value) => value.fingerprint !== fingerprint), false),
|
||||
deleteSSHKey(fingerprint),
|
||||
])
|
||||
.catch((error) => {
|
||||
mutate(undefined, true).catch(console.error);
|
||||
clearAndAddHttpError(error);
|
||||
});
|
||||
]).catch((error) => {
|
||||
mutate(undefined, true).catch(console.error);
|
||||
clearAndAddHttpError(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -3,14 +3,14 @@ import { Redirect, Route, RouteProps } from 'react-router';
|
|||
import { useStoreState } from '@/state/hooks';
|
||||
|
||||
export default ({ children, ...props }: Omit<RouteProps, 'render'>) => {
|
||||
const isAuthenticated = useStoreState(state => !!state.user.data?.uuid);
|
||||
const isAuthenticated = useStoreState((state) => !!state.user.data?.uuid);
|
||||
|
||||
return (
|
||||
<Route
|
||||
{...props}
|
||||
render={({ location }) => (
|
||||
isAuthenticated ? children : <Redirect to={{ pathname: '/auth/login', state: { from: location } }}/>
|
||||
)}
|
||||
render={({ location }) =>
|
||||
isAuthenticated ? children : <Redirect to={{ pathname: '/auth/login', state: { from: location } }} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -12,88 +12,103 @@ interface Props {
|
|||
|
||||
const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
|
||||
${tw`relative inline-block rounded p-2 uppercase tracking-wide text-sm transition-all duration-150 border`};
|
||||
|
||||
${props => ((!props.isSecondary && !props.color) || props.color === 'primary') && css<Props>`
|
||||
${props => !props.isSecondary && tw`bg-primary-500 border-primary-600 border text-primary-50`};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
${tw`bg-primary-600 border-primary-700`};
|
||||
}
|
||||
`};
|
||||
|
||||
${props => props.color === 'grey' && css`
|
||||
${tw`border-neutral-600 bg-neutral-500 text-neutral-50`};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
${tw`bg-neutral-600 border-neutral-700`};
|
||||
}
|
||||
`};
|
||||
|
||||
${props => props.color === 'green' && css<Props>`
|
||||
${tw`border-green-600 bg-green-500 text-green-50`};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
${tw`bg-green-600 border-green-700`};
|
||||
}
|
||||
|
||||
${props => props.isSecondary && css`
|
||||
&:active:not(:disabled) {
|
||||
|
||||
${(props) =>
|
||||
((!props.isSecondary && !props.color) || props.color === 'primary') &&
|
||||
css<Props>`
|
||||
${(props) => !props.isSecondary && tw`bg-primary-500 border-primary-600 border text-primary-50`};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
${tw`bg-primary-600 border-primary-700`};
|
||||
}
|
||||
`};
|
||||
|
||||
${(props) =>
|
||||
props.color === 'grey' &&
|
||||
css`
|
||||
${tw`border-neutral-600 bg-neutral-500 text-neutral-50`};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
${tw`bg-neutral-600 border-neutral-700`};
|
||||
}
|
||||
`};
|
||||
|
||||
${(props) =>
|
||||
props.color === 'green' &&
|
||||
css<Props>`
|
||||
${tw`border-green-600 bg-green-500 text-green-50`};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
${tw`bg-green-600 border-green-700`};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.isSecondary &&
|
||||
css`
|
||||
&:active:not(:disabled) {
|
||||
${tw`bg-green-600 border-green-700`};
|
||||
}
|
||||
`};
|
||||
`};
|
||||
`};
|
||||
|
||||
${props => props.color === 'red' && css<Props>`
|
||||
${tw`border-red-600 bg-red-500 text-red-50`};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
${tw`bg-red-600 border-red-700`};
|
||||
}
|
||||
|
||||
${props => props.isSecondary && css`
|
||||
&:active:not(:disabled) {
|
||||
|
||||
${(props) =>
|
||||
props.color === 'red' &&
|
||||
css<Props>`
|
||||
${tw`border-red-600 bg-red-500 text-red-50`};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
${tw`bg-red-600 border-red-700`};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.isSecondary &&
|
||||
css`
|
||||
&:active:not(:disabled) {
|
||||
${tw`bg-red-600 border-red-700`};
|
||||
}
|
||||
`};
|
||||
`};
|
||||
`};
|
||||
|
||||
${props => props.size === 'xsmall' && tw`px-2 py-1 text-xs`};
|
||||
${props => (!props.size || props.size === 'small') && tw`px-4 py-2`};
|
||||
${props => props.size === 'large' && tw`p-4 text-sm`};
|
||||
${props => props.size === 'xlarge' && tw`p-4 w-full`};
|
||||
|
||||
${props => props.isSecondary && css<Props>`
|
||||
${tw`border-neutral-600 bg-transparent text-neutral-200`};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
${tw`border-neutral-500 text-neutral-100`};
|
||||
${props => props.color === 'red' && tw`bg-red-500 border-red-600 text-red-50`};
|
||||
${props => props.color === 'primary' && tw`bg-primary-500 border-primary-600 text-primary-50`};
|
||||
${props => props.color === 'green' && tw`bg-green-500 border-green-600 text-green-50`};
|
||||
}
|
||||
`};
|
||||
|
||||
&:disabled { opacity: 0.55; cursor: default }
|
||||
|
||||
${(props) => props.size === 'xsmall' && tw`px-2 py-1 text-xs`};
|
||||
${(props) => (!props.size || props.size === 'small') && tw`px-4 py-2`};
|
||||
${(props) => props.size === 'large' && tw`p-4 text-sm`};
|
||||
${(props) => props.size === 'xlarge' && tw`p-4 w-full`};
|
||||
|
||||
${(props) =>
|
||||
props.isSecondary &&
|
||||
css<Props>`
|
||||
${tw`border-neutral-600 bg-transparent text-neutral-200`};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
${tw`border-neutral-500 text-neutral-100`};
|
||||
${(props) => props.color === 'red' && tw`bg-red-500 border-red-600 text-red-50`};
|
||||
${(props) => props.color === 'primary' && tw`bg-primary-500 border-primary-600 text-primary-50`};
|
||||
${(props) => props.color === 'green' && tw`bg-green-500 border-green-600 text-green-50`};
|
||||
}
|
||||
`};
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
`;
|
||||
|
||||
type ComponentProps = Omit<JSX.IntrinsicElements['button'], 'ref' | keyof Props> & Props;
|
||||
|
||||
const Button: React.FC<ComponentProps> = ({ children, isLoading, ...props }) => (
|
||||
<ButtonStyle {...props}>
|
||||
{isLoading &&
|
||||
<div css={tw`flex absolute justify-center items-center w-full h-full left-0 top-0`}>
|
||||
<Spinner size={'small'}/>
|
||||
</div>
|
||||
}
|
||||
<span css={isLoading ? tw`text-transparent` : undefined}>
|
||||
{children}
|
||||
</span>
|
||||
{isLoading && (
|
||||
<div css={tw`flex absolute justify-center items-center w-full h-full left-0 top-0`}>
|
||||
<Spinner size={'small'} />
|
||||
</div>
|
||||
)}
|
||||
<span css={isLoading ? tw`text-transparent` : undefined}>{children}</span>
|
||||
</ButtonStyle>
|
||||
);
|
||||
|
||||
type LinkProps = Omit<JSX.IntrinsicElements['a'], 'ref' | keyof Props> & Props;
|
||||
|
||||
const LinkButton: React.FC<LinkProps> = props => <ButtonStyle as={'a'} {...props}/>;
|
||||
const LinkButton: React.FC<LinkProps> = (props) => <ButtonStyle as={'a'} {...props} />;
|
||||
|
||||
export { LinkButton, ButtonStyle };
|
||||
export default Button;
|
||||
|
|
|
@ -14,12 +14,9 @@ const Can = ({ action, matchAny = false, renderOnError, children }: Props) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
((matchAny && can.filter(p => p).length > 0) || (!matchAny && can.every(p => p))) ?
|
||||
children
|
||||
:
|
||||
renderOnError
|
||||
}
|
||||
{(matchAny && can.filter((p) => p).length > 0) || (!matchAny && can.every((p) => p))
|
||||
? children
|
||||
: renderOnError}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -29,7 +29,7 @@ const Checkbox = ({ name, value, className, ...props }: Props & InputProps) => (
|
|||
type={'checkbox'}
|
||||
checked={(field.value || []).includes(value)}
|
||||
onClick={() => form.setFieldTouched(field.name, true)}
|
||||
onChange={e => {
|
||||
onChange={(e) => {
|
||||
const set = new Set(field.value);
|
||||
set.has(value) ? set.delete(value) : set.add(value);
|
||||
|
||||
|
|
|
@ -98,7 +98,7 @@ const EditorContainer = styled.div`
|
|||
}
|
||||
|
||||
.CodeMirror-foldmarker {
|
||||
color: #CBCCC6;
|
||||
color: #cbccc6;
|
||||
text-shadow: none;
|
||||
margin-left: 0.25rem;
|
||||
margin-right: 0.25rem;
|
||||
|
@ -144,7 +144,7 @@ const findModeByFilename = (filename: string) => {
|
|||
};
|
||||
|
||||
export default ({ style, initialContent, filename, mode, fetchContent, onContentSaved, onModeChanged }: Props) => {
|
||||
const [ editor, setEditor ] = useState<CodeMirror.Editor>();
|
||||
const [editor, setEditor] = useState<CodeMirror.Editor>();
|
||||
|
||||
const ref = useCallback((node) => {
|
||||
if (!node) return;
|
||||
|
@ -173,7 +173,7 @@ export default ({ style, initialContent, filename, mode, fetchContent, onContent
|
|||
// @ts-ignore
|
||||
autoCloseBrackets: true,
|
||||
matchBrackets: true,
|
||||
gutters: [ 'CodeMirror-linenumbers', 'CodeMirror-foldgutter' ],
|
||||
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
|
||||
});
|
||||
|
||||
setEditor(e);
|
||||
|
@ -185,15 +185,15 @@ export default ({ style, initialContent, filename, mode, fetchContent, onContent
|
|||
}
|
||||
|
||||
onModeChanged(findModeByFilename(filename)?.mime || 'text/plain');
|
||||
}, [ filename ]);
|
||||
}, [filename]);
|
||||
|
||||
useEffect(() => {
|
||||
editor && editor.setOption('mode', mode);
|
||||
}, [ editor, mode ]);
|
||||
}, [editor, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
editor && editor.setValue(initialContent || '');
|
||||
}, [ editor, initialContent ]);
|
||||
}, [editor, initialContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
|
@ -207,11 +207,11 @@ export default ({ style, initialContent, filename, mode, fetchContent, onContent
|
|||
});
|
||||
|
||||
fetchContent(() => Promise.resolve(editor.getValue()));
|
||||
}, [ editor, fetchContent, onContentSaved ]);
|
||||
}, [editor, fetchContent, onContentSaved]);
|
||||
|
||||
return (
|
||||
<EditorContainer style={style}>
|
||||
<textarea ref={ref}/>
|
||||
<textarea ref={ref} />
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -17,9 +17,7 @@ const ConfirmationModal: React.FC<Props> = ({ title, children, buttonText, onCon
|
|||
return (
|
||||
<>
|
||||
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
|
||||
<div css={tw`text-neutral-300`}>
|
||||
{children}
|
||||
</div>
|
||||
<div css={tw`text-neutral-300`}>{children}</div>
|
||||
<div css={tw`flex flex-wrap items-center justify-end mt-8`}>
|
||||
<Button isSecondary onClick={() => dismiss()} css={tw`w-full sm:w-auto border-transparent`}>
|
||||
Cancel
|
||||
|
@ -34,6 +32,6 @@ const ConfirmationModal: React.FC<Props> = ({ title, children, buttonText, onCon
|
|||
|
||||
ConfirmationModal.displayName = 'ConfirmationModal';
|
||||
|
||||
export default asModal<Props>(props => ({
|
||||
export default asModal<Props>((props) => ({
|
||||
showSpinnerOverlay: props.showSpinnerOverlay,
|
||||
}))(ConfirmationModal);
|
||||
|
|
|
@ -3,29 +3,23 @@ import FlashMessageRender from '@/components/FlashMessageRender';
|
|||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
type Props = Readonly<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
title?: string;
|
||||
borderColor?: string;
|
||||
showFlashes?: string | boolean;
|
||||
showLoadingOverlay?: boolean;
|
||||
}>;
|
||||
type Props = Readonly<
|
||||
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
title?: string;
|
||||
borderColor?: string;
|
||||
showFlashes?: string | boolean;
|
||||
showLoadingOverlay?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
const ContentBox = ({ title, borderColor, showFlashes, showLoadingOverlay, children, ...props }: Props) => (
|
||||
<div {...props}>
|
||||
{title && <h2 css={tw`text-neutral-300 mb-4 px-4 text-2xl`}>{title}</h2>}
|
||||
{showFlashes &&
|
||||
<FlashMessageRender
|
||||
byKey={typeof showFlashes === 'string' ? showFlashes : undefined}
|
||||
css={tw`mb-4`}
|
||||
/>
|
||||
}
|
||||
<div
|
||||
css={[
|
||||
tw`bg-neutral-700 p-4 rounded shadow-lg relative`,
|
||||
!!borderColor && tw`border-t-4`,
|
||||
]}
|
||||
>
|
||||
<SpinnerOverlay visible={showLoadingOverlay || false}/>
|
||||
{showFlashes && (
|
||||
<FlashMessageRender byKey={typeof showFlashes === 'string' ? showFlashes : undefined} css={tw`mb-4`} />
|
||||
)}
|
||||
<div css={[tw`bg-neutral-700 p-4 rounded shadow-lg relative`, !!borderColor && tw`border-t-4`]}>
|
||||
<SpinnerOverlay visible={showLoadingOverlay || false} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -20,7 +20,7 @@ const Toast = styled.div`
|
|||
`;
|
||||
|
||||
const CopyOnClick: React.FC<{ text: any }> = ({ text, children }) => {
|
||||
const [ copied, setCopied ] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
|
@ -32,7 +32,7 @@ const CopyOnClick: React.FC<{ text: any }> = ({ text, children }) => {
|
|||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [ copied ]);
|
||||
}, [copied]);
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
setCopied(true);
|
||||
|
@ -42,15 +42,15 @@ const CopyOnClick: React.FC<{ text: any }> = ({ text, children }) => {
|
|||
<>
|
||||
<SwitchTransition>
|
||||
<Fade timeout={250} key={copied ? 'visible' : 'invisible'}>
|
||||
{copied ?
|
||||
{copied ? (
|
||||
<Toast>
|
||||
<div>
|
||||
<p>Copied "{text}" to clipboard.</p>
|
||||
</div>
|
||||
</Toast>
|
||||
:
|
||||
) : (
|
||||
<></>
|
||||
}
|
||||
)}
|
||||
</Fade>
|
||||
</SwitchTransition>
|
||||
<CopyToClipboard onCopy={onCopy} text={text} options={{ debug: true }} css={tw`cursor-pointer`}>
|
||||
|
|
|
@ -13,7 +13,7 @@ export const DropdownButtonRow = styled.button<{ danger?: boolean }>`
|
|||
transition: 150ms all ease;
|
||||
|
||||
&:hover {
|
||||
${props => props.danger ? tw`text-red-700 bg-red-100` : tw`text-neutral-700 bg-neutral-100`};
|
||||
${(props) => (props.danger ? tw`text-red-700 bg-red-100` : tw`text-neutral-700 bg-neutral-100`)};
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -30,11 +30,11 @@ class DropdownMenu extends React.PureComponent<Props, State> {
|
|||
visible: false,
|
||||
};
|
||||
|
||||
componentWillUnmount () {
|
||||
componentWillUnmount() {
|
||||
this.removeListeners();
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps: Readonly<Props>, prevState: Readonly<State>) {
|
||||
componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>) {
|
||||
const menu = this.menu.current;
|
||||
|
||||
if (this.state.visible && !prevState.visible && menu) {
|
||||
|
@ -76,19 +76,20 @@ class DropdownMenu extends React.PureComponent<Props, State> {
|
|||
}
|
||||
};
|
||||
|
||||
triggerMenu = (posX: number) => this.setState(s => ({
|
||||
posX: !s.visible ? posX : s.posX,
|
||||
visible: !s.visible,
|
||||
}));
|
||||
triggerMenu = (posX: number) =>
|
||||
this.setState((s) => ({
|
||||
posX: !s.visible ? posX : s.posX,
|
||||
visible: !s.visible,
|
||||
}));
|
||||
|
||||
render () {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.props.renderToggle(this.onClickHandler)}
|
||||
<Fade timeout={150} in={this.state.visible} unmountOnExit>
|
||||
<div
|
||||
ref={this.menu}
|
||||
onClick={e => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
this.setState({ visible: false });
|
||||
}}
|
||||
|
|
|
@ -13,26 +13,27 @@ class ErrorBoundary extends React.Component<{}, State> {
|
|||
hasError: false,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError () {
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch (error: Error) {
|
||||
componentDidCatch(error: Error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
render () {
|
||||
return this.state.hasError ?
|
||||
render() {
|
||||
return this.state.hasError ? (
|
||||
<div css={tw`flex items-center justify-center w-full my-4`}>
|
||||
<div css={tw`flex items-center bg-neutral-900 rounded p-3 text-red-500`}>
|
||||
<Icon icon={faExclamationTriangle} css={tw`h-4 w-auto mr-2`}/>
|
||||
<Icon icon={faExclamationTriangle} css={tw`h-4 w-auto mr-2`} />
|
||||
<p css={tw`text-sm text-neutral-100`}>
|
||||
An error was encountered by the application while rendering this view. Try refreshing the page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
:
|
||||
this.props.children;
|
||||
) : (
|
||||
this.props.children
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,25 +8,29 @@ interface Props extends Omit<CSSTransitionProps, 'timeout' | 'classNames'> {
|
|||
}
|
||||
|
||||
const Container = styled.div<{ timeout: number }>`
|
||||
.fade-enter, .fade-exit, .fade-appear {
|
||||
.fade-enter,
|
||||
.fade-exit,
|
||||
.fade-appear {
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.fade-enter, .fade-appear {
|
||||
|
||||
.fade-enter,
|
||||
.fade-appear {
|
||||
${tw`opacity-0`};
|
||||
|
||||
&.fade-enter-active, &.fade-appear-active {
|
||||
|
||||
&.fade-enter-active,
|
||||
&.fade-appear-active {
|
||||
${tw`opacity-100 transition-opacity ease-in`};
|
||||
transition-duration: ${props => props.timeout}ms;
|
||||
transition-duration: ${(props) => props.timeout}ms;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.fade-exit {
|
||||
${tw`opacity-100`};
|
||||
|
||||
|
||||
&.fade-exit-active {
|
||||
${tw`opacity-0 transition-opacity ease-in`};
|
||||
transition-duration: ${props => props.timeout}ms;
|
||||
transition-duration: ${(props) => props.timeout}ms;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -13,14 +13,16 @@ interface OwnProps {
|
|||
|
||||
type Props = OwnProps & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name'>;
|
||||
|
||||
const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, label, description, validate, ...props }, ref) => (
|
||||
<FormikField innerRef={ref} name={name} validate={validate}>
|
||||
{
|
||||
({ field, form: { errors, touched } }: FieldProps) => (
|
||||
const Field = forwardRef<HTMLInputElement, Props>(
|
||||
({ id, name, light = false, label, description, validate, ...props }, ref) => (
|
||||
<FormikField innerRef={ref} name={name} validate={validate}>
|
||||
{({ field, form: { errors, touched } }: FieldProps) => (
|
||||
<div>
|
||||
{label &&
|
||||
<Label htmlFor={id} isLight={light}>{label}</Label>
|
||||
}
|
||||
{label && (
|
||||
<Label htmlFor={id} isLight={light}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<Input
|
||||
id={id}
|
||||
{...field}
|
||||
|
@ -28,18 +30,19 @@ const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, la
|
|||
isLight={light}
|
||||
hasError={!!(touched[field.name] && errors[field.name])}
|
||||
/>
|
||||
{touched[field.name] && errors[field.name] ?
|
||||
{touched[field.name] && errors[field.name] ? (
|
||||
<p className={'input-help error'}>
|
||||
{(errors[field.name] as string).charAt(0).toUpperCase() + (errors[field.name] as string).slice(1)}
|
||||
{(errors[field.name] as string).charAt(0).toUpperCase() +
|
||||
(errors[field.name] as string).slice(1)}
|
||||
</p>
|
||||
:
|
||||
description ? <p className={'input-help'}>{description}</p> : null
|
||||
}
|
||||
) : description ? (
|
||||
<p className={'input-help'}>{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormikField>
|
||||
));
|
||||
)}
|
||||
</FormikField>
|
||||
)
|
||||
);
|
||||
Field.displayName = 'Field';
|
||||
|
||||
export default Field;
|
||||
|
|
|
@ -15,17 +15,15 @@ interface Props {
|
|||
|
||||
const FormikFieldWrapper = ({ id, name, label, className, description, validate, children }: Props) => (
|
||||
<Field name={name} validate={validate}>
|
||||
{
|
||||
({ field, form: { errors, touched } }: FieldProps) => (
|
||||
<div className={`${className} ${(touched[field.name] && errors[field.name]) ? 'has-error' : undefined}`}>
|
||||
{label && <Label htmlFor={id}>{label}</Label>}
|
||||
{children}
|
||||
<InputError errors={errors} touched={touched} name={field.name}>
|
||||
{description || null}
|
||||
</InputError>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{({ field, form: { errors, touched } }: FieldProps) => (
|
||||
<div className={`${className} ${touched[field.name] && errors[field.name] ? 'has-error' : undefined}`}>
|
||||
{label && <Label htmlFor={id}>{label}</Label>}
|
||||
{children}
|
||||
<InputError errors={errors} touched={touched} name={field.name}>
|
||||
{description || null}
|
||||
</InputError>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
|
||||
|
|
|
@ -3,8 +3,8 @@ import tw from 'twin.macro';
|
|||
|
||||
export default styled.div<{ $hoverable?: boolean }>`
|
||||
${tw`flex rounded no-underline text-neutral-200 items-center bg-neutral-700 p-4 border border-transparent transition-colors duration-150 overflow-hidden`};
|
||||
|
||||
${props => props.$hoverable !== false && tw`hover:border-neutral-500`};
|
||||
|
||||
${(props) => props.$hoverable !== false && tw`hover:border-neutral-500`};
|
||||
|
||||
& .icon {
|
||||
${tw`rounded-full bg-neutral-500 p-3`};
|
||||
|
|
|
@ -9,9 +9,9 @@ interface Props {
|
|||
}
|
||||
|
||||
const Icon = ({ icon, className, style }: Props) => {
|
||||
let [ width, height, , , paths ] = icon.icon;
|
||||
let [width, height, , , paths] = icon.icon;
|
||||
|
||||
paths = Array.isArray(paths) ? paths : [ paths ];
|
||||
paths = Array.isArray(paths) ? paths : [paths];
|
||||
|
||||
return (
|
||||
<svg
|
||||
|
@ -22,7 +22,7 @@ const Icon = ({ icon, className, style }: Props) => {
|
|||
style={style}
|
||||
>
|
||||
{paths.map((path, index) => (
|
||||
<path key={`svg_path_${index}`} d={path}/>
|
||||
<path key={`svg_path_${index}`} d={path} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
|
|
|
@ -7,9 +7,11 @@ export interface Props {
|
|||
}
|
||||
|
||||
const light = css<Props>`
|
||||
${tw`bg-white border-neutral-200 text-neutral-800`};
|
||||
&:focus { ${tw`border-primary-400`} }
|
||||
|
||||
${tw`bg-white border-neutral-200 text-neutral-800`};
|
||||
&:focus {
|
||||
${tw`border-primary-400`}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
${tw`bg-neutral-100 border-neutral-200`};
|
||||
}
|
||||
|
@ -40,43 +42,47 @@ const inputStyle = css<Props>`
|
|||
${tw`appearance-none outline-none w-full min-w-0`};
|
||||
${tw`p-3 border-2 rounded text-sm transition-all duration-150`};
|
||||
${tw`bg-neutral-600 border-neutral-500 hover:border-neutral-400 text-neutral-200 shadow-none focus:ring-0`};
|
||||
|
||||
|
||||
& + .input-help {
|
||||
${tw`mt-1 text-xs`};
|
||||
${props => props.hasError ? tw`text-red-200` : tw`text-neutral-200`};
|
||||
${(props) => (props.hasError ? tw`text-red-200` : tw`text-neutral-200`)};
|
||||
}
|
||||
|
||||
&:required, &:invalid {
|
||||
|
||||
&:required,
|
||||
&:invalid {
|
||||
${tw`shadow-none`};
|
||||
}
|
||||
|
||||
|
||||
&:not(:disabled):not(:read-only):focus {
|
||||
${tw`shadow-md border-primary-300 ring-2 ring-primary-400 ring-opacity-50`};
|
||||
${props => props.hasError && tw`border-red-300 ring-red-200`};
|
||||
${(props) => props.hasError && tw`border-red-300 ring-red-200`};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
${tw`opacity-75`};
|
||||
}
|
||||
|
||||
${props => props.isLight && light};
|
||||
${props => props.hasError && tw`text-red-100 border-red-400 hover:border-red-300`};
|
||||
|
||||
${(props) => props.isLight && light};
|
||||
${(props) => props.hasError && tw`text-red-100 border-red-400 hover:border-red-300`};
|
||||
`;
|
||||
|
||||
const Input = styled.input<Props>`
|
||||
&:not([type="checkbox"]):not([type="radio"]) {
|
||||
&:not([type='checkbox']):not([type='radio']) {
|
||||
${inputStyle};
|
||||
}
|
||||
|
||||
&[type="checkbox"], &[type="radio"] {
|
||||
|
||||
&[type='checkbox'],
|
||||
&[type='radio'] {
|
||||
${checkboxStyle};
|
||||
|
||||
&[type="radio"] {
|
||||
|
||||
&[type='radio'] {
|
||||
${tw`rounded-full`};
|
||||
}
|
||||
}
|
||||
`;
|
||||
const Textarea = styled.textarea<Props>`${inputStyle}`;
|
||||
const Textarea = styled.textarea<Props>`
|
||||
${inputStyle}
|
||||
`;
|
||||
|
||||
export { Textarea };
|
||||
export default Input;
|
||||
|
|
|
@ -10,19 +10,15 @@ interface Props {
|
|||
children?: string | number | null | undefined;
|
||||
}
|
||||
|
||||
const InputError = ({ errors, touched, name, children }: Props) => (
|
||||
touched[name] && errors[name] ?
|
||||
const InputError = ({ errors, touched, name, children }: Props) =>
|
||||
touched[name] && errors[name] ? (
|
||||
<p css={tw`text-xs text-red-400 pt-2`}>
|
||||
{typeof errors[name] === 'string' ?
|
||||
capitalize(errors[name] as string)
|
||||
:
|
||||
capitalize((errors[name] as unknown as string[])[0])
|
||||
}
|
||||
{typeof errors[name] === 'string'
|
||||
? capitalize(errors[name] as string)
|
||||
: capitalize((errors[name] as unknown as string[])[0])}
|
||||
</p>
|
||||
:
|
||||
<>
|
||||
{children ? <p css={tw`text-xs text-neutral-400 pt-2`}>{children}</p> : null}
|
||||
</>
|
||||
);
|
||||
) : (
|
||||
<>{children ? <p css={tw`text-xs text-neutral-400 pt-2`}>{children}</p> : null}</>
|
||||
);
|
||||
|
||||
export default InputError;
|
||||
|
|
|
@ -7,19 +7,21 @@ import Select from '@/components/elements/Select';
|
|||
|
||||
const Container = styled.div<{ visible?: boolean }>`
|
||||
${tw`relative`};
|
||||
|
||||
${props => props.visible && css`
|
||||
& ${Select} {
|
||||
background-image: none;
|
||||
}
|
||||
`};
|
||||
|
||||
${(props) =>
|
||||
props.visible &&
|
||||
css`
|
||||
& ${Select} {
|
||||
background-image: none;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
|
||||
const InputSpinner = ({ visible, children }: { visible: boolean; children: React.ReactNode }) => (
|
||||
<Container visible={visible}>
|
||||
<Fade appear unmountOnExit in={visible} timeout={150}>
|
||||
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>
|
||||
<Spinner size={'small'}/>
|
||||
<Spinner size={'small'} />
|
||||
</div>
|
||||
</Fade>
|
||||
{children}
|
||||
|
|
|
@ -3,7 +3,7 @@ import tw from 'twin.macro';
|
|||
|
||||
const Label = styled.label<{ isLight?: boolean }>`
|
||||
${tw`block text-xs uppercase text-neutral-200 mb-1 sm:mb-2`};
|
||||
${props => props.isLight && tw`text-neutral-700`};
|
||||
${(props) => props.isLight && tw`text-neutral-700`};
|
||||
`;
|
||||
|
||||
export default Label;
|
||||
|
|
|
@ -22,41 +22,55 @@ export interface ModalProps extends RequiredModalProps {
|
|||
|
||||
export const ModalMask = styled.div`
|
||||
${tw`fixed z-50 overflow-auto flex w-full inset-0`};
|
||||
background: rgba(0, 0, 0, 0.70);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
`;
|
||||
|
||||
const ModalContainer = styled.div<{ alignTop?: boolean }>`
|
||||
const ModalContainer = styled.div<{ alignTop?: boolean }>`
|
||||
max-width: 95%;
|
||||
max-height: calc(100vh - 8rem);
|
||||
${breakpoint('md')`max-width: 75%`};
|
||||
${breakpoint('lg')`max-width: 50%`};
|
||||
|
||||
${tw`relative flex flex-col w-full m-auto`};
|
||||
${props => props.alignTop && css`
|
||||
margin-top: 20%;
|
||||
${breakpoint('md')`margin-top: 10%`};
|
||||
`};
|
||||
${(props) =>
|
||||
props.alignTop &&
|
||||
css`
|
||||
margin-top: 20%;
|
||||
${breakpoint('md')`margin-top: 10%`};
|
||||
`};
|
||||
|
||||
margin-bottom: auto;
|
||||
|
||||
|
||||
& > .close-icon {
|
||||
${tw`absolute right-0 p-2 text-white cursor-pointer opacity-50 transition-all duration-150 ease-linear hover:opacity-100`};
|
||||
top: -2.5rem;
|
||||
|
||||
&:hover {${tw`transform rotate-90`}}
|
||||
|
||||
|
||||
&:hover {
|
||||
${tw`transform rotate-90`}
|
||||
}
|
||||
|
||||
& > svg {
|
||||
${tw`w-6 h-6`};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => {
|
||||
const [ render, setRender ] = useState(visible);
|
||||
const Modal: React.FC<ModalProps> = ({
|
||||
visible,
|
||||
appear,
|
||||
dismissable,
|
||||
showSpinnerOverlay,
|
||||
top = true,
|
||||
closeOnBackground = true,
|
||||
closeOnEscape = true,
|
||||
onDismissed,
|
||||
children,
|
||||
}) => {
|
||||
const [render, setRender] = useState(visible);
|
||||
|
||||
const isDismissable = useMemo(() => {
|
||||
return (dismissable || true) && !(showSpinnerOverlay || false);
|
||||
}, [ dismissable, showSpinnerOverlay ]);
|
||||
}, [dismissable, showSpinnerOverlay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDismissable || !closeOnEscape) return;
|
||||
|
@ -69,22 +83,16 @@ const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinner
|
|||
return () => {
|
||||
window.removeEventListener('keydown', handler);
|
||||
};
|
||||
}, [ isDismissable, closeOnEscape, render ]);
|
||||
}, [isDismissable, closeOnEscape, render]);
|
||||
|
||||
useEffect(() => setRender(visible), [ visible ]);
|
||||
useEffect(() => setRender(visible), [visible]);
|
||||
|
||||
return (
|
||||
<Fade
|
||||
in={render}
|
||||
timeout={150}
|
||||
appear={appear || true}
|
||||
unmountOnExit
|
||||
onExited={() => onDismissed()}
|
||||
>
|
||||
<Fade in={render} timeout={150} appear={appear || true} unmountOnExit onExited={() => onDismissed()}>
|
||||
<ModalMask
|
||||
onClick={e => e.stopPropagation()}
|
||||
onContextMenu={e => e.stopPropagation()}
|
||||
onMouseDown={e => {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => {
|
||||
if (isDismissable && closeOnBackground) {
|
||||
e.stopPropagation();
|
||||
if (e.target === e.currentTarget) {
|
||||
|
@ -94,29 +102,36 @@ const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinner
|
|||
}}
|
||||
>
|
||||
<ModalContainer alignTop={top}>
|
||||
{isDismissable &&
|
||||
<div className={'close-icon'} onClick={() => setRender(false)}>
|
||||
<svg xmlns={'http://www.w3.org/2000/svg'} fill={'none'} viewBox={'0 0 24 24'} stroke={'currentColor'}>
|
||||
<path
|
||||
strokeLinecap={'round'}
|
||||
strokeLinejoin={'round'}
|
||||
strokeWidth={'2'}
|
||||
d={'M6 18L18 6M6 6l12 12'}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
{showSpinnerOverlay &&
|
||||
<Fade timeout={150} appear in>
|
||||
<div
|
||||
css={tw`absolute w-full h-full rounded flex items-center justify-center`}
|
||||
style={{ background: 'hsla(211, 10%, 53%, 0.35)', zIndex: 9999 }}
|
||||
>
|
||||
<Spinner/>
|
||||
{isDismissable && (
|
||||
<div className={'close-icon'} onClick={() => setRender(false)}>
|
||||
<svg
|
||||
xmlns={'http://www.w3.org/2000/svg'}
|
||||
fill={'none'}
|
||||
viewBox={'0 0 24 24'}
|
||||
stroke={'currentColor'}
|
||||
>
|
||||
<path
|
||||
strokeLinecap={'round'}
|
||||
strokeLinejoin={'round'}
|
||||
strokeWidth={'2'}
|
||||
d={'M6 18L18 6M6 6l12 12'}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</Fade>
|
||||
}
|
||||
<div css={tw`bg-neutral-800 p-3 sm:p-4 md:p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}>
|
||||
)}
|
||||
{showSpinnerOverlay && (
|
||||
<Fade timeout={150} appear in>
|
||||
<div
|
||||
css={tw`absolute w-full h-full rounded flex items-center justify-center`}
|
||||
style={{ background: 'hsla(211, 10%, 53%, 0.35)', zIndex: 9999 }}
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
</Fade>
|
||||
)}
|
||||
<div
|
||||
css={tw`bg-neutral-800 p-3 sm:p-4 md:p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ModalContainer>
|
||||
|
|
|
@ -15,15 +15,13 @@ const PageContentBlock: React.FC<PageContentBlockProps> = ({ title, showFlashKey
|
|||
if (title) {
|
||||
document.title = title;
|
||||
}
|
||||
}, [ title ]);
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<CSSTransition timeout={150} classNames={'fade'} appear in>
|
||||
<>
|
||||
<ContentContainer css={tw`my-4 sm:my-10`} className={className}>
|
||||
{showFlashKey &&
|
||||
<FlashMessageRender byKey={showFlashKey} css={tw`mb-4`}/>
|
||||
}
|
||||
{showFlashKey && <FlashMessageRender byKey={showFlashKey} css={tw`mb-4`} />}
|
||||
{children}
|
||||
</ContentContainer>
|
||||
<ContentContainer css={tw`mb-4`}>
|
||||
|
@ -36,7 +34,7 @@ const PageContentBlock: React.FC<PageContentBlockProps> = ({ title, showFlashKey
|
|||
>
|
||||
Pterodactyl®
|
||||
</a>
|
||||
© 2015 - {(new Date()).getFullYear()}
|
||||
© 2015 - {new Date().getFullYear()}
|
||||
</p>
|
||||
</ContentContainer>
|
||||
</>
|
||||
|
|
|
@ -22,13 +22,13 @@ interface Props<T> {
|
|||
|
||||
const Block = styled(Button)`
|
||||
${tw`p-0 w-10 h-10`}
|
||||
|
||||
|
||||
&:not(:last-of-type) {
|
||||
${tw`mr-2`};
|
||||
}
|
||||
`;
|
||||
|
||||
function Pagination<T> ({ data: { items, pagination }, onPageSelect, children }: Props<T>) {
|
||||
function Pagination<T>({ data: { items, pagination }, onPageSelect, children }: Props<T>) {
|
||||
const isFirstPage = pagination.currentPage === 1;
|
||||
const isLastPage = pagination.currentPage >= pagination.totalPages;
|
||||
|
||||
|
@ -46,19 +46,14 @@ function Pagination<T> ({ data: { items, pagination }, onPageSelect, children }:
|
|||
return (
|
||||
<>
|
||||
{children({ items, isFirstPage, isLastPage })}
|
||||
{(pages.length > 1) &&
|
||||
<div css={tw`mt-4 flex justify-center`}>
|
||||
{(pages[0] > 1 && !isFirstPage) &&
|
||||
<Block
|
||||
isSecondary
|
||||
color={'primary'}
|
||||
onClick={() => onPageSelect(1)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleDoubleLeft}/>
|
||||
</Block>
|
||||
}
|
||||
{
|
||||
pages.map(i => (
|
||||
{pages.length > 1 && (
|
||||
<div css={tw`mt-4 flex justify-center`}>
|
||||
{pages[0] > 1 && !isFirstPage && (
|
||||
<Block isSecondary color={'primary'} onClick={() => onPageSelect(1)}>
|
||||
<FontAwesomeIcon icon={faAngleDoubleLeft} />
|
||||
</Block>
|
||||
)}
|
||||
{pages.map((i) => (
|
||||
<Block
|
||||
isSecondary={pagination.currentPage !== i}
|
||||
color={'primary'}
|
||||
|
@ -67,19 +62,14 @@ function Pagination<T> ({ data: { items, pagination }, onPageSelect, children }:
|
|||
>
|
||||
{i}
|
||||
</Block>
|
||||
))
|
||||
}
|
||||
{(pages[4] < pagination.totalPages && !isLastPage) &&
|
||||
<Block
|
||||
isSecondary
|
||||
color={'primary'}
|
||||
onClick={() => onPageSelect(pagination.totalPages)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleDoubleRight}/>
|
||||
</Block>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
))}
|
||||
{pages[4] < pagination.totalPages && !isLastPage && (
|
||||
<Block isSecondary color={'primary'} onClick={() => onPageSelect(pagination.totalPages)}>
|
||||
<FontAwesomeIcon icon={faAngleDoubleRight} />
|
||||
</Block>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,20 +11,17 @@ interface Props extends Omit<RouteProps, 'path'> {
|
|||
|
||||
export default ({ permission, children, ...props }: Props) => (
|
||||
<Route {...props}>
|
||||
{!permission ?
|
||||
{!permission ? (
|
||||
children
|
||||
:
|
||||
) : (
|
||||
<Can
|
||||
action={permission}
|
||||
renderOnError={
|
||||
<ServerError
|
||||
title={'Access Denied'}
|
||||
message={'You do not have permission to access this page.'}
|
||||
/>
|
||||
<ServerError title={'Access Denied'} message={'You do not have permission to access this page.'} />
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Can>
|
||||
}
|
||||
)}
|
||||
</Route>
|
||||
);
|
||||
|
|
|
@ -14,10 +14,10 @@ const BarFill = styled.div`
|
|||
export default () => {
|
||||
const interval = useRef<number>(null);
|
||||
const timeout = useRef<number>(null);
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const progress = useStoreState(state => state.progress.progress);
|
||||
const continuous = useStoreState(state => state.progress.continuous);
|
||||
const setProgress = useStoreActions(actions => actions.progress.setProgress);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const progress = useStoreState((state) => state.progress.progress);
|
||||
const continuous = useStoreState((state) => state.progress.continuous);
|
||||
const setProgress = useStoreActions((actions) => actions.progress.setProgress);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
@ -33,7 +33,7 @@ export default () => {
|
|||
// @ts-ignore
|
||||
timeout.current = setTimeout(() => setProgress(undefined), 500);
|
||||
}
|
||||
}, [ progress ]);
|
||||
}, [progress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!continuous) {
|
||||
|
@ -44,7 +44,7 @@ export default () => {
|
|||
if (!progress || progress === 0) {
|
||||
setProgress(randomInt(20, 30));
|
||||
}
|
||||
}, [ continuous ]);
|
||||
}, [continuous]);
|
||||
|
||||
useEffect(() => {
|
||||
if (continuous) {
|
||||
|
@ -56,18 +56,12 @@ export default () => {
|
|||
interval.current = setTimeout(() => setProgress(progress + randomInt(1, 5)), 500);
|
||||
}
|
||||
}
|
||||
}, [ progress, continuous ]);
|
||||
}, [progress, continuous]);
|
||||
|
||||
return (
|
||||
<div css={tw`w-full fixed`} style={{ height: '2px' }}>
|
||||
<CSSTransition
|
||||
timeout={150}
|
||||
appear
|
||||
in={visible}
|
||||
unmountOnExit
|
||||
classNames={'fade'}
|
||||
>
|
||||
<BarFill style={{ width: progress === undefined ? '100%' : `${progress}%` }}/>
|
||||
<CSSTransition timeout={150} appear in={visible} unmountOnExit classNames={'fade'}>
|
||||
<BarFill style={{ width: progress === undefined ? '100%' : `${progress}%` }} />
|
||||
</CSSTransition>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -43,22 +43,22 @@ const ActionButton = styled(Button)`
|
|||
const ScreenBlock = ({ title, image, message, onBack, onRetry }: ScreenBlockProps) => (
|
||||
<PageContentBlock>
|
||||
<div css={tw`flex justify-center`}>
|
||||
<div css={tw`w-full sm:w-3/4 md:w-1/2 p-12 md:p-20 bg-neutral-100 rounded-lg shadow-lg text-center relative`}>
|
||||
{(typeof onBack === 'function' || typeof onRetry === 'function') &&
|
||||
<div css={tw`absolute left-0 top-0 ml-4 mt-4`}>
|
||||
<ActionButton
|
||||
onClick={() => onRetry ? onRetry() : (onBack ? onBack() : null)}
|
||||
className={onRetry ? 'hover:spin' : undefined}
|
||||
>
|
||||
<FontAwesomeIcon icon={onRetry ? faSyncAlt : faArrowLeft}/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
}
|
||||
<img src={image} css={tw`w-2/3 h-auto select-none mx-auto`}/>
|
||||
<div
|
||||
css={tw`w-full sm:w-3/4 md:w-1/2 p-12 md:p-20 bg-neutral-100 rounded-lg shadow-lg text-center relative`}
|
||||
>
|
||||
{(typeof onBack === 'function' || typeof onRetry === 'function') && (
|
||||
<div css={tw`absolute left-0 top-0 ml-4 mt-4`}>
|
||||
<ActionButton
|
||||
onClick={() => (onRetry ? onRetry() : onBack ? onBack() : null)}
|
||||
className={onRetry ? 'hover:spin' : undefined}
|
||||
>
|
||||
<FontAwesomeIcon icon={onRetry ? faSyncAlt : faArrowLeft} />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
<img src={image} css={tw`w-2/3 h-auto select-none mx-auto`} />
|
||||
<h2 css={tw`mt-10 text-neutral-900 font-bold text-4xl`}>{title}</h2>
|
||||
<p css={tw`text-sm text-neutral-700 mt-2`}>
|
||||
{message}
|
||||
</p>
|
||||
<p css={tw`text-sm text-neutral-700 mt-2`}>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
|
@ -66,10 +66,10 @@ const ScreenBlock = ({ title, image, message, onBack, onRetry }: ScreenBlockProp
|
|||
|
||||
type ServerErrorProps = (Omit<PropsWithBack, 'image' | 'title'> | Omit<PropsWithRetry, 'image' | 'title'>) & {
|
||||
title?: string;
|
||||
}
|
||||
};
|
||||
|
||||
const ServerError = ({ title, ...props }: ServerErrorProps) => (
|
||||
<ScreenBlock title={title || 'Something went wrong'} image={ServerErrorSvg} {...props}/>
|
||||
<ScreenBlock title={title || 'Something went wrong'} image={ServerErrorSvg} {...props} />
|
||||
);
|
||||
|
||||
const NotFound = ({ title, message, onBack }: Partial<Pick<ScreenBlockProps, 'title' | 'message' | 'onBack'>>) => (
|
||||
|
|
|
@ -8,7 +8,9 @@ interface Props {
|
|||
const Select = styled.select<Props>`
|
||||
${tw`shadow-none block p-3 pr-8 rounded border w-full text-sm transition-colors duration-150 ease-linear`};
|
||||
|
||||
&, &:hover:not(:disabled), &:focus {
|
||||
&,
|
||||
&:hover:not(:disabled),
|
||||
&:focus {
|
||||
${tw`outline-none`};
|
||||
}
|
||||
|
||||
|
@ -22,15 +24,18 @@ const Select = styled.select<Props>`
|
|||
&::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${props => !props.hideDropdownArrow && css`
|
||||
${tw`bg-neutral-600 border-neutral-500 text-neutral-200`};
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='%23C3D1DF' d='M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z'/%3e%3c/svg%3e ");
|
||||
|
||||
&:hover:not(:disabled), &:focus {
|
||||
${tw`border-neutral-400`};
|
||||
}
|
||||
`};
|
||||
|
||||
${(props) =>
|
||||
!props.hideDropdownArrow &&
|
||||
css`
|
||||
${tw`bg-neutral-600 border-neutral-500 text-neutral-200`};
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='%23C3D1DF' d='M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z'/%3e%3c/svg%3e ");
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus {
|
||||
${tw`border-neutral-400`};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
export default Select;
|
||||
|
|
|
@ -7,7 +7,7 @@ interface Props extends PageContentBlockProps {
|
|||
}
|
||||
|
||||
const ServerContentBlock: React.FC<Props> = ({ title, children, ...props }) => {
|
||||
const name = ServerContext.useStoreState(state => state.server.data!.name);
|
||||
const name = ServerContext.useStoreState((state) => state.server.data!.name);
|
||||
|
||||
return (
|
||||
<PageContentBlock title={`${name} | ${title}`} {...props}>
|
||||
|
|
|
@ -25,30 +25,30 @@ const SpinnerComponent = styled.div<Props>`
|
|||
${tw`w-8 h-8`};
|
||||
border-width: 3px;
|
||||
border-radius: 50%;
|
||||
animation: ${spin} 1s cubic-bezier(0.55, 0.25, 0.25, 0.70) infinite;
|
||||
animation: ${spin} 1s cubic-bezier(0.55, 0.25, 0.25, 0.7) infinite;
|
||||
|
||||
${props => props.size === 'small' ? tw`w-4 h-4 border-2` : (props.size === 'large' ? css`
|
||||
${tw`w-16 h-16`};
|
||||
border-width: 6px;
|
||||
` : null)};
|
||||
${(props) =>
|
||||
props.size === 'small'
|
||||
? tw`w-4 h-4 border-2`
|
||||
: props.size === 'large'
|
||||
? css`
|
||||
${tw`w-16 h-16`};
|
||||
border-width: 6px;
|
||||
`
|
||||
: null};
|
||||
|
||||
border-color: ${props => !props.isBlue ? 'rgba(255, 255, 255, 0.2)' : 'hsla(212, 92%, 43%, 0.2)'};
|
||||
border-top-color: ${props => !props.isBlue ? 'rgb(255, 255, 255)' : 'hsl(212, 92%, 43%)'};
|
||||
border-color: ${(props) => (!props.isBlue ? 'rgba(255, 255, 255, 0.2)' : 'hsla(212, 92%, 43%, 0.2)')};
|
||||
border-top-color: ${(props) => (!props.isBlue ? 'rgb(255, 255, 255)' : 'hsl(212, 92%, 43%)')};
|
||||
`;
|
||||
|
||||
const Spinner: Spinner = ({ centered, ...props }) => (
|
||||
centered ?
|
||||
<div
|
||||
css={[
|
||||
tw`flex justify-center items-center`,
|
||||
props.size === 'large' ? tw`m-20` : tw`m-6`,
|
||||
]}
|
||||
>
|
||||
<SpinnerComponent {...props}/>
|
||||
const Spinner: Spinner = ({ centered, ...props }) =>
|
||||
centered ? (
|
||||
<div css={[tw`flex justify-center items-center`, props.size === 'large' ? tw`m-20` : tw`m-6`]}>
|
||||
<SpinnerComponent {...props} />
|
||||
</div>
|
||||
:
|
||||
<SpinnerComponent {...props}/>
|
||||
);
|
||||
) : (
|
||||
<SpinnerComponent {...props} />
|
||||
);
|
||||
Spinner.displayName = 'Spinner';
|
||||
|
||||
Spinner.Size = {
|
||||
|
@ -58,10 +58,8 @@ Spinner.Size = {
|
|||
};
|
||||
|
||||
Spinner.Suspense = ({ children, centered = true, size = Spinner.Size.LARGE, ...props }) => (
|
||||
<Suspense fallback={<Spinner centered={centered} size={size} {...props}/>}>
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
<Suspense fallback={<Spinner centered={centered} size={size} {...props} />}>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Suspense>
|
||||
);
|
||||
Spinner.Suspense.displayName = 'Spinner.Suspense';
|
||||
|
|
|
@ -19,7 +19,7 @@ const SpinnerOverlay: React.FC<Props> = ({ size, fixed, visible, backgroundOpaci
|
|||
]}
|
||||
style={{ background: `rgba(0, 0, 0, ${backgroundOpacity || 0.45})` }}
|
||||
>
|
||||
<Spinner size={size}/>
|
||||
<Spinner size={size} />
|
||||
{children && (typeof children === 'string' ? <p css={tw`mt-4 text-neutral-400`}>{children}</p> : children)}
|
||||
</div>
|
||||
</Fade>
|
||||
|
|
|
@ -8,7 +8,8 @@ const SubNavigation = styled.div`
|
|||
${tw`flex items-center text-sm mx-auto px-2`};
|
||||
max-width: 1200px;
|
||||
|
||||
& > a, & > div {
|
||||
& > a,
|
||||
& > div {
|
||||
${tw`inline-block py-3 px-4 text-neutral-300 no-underline whitespace-nowrap transition-all duration-150`};
|
||||
|
||||
&:not(:first-of-type) {
|
||||
|
@ -19,7 +20,8 @@ const SubNavigation = styled.div`
|
|||
${tw`text-neutral-100`};
|
||||
}
|
||||
|
||||
&:active, &.active {
|
||||
&:active,
|
||||
&.active {
|
||||
${tw`text-neutral-100`};
|
||||
box-shadow: inset 0 -2px ${theme`colors.cyan.600`.toString()};
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import Input from '@/components/elements/Input';
|
|||
const ToggleContainer = styled.div`
|
||||
${tw`relative select-none w-12 leading-normal`};
|
||||
|
||||
& > input[type="checkbox"] {
|
||||
& > input[type='checkbox'] {
|
||||
${tw`hidden`};
|
||||
|
||||
&:checked + label {
|
||||
|
@ -30,7 +30,7 @@ const ToggleContainer = styled.div`
|
|||
right: calc(50% + 0.125rem);
|
||||
//width: 1.25rem;
|
||||
//height: 1.25rem;
|
||||
content: "";
|
||||
content: '';
|
||||
transition: all 75ms ease-in;
|
||||
}
|
||||
}
|
||||
|
@ -52,35 +52,28 @@ const Switch = ({ name, label, description, defaultChecked, readOnly, onChange,
|
|||
return (
|
||||
<div css={tw`flex items-center`}>
|
||||
<ToggleContainer css={tw`flex-none`}>
|
||||
{children
|
||||
|| <Input
|
||||
id={uuid}
|
||||
name={name}
|
||||
type={'checkbox'}
|
||||
onChange={e => onChange && onChange(e)}
|
||||
defaultChecked={defaultChecked}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
}
|
||||
<Label htmlFor={uuid}/>
|
||||
{children || (
|
||||
<Input
|
||||
id={uuid}
|
||||
name={name}
|
||||
type={'checkbox'}
|
||||
onChange={(e) => onChange && onChange(e)}
|
||||
defaultChecked={defaultChecked}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
)}
|
||||
<Label htmlFor={uuid} />
|
||||
</ToggleContainer>
|
||||
{(label || description) &&
|
||||
<div css={tw`ml-4 w-full`}>
|
||||
{label &&
|
||||
<Label
|
||||
css={[ tw`cursor-pointer`, !!description && tw`mb-0` ]}
|
||||
htmlFor={uuid}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
}
|
||||
{description &&
|
||||
<p css={tw`text-neutral-400 text-sm mt-2`}>
|
||||
{description}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{(label || description) && (
|
||||
<div css={tw`ml-4 w-full`}>
|
||||
{label && (
|
||||
<Label css={[tw`cursor-pointer`, !!description && tw`mb-0`]} htmlFor={uuid}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
{description && <p css={tw`text-neutral-400 text-sm mt-2`}>{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -14,17 +14,16 @@ interface Props {
|
|||
const TitledGreyBox = ({ icon, title, children, className }: Props) => (
|
||||
<div css={tw`rounded shadow-md bg-neutral-700`} className={className}>
|
||||
<div css={tw`bg-neutral-900 rounded-t p-3 border-b border-black`}>
|
||||
{typeof title === 'string' ?
|
||||
{typeof title === 'string' ? (
|
||||
<p css={tw`text-sm uppercase`}>
|
||||
{icon && <FontAwesomeIcon icon={icon} css={tw`mr-2 text-neutral-300`}/>}{title}
|
||||
{icon && <FontAwesomeIcon icon={icon} css={tw`mr-2 text-neutral-300`} />}
|
||||
{title}
|
||||
</p>
|
||||
:
|
||||
) : (
|
||||
title
|
||||
}
|
||||
</div>
|
||||
<div css={tw`p-3`}>
|
||||
{children}
|
||||
)}
|
||||
</div>
|
||||
<div css={tw`p-3`}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
@ -25,9 +25,12 @@ const formatProperties = (properties: Record<string, unknown>): Record<string, u
|
|||
|
||||
return {
|
||||
...obj,
|
||||
[key]: isCount || typeof value !== 'string'
|
||||
? (isObject(value) ? formatProperties(value) : value)
|
||||
: `<strong>${value}</strong>`,
|
||||
[key]:
|
||||
isCount || typeof value !== 'string'
|
||||
? isObject(value)
|
||||
? formatProperties(value)
|
||||
: value
|
||||
: `<strong>${value}</strong>`,
|
||||
};
|
||||
}, {});
|
||||
};
|
||||
|
@ -58,16 +61,18 @@ export default ({ activity, children }: Props) => {
|
|||
{activity.event}
|
||||
</Link>
|
||||
<div className={classNames(style.icons, 'group-hover:text-gray-300')}>
|
||||
{activity.isApi &&
|
||||
{activity.isApi && (
|
||||
<Tooltip placement={'top'} content={'Performed using API Key'}>
|
||||
<span><TerminalIcon/></span>
|
||||
<span>
|
||||
<TerminalIcon />
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<p className={style.description}>
|
||||
<Translate ns={'activity'} values={properties} i18nKey={activity.event.replace(':', '.')}/>
|
||||
<Translate ns={'activity'} values={properties} i18nKey={activity.event.replace(':', '.')} />
|
||||
</p>
|
||||
<div className={'mt-1 flex items-center text-sm'}>
|
||||
<Link
|
||||
|
@ -77,17 +82,12 @@ export default ({ activity, children }: Props) => {
|
|||
{activity.ip}
|
||||
</Link>
|
||||
<span className={'text-gray-400'}> | </span>
|
||||
<Tooltip
|
||||
placement={'right'}
|
||||
content={format(activity.timestamp, 'MMM do, yyyy H:mm:ss')}
|
||||
>
|
||||
<span>
|
||||
{formatDistanceToNowStrict(activity.timestamp, { addSuffix: true })}
|
||||
</span>
|
||||
<Tooltip placement={'right'} content={format(activity.timestamp, 'MMM do, yyyy H:mm:ss')}>
|
||||
<span>{formatDistanceToNowStrict(activity.timestamp, { addSuffix: true })}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{activity.hasAdditionalMetadata && <ActivityLogMetaButton meta={activity.properties}/>}
|
||||
{activity.hasAdditionalMetadata && <ActivityLogMetaButton meta={activity.properties} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -4,16 +4,11 @@ import { Dialog } from '@/components/elements/dialog';
|
|||
import { Button } from '@/components/elements/button/index';
|
||||
|
||||
export default ({ meta }: { meta: Record<string, unknown> }) => {
|
||||
const [ open, setOpen ] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={'self-center md:px-4'}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
hideCloseIcon
|
||||
title={'Metadata'}
|
||||
>
|
||||
<Dialog open={open} onClose={() => setOpen(false)} hideCloseIcon title={'Metadata'}>
|
||||
<pre className={'bg-gray-900 rounded p-2 overflow-x-scroll font-mono text-sm leading-relaxed'}>
|
||||
{JSON.stringify(meta, null, 2)}
|
||||
</pre>
|
||||
|
@ -23,10 +18,12 @@ export default ({ meta }: { meta: Record<string, unknown> }) => {
|
|||
</Dialog>
|
||||
<button
|
||||
aria-describedby={'View additional event metadata'}
|
||||
className={'p-2 transition-colors duration-100 text-gray-400 group-hover:text-gray-300 group-hover:hover:text-gray-50'}
|
||||
className={
|
||||
'p-2 transition-colors duration-100 text-gray-400 group-hover:text-gray-300 group-hover:hover:text-gray-50'
|
||||
}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<ClipboardListIcon className={'w-5 h-5'}/>
|
||||
<ClipboardListIcon className={'w-5 h-5'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -17,14 +17,14 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
[styles.small]: size === Options.Size.Small,
|
||||
[styles.large]: size === Options.Size.Large,
|
||||
},
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const TextButton = forwardRef<HTMLButtonElement, ButtonProps>(({ className, ...props }, ref) => (
|
||||
|
|
|
@ -20,4 +20,4 @@ export type ButtonProps = JSX.IntrinsicElements['button'] & {
|
|||
shape?: Shape;
|
||||
size?: Size;
|
||||
variant?: Variant;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -7,7 +7,7 @@ type ConfirmationProps = Omit<DialogProps, 'description' | 'children'> & {
|
|||
children: React.ReactNode;
|
||||
confirm?: string | undefined;
|
||||
onConfirmed: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
}
|
||||
};
|
||||
|
||||
export default ({ confirm = 'Okay', children, onConfirmed, ...props }: ConfirmationProps) => {
|
||||
return (
|
||||
|
|
|
@ -16,19 +16,17 @@ export interface DialogProps {
|
|||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DialogButtons = ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
);
|
||||
const DialogButtons = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
|
||||
const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }: DialogProps) => {
|
||||
const items = React.Children.toArray(children || []);
|
||||
const [ buttons, icon, content ] = [
|
||||
const [buttons, icon, content] = [
|
||||
// @ts-expect-error
|
||||
items.find(child => child.type === DialogButtons),
|
||||
items.find((child) => child.type === DialogButtons),
|
||||
// @ts-expect-error
|
||||
items.find(child => child.type === DialogIcon),
|
||||
items.find((child) => child.type === DialogIcon),
|
||||
// @ts-expect-error
|
||||
items.filter(child => ![ DialogIcon, DialogButtons ].includes(child.type)),
|
||||
items.filter((child) => ![DialogIcon, DialogButtons].includes(child.type)),
|
||||
];
|
||||
|
||||
return (
|
||||
|
@ -44,7 +42,7 @@ const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }:
|
|||
open={open}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={'fixed inset-0 bg-gray-900/50 z-40'}/>
|
||||
<div className={'fixed inset-0 bg-gray-900/50 z-40'} />
|
||||
<div className={'fixed inset-0 overflow-y-auto z-50'}>
|
||||
<div className={'flex min-h-full items-center justify-center p-4 text-center'}>
|
||||
<HDialog.Panel
|
||||
|
@ -61,22 +59,28 @@ const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }:
|
|||
<div className={'flex p-6 overflow-y-auto'}>
|
||||
{icon && <div className={'mr-4'}>{icon}</div>}
|
||||
<div className={'flex-1 max-h-[70vh]'}>
|
||||
{title &&
|
||||
<HDialog.Title className={'font-header text-xl font-medium mb-2 text-gray-50 pr-4'}>
|
||||
{title && (
|
||||
<HDialog.Title
|
||||
className={'font-header text-xl font-medium mb-2 text-gray-50 pr-4'}
|
||||
>
|
||||
{title}
|
||||
</HDialog.Title>
|
||||
}
|
||||
)}
|
||||
{description && <HDialog.Description>{description}</HDialog.Description>}
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
{buttons &&
|
||||
<div className={'px-6 py-3 bg-gray-700 flex items-center justify-end space-x-3 rounded-b'}>
|
||||
{buttons && (
|
||||
<div
|
||||
className={
|
||||
'px-6 py-3 bg-gray-700 flex items-center justify-end space-x-3 rounded-b'
|
||||
}
|
||||
>
|
||||
{buttons}
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
{/* Keep this below the other buttons so that it isn't the default focus if they're present. */}
|
||||
{!hideCloseIcon &&
|
||||
{!hideCloseIcon && (
|
||||
<div className={'absolute right-0 top-0 m-4'}>
|
||||
<Button.Text
|
||||
size={Button.Sizes.Small}
|
||||
|
@ -84,10 +88,10 @@ const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }:
|
|||
onClick={onClose}
|
||||
className={'hover:rotate-90'}
|
||||
>
|
||||
<XIcon className={'w-5 h-5'}/>
|
||||
<XIcon className={'w-5 h-5'} />
|
||||
</Button.Text>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</HDialog.Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -8,22 +8,22 @@ interface Props {
|
|||
}
|
||||
|
||||
export default ({ type, className }: Props) => {
|
||||
const [ Component, styles ] = (function (): [ (props: React.ComponentProps<'svg'>) => JSX.Element, string ] {
|
||||
const [Component, styles] = (function (): [(props: React.ComponentProps<'svg'>) => JSX.Element, string] {
|
||||
switch (type) {
|
||||
case 'danger':
|
||||
return [ ShieldExclamationIcon, 'bg-red-500 text-red-50' ];
|
||||
return [ShieldExclamationIcon, 'bg-red-500 text-red-50'];
|
||||
case 'warning':
|
||||
return [ ExclamationIcon, 'bg-yellow-600 text-yellow-50' ];
|
||||
return [ExclamationIcon, 'bg-yellow-600 text-yellow-50'];
|
||||
case 'success':
|
||||
return [ CheckIcon, 'bg-green-600 text-green-50' ];
|
||||
return [CheckIcon, 'bg-green-600 text-green-50'];
|
||||
case 'info':
|
||||
return [ InformationCircleIcon, 'bg-primary-500 text-primary-50' ];
|
||||
return [InformationCircleIcon, 'bg-primary-500 text-primary-50'];
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className={classNames('flex items-center justify-center w-10 h-10 rounded-full', styles, className)}>
|
||||
<Component className={'w-6 h-6'}/>
|
||||
<Component className={'w-6 h-6'} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -11,22 +11,22 @@ interface Props {
|
|||
}
|
||||
|
||||
const DropdownGap = ({ invisible }: { invisible?: boolean }) => (
|
||||
<div className={classNames('border m-2', { 'border-neutral-700': !invisible, 'border-transparent': invisible })}/>
|
||||
<div className={classNames('border m-2', { 'border-neutral-700': !invisible, 'border-transparent': invisible })} />
|
||||
);
|
||||
|
||||
type TypedChild = (React.ReactChild | React.ReactFragment | React.ReactPortal) & {
|
||||
type?: JSX.Element;
|
||||
}
|
||||
};
|
||||
|
||||
const Dropdown = forwardRef<typeof Menu, Props>(({ as, children }, ref) => {
|
||||
const [ Button, items ] = useMemo(() => {
|
||||
const [Button, items] = useMemo(() => {
|
||||
const list = React.Children.toArray(children) as unknown as TypedChild[];
|
||||
|
||||
return [
|
||||
list.filter(child => child.type === DropdownButton),
|
||||
list.filter(child => child.type !== DropdownButton),
|
||||
list.filter((child) => child.type === DropdownButton),
|
||||
list.filter((child) => child.type !== DropdownButton),
|
||||
];
|
||||
}, [ children ]);
|
||||
}, [children]);
|
||||
|
||||
if (!Button) {
|
||||
throw new Error('Cannot mount <Dropdown /> component without a child <Dropdown.Button />.');
|
||||
|
@ -44,9 +44,7 @@ const Dropdown = forwardRef<typeof Menu, Props>(({ as, children }, ref) => {
|
|||
leaveTo={'transform scale-95 opacity-0'}
|
||||
>
|
||||
<Menu.Items className={classNames(styles.items_container, 'w-56')}>
|
||||
<div className={'px-1 py-1'}>
|
||||
{items}
|
||||
</div>
|
||||
<div className={'px-1 py-1'}>{items}</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
|
|
|
@ -12,13 +12,13 @@ interface Props {
|
|||
|
||||
export default ({ className, animate = true, children }: Props) => (
|
||||
<Menu.Button className={classNames(styles.button, className || 'px-4')}>
|
||||
{typeof children === 'string' ?
|
||||
{typeof children === 'string' ? (
|
||||
<>
|
||||
<span className={'mr-2'}>{children}</span>
|
||||
<ChevronDownIcon aria-hidden={'true'} data-animated={animate.toString()}/>
|
||||
<ChevronDownIcon aria-hidden={'true'} data-animated={animate.toString()} />
|
||||
</>
|
||||
:
|
||||
) : (
|
||||
children
|
||||
}
|
||||
)}
|
||||
</Menu.Button>
|
||||
);
|
||||
|
|
|
@ -12,32 +12,31 @@ interface Props {
|
|||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const DropdownItem = forwardRef<HTMLAnchorElement, Props>(({
|
||||
disabled,
|
||||
danger,
|
||||
className,
|
||||
onClick,
|
||||
children,
|
||||
icon: IconComponent,
|
||||
}, ref) => {
|
||||
return (
|
||||
<Menu.Item disabled={disabled}>
|
||||
{({ disabled, active }) => (
|
||||
<a
|
||||
ref={ref}
|
||||
href={'#'}
|
||||
className={classNames(styles.menu_item, {
|
||||
[styles.danger]: danger,
|
||||
[styles.disabled]: disabled,
|
||||
}, className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{IconComponent}
|
||||
{typeof children === 'function' ? children({ disabled, active }) : children}
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
});
|
||||
const DropdownItem = forwardRef<HTMLAnchorElement, Props>(
|
||||
({ disabled, danger, className, onClick, children, icon: IconComponent }, ref) => {
|
||||
return (
|
||||
<Menu.Item disabled={disabled}>
|
||||
{({ disabled, active }) => (
|
||||
<a
|
||||
ref={ref}
|
||||
href={'#'}
|
||||
className={classNames(
|
||||
styles.menu_item,
|
||||
{
|
||||
[styles.danger]: danger,
|
||||
[styles.disabled]: disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{IconComponent}
|
||||
{typeof children === 'function' ? children({ disabled, active }) : children}
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default DropdownItem;
|
||||
|
|
|
@ -3,9 +3,5 @@ import classNames from 'classnames';
|
|||
import styles from './styles.module.css';
|
||||
|
||||
export default forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(({ className, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={classNames('form-input', styles.text_input, className)}
|
||||
{...props}
|
||||
/>
|
||||
<input ref={ref} className={classNames('form-input', styles.text_input, className)} {...props} />
|
||||
));
|
||||
|
|
|
@ -3,9 +3,5 @@ import classNames from 'classnames';
|
|||
import styles from './styles.module.css';
|
||||
|
||||
export default forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(({ className, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={classNames('form-input', styles.text_input, className)}
|
||||
{...props}
|
||||
/>
|
||||
<input ref={ref} className={classNames('form-input', styles.text_input, className)} {...props} />
|
||||
));
|
||||
|
|
|
@ -12,7 +12,7 @@ interface Props {
|
|||
|
||||
const PaginationFooter = ({ pagination, className, onPageSelect }: Props) => {
|
||||
const start = (pagination.currentPage - 1) * pagination.perPage;
|
||||
const end = ((pagination.currentPage - 1) * pagination.perPage) + pagination.count;
|
||||
const end = (pagination.currentPage - 1) * pagination.perPage + pagination.count;
|
||||
|
||||
const { currentPage: current, totalPages: total } = pagination;
|
||||
|
||||
|
@ -43,31 +43,34 @@ const PaginationFooter = ({ pagination, className, onPageSelect }: Props) => {
|
|||
Showing
|
||||
<span className={'font-semibold text-neutral-400'}>
|
||||
{Math.max(start, Math.min(pagination.total, 1))}
|
||||
</span> to
|
||||
</span>
|
||||
to
|
||||
<span className={'font-semibold text-neutral-400'}>{end}</span> of
|
||||
<span className={'font-semibold text-neutral-400'}>{pagination.total}</span> results.
|
||||
</p>
|
||||
{pagination.totalPages > 1 &&
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className={'flex space-x-1'}>
|
||||
<Button.Text {...buttonProps(1)} disabled={pages.previous.length !== 2}>
|
||||
<ChevronDoubleLeftIcon className={'w-3 h-3'}/>
|
||||
<ChevronDoubleLeftIcon className={'w-3 h-3'} />
|
||||
</Button.Text>
|
||||
{pages.previous.reverse().map((value) => (
|
||||
<Button.Text key={`previous-${value}`} {...buttonProps(value)}>
|
||||
{value}
|
||||
</Button.Text>
|
||||
))}
|
||||
<Button size={Button.Sizes.Small} shape={Button.Shapes.IconSquare}>{current}</Button>
|
||||
<Button size={Button.Sizes.Small} shape={Button.Shapes.IconSquare}>
|
||||
{current}
|
||||
</Button>
|
||||
{pages.next.map((value) => (
|
||||
<Button.Text key={`next-${value}`} {...buttonProps(value)}>
|
||||
{value}
|
||||
</Button.Text>
|
||||
))}
|
||||
<Button.Text {...buttonProps(total)} disabled={pages.next.length !== 2}>
|
||||
<ChevronDoubleRightIcon className={'w-3 h-3'}/>
|
||||
<ChevronDoubleRightIcon className={'w-3 h-3'} />
|
||||
</Button.Text>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -38,17 +38,9 @@ const arrowSides: Record<Side, string> = {
|
|||
left: 'top-0 right-[-6px]',
|
||||
};
|
||||
|
||||
export default ({
|
||||
content,
|
||||
children,
|
||||
disabled = false,
|
||||
alwaysOpen = false,
|
||||
delay = 0,
|
||||
rest = 30,
|
||||
...props
|
||||
}: Props) => {
|
||||
export default ({ content, children, disabled = false, alwaysOpen = false, delay = 0, rest = 30, ...props }: Props) => {
|
||||
const arrowEl = useRef<HTMLDivElement>(null);
|
||||
const [ open, setOpen ] = useState(alwaysOpen || false);
|
||||
const [open, setOpen] = useState(alwaysOpen || false);
|
||||
|
||||
const { x, y, reference, floating, middlewareData, strategy, context } = useFloating({
|
||||
open,
|
||||
|
@ -82,7 +74,7 @@ export default ({
|
|||
<>
|
||||
{cloneElement(children, getReferenceProps({ ref: reference, ...children.props }))}
|
||||
<AnimatePresence>
|
||||
{open &&
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.85 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
|
@ -90,7 +82,8 @@ export default ({
|
|||
transition={{ type: 'spring', damping: 20, stiffness: 300, duration: 0.075 }}
|
||||
{...getFloatingProps({
|
||||
ref: floating,
|
||||
className: 'absolute top-0 left-0 bg-gray-900 text-sm text-gray-200 px-3 py-2 rounded pointer-events-none max-w-[20rem] z-[9999]',
|
||||
className:
|
||||
'absolute top-0 left-0 bg-gray-900 text-sm text-gray-200 px-3 py-2 rounded pointer-events-none max-w-[20rem] z-[9999]',
|
||||
style: {
|
||||
position: strategy,
|
||||
top: `${y || 0}px`,
|
||||
|
@ -99,17 +92,19 @@ export default ({
|
|||
})}
|
||||
>
|
||||
{content}
|
||||
{props.arrow &&
|
||||
{props.arrow && (
|
||||
<div
|
||||
ref={arrowEl}
|
||||
style={{
|
||||
transform: `translate(${Math.round(ax || 0)}px, ${Math.round(ay || 0)}px) rotate(45deg)`,
|
||||
transform: `translate(${Math.round(ax || 0)}px, ${Math.round(
|
||||
ay || 0
|
||||
)}px) rotate(45deg)`,
|
||||
}}
|
||||
className={classNames('absolute bg-gray-900 w-3 h-3', side)}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
</motion.div>
|
||||
}
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -5,15 +5,17 @@ type Duration = `duration-${number}`;
|
|||
|
||||
interface Props {
|
||||
as?: React.ElementType;
|
||||
duration?: Duration | [ Duration, Duration ];
|
||||
duration?: Duration | [Duration, Duration];
|
||||
show: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default ({ children, duration, ...props }: Props) => {
|
||||
const [ enterDuration, exitDuration ] = Array.isArray(duration)
|
||||
const [enterDuration, exitDuration] = Array.isArray(duration)
|
||||
? duration
|
||||
: (!duration ? [ 'duration-200', 'duration-100' ] : [ duration, duration ]);
|
||||
: !duration
|
||||
? ['duration-200', 'duration-100']
|
||||
: [duration, duration];
|
||||
|
||||
return (
|
||||
<Transition
|
||||
|
|
|
@ -6,28 +6,30 @@ import ServerErrorSvg from '@/assets/images/server_error.svg';
|
|||
import ServerRestoreSvg from '@/assets/images/server_restore.svg';
|
||||
|
||||
export default () => {
|
||||
const status = ServerContext.useStoreState(state => state.server.data?.status || null);
|
||||
const isTransferring = ServerContext.useStoreState(state => state.server.data?.isTransferring || false);
|
||||
const status = ServerContext.useStoreState((state) => state.server.data?.status || null);
|
||||
const isTransferring = ServerContext.useStoreState((state) => state.server.data?.isTransferring || false);
|
||||
|
||||
return (
|
||||
status === 'installing' || status === 'install_failed' ?
|
||||
<ScreenBlock
|
||||
title={'Running Installer'}
|
||||
image={ServerInstallSvg}
|
||||
message={'Your server should be ready soon, please try again in a few minutes.'}
|
||||
/>
|
||||
:
|
||||
status === 'suspended' ?
|
||||
<ScreenBlock
|
||||
title={'Server Suspended'}
|
||||
image={ServerErrorSvg}
|
||||
message={'This server is suspended and cannot be accessed.'}
|
||||
/>
|
||||
:
|
||||
<ScreenBlock
|
||||
title={isTransferring ? 'Transferring' : 'Restoring from Backup'}
|
||||
image={ServerRestoreSvg}
|
||||
message={isTransferring ? 'Your server is being transfered to a new node, please check back later.' : 'Your server is currently being restored from a backup, please check back in a few minutes.'}
|
||||
/>
|
||||
return status === 'installing' || status === 'install_failed' ? (
|
||||
<ScreenBlock
|
||||
title={'Running Installer'}
|
||||
image={ServerInstallSvg}
|
||||
message={'Your server should be ready soon, please try again in a few minutes.'}
|
||||
/>
|
||||
) : status === 'suspended' ? (
|
||||
<ScreenBlock
|
||||
title={'Server Suspended'}
|
||||
image={ServerErrorSvg}
|
||||
message={'This server is suspended and cannot be accessed.'}
|
||||
/>
|
||||
) : (
|
||||
<ScreenBlock
|
||||
title={isTransferring ? 'Transferring' : 'Restoring from Backup'}
|
||||
image={ServerRestoreSvg}
|
||||
message={
|
||||
isTransferring
|
||||
? 'Your server is being transfered to a new node, please check back later.'
|
||||
: 'Your server is currently being restored from a backup, please check back in a few minutes.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,26 +5,26 @@ import { mutate } from 'swr';
|
|||
import { getDirectorySwrKey } from '@/plugins/useFileManagerSwr';
|
||||
|
||||
const InstallListener = () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
|
||||
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const getServer = ServerContext.useStoreActions((actions) => actions.server.getServer);
|
||||
const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState);
|
||||
|
||||
useWebsocketEvent(SocketEvent.BACKUP_RESTORE_COMPLETED, () => {
|
||||
mutate(getDirectorySwrKey(uuid, '/'), undefined);
|
||||
setServerFromState(s => ({ ...s, status: null }));
|
||||
setServerFromState((s) => ({ ...s, status: null }));
|
||||
});
|
||||
|
||||
// Listen for the installation completion event and then fire off a request to fetch the updated
|
||||
// server information. This allows the server to automatically become available to the user if they
|
||||
// just sit on the page.
|
||||
useWebsocketEvent(SocketEvent.INSTALL_COMPLETED, () => {
|
||||
getServer(uuid).catch(error => console.error(error));
|
||||
getServer(uuid).catch((error) => console.error(error));
|
||||
});
|
||||
|
||||
// When we see the install started event immediately update the state to indicate such so that the
|
||||
// screens automatically update.
|
||||
useWebsocketEvent(SocketEvent.INSTALL_STARTED, () => {
|
||||
setServerFromState(s => ({ ...s, status: 'installing' }));
|
||||
setServerFromState((s) => ({ ...s, status: 'installing' }));
|
||||
});
|
||||
|
||||
return null;
|
||||
|
|
|
@ -16,7 +16,7 @@ import useLocationHash from '@/plugins/useLocationHash';
|
|||
export default () => {
|
||||
const { hash } = useLocationHash();
|
||||
const { clearAndAddHttpError } = useFlashKey('server:activity');
|
||||
const [ filters, setFilters ] = useState<ActivityLogFilters>({ page: 1, sorts: { timestamp: -1 } });
|
||||
const [filters, setFilters] = useState<ActivityLogFilters>({ page: 1, sorts: { timestamp: -1 } });
|
||||
|
||||
const { data, isValidating, error } = useActivityLogs(filters, {
|
||||
revalidateOnMount: true,
|
||||
|
@ -24,45 +24,46 @@ export default () => {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(value => ({ ...value, filters: { ip: hash.ip, event: hash.event } }));
|
||||
}, [ hash ]);
|
||||
setFilters((value) => ({ ...value, filters: { ip: hash.ip, event: hash.event } }));
|
||||
}, [hash]);
|
||||
|
||||
useEffect(() => {
|
||||
clearAndAddHttpError(error);
|
||||
}, [ error ]);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<ServerContentBlock title={'Activity Log'}>
|
||||
<FlashMessageRender byKey={'server:activity'}/>
|
||||
{(filters.filters?.event || filters.filters?.ip) &&
|
||||
<FlashMessageRender byKey={'server:activity'} />
|
||||
{(filters.filters?.event || filters.filters?.ip) && (
|
||||
<div className={'flex justify-end mb-2'}>
|
||||
<Link
|
||||
to={'#'}
|
||||
className={classNames(btnStyles.button, btnStyles.text, 'w-full sm:w-auto')}
|
||||
onClick={() => setFilters(value => ({ ...value, filters: {} }))}
|
||||
onClick={() => setFilters((value) => ({ ...value, filters: {} }))}
|
||||
>
|
||||
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'}/>
|
||||
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'} />
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
{!data && isValidating ?
|
||||
<Spinner centered/>
|
||||
:
|
||||
!data?.items.length ?
|
||||
<p className={'text-sm text-center text-gray-400'}>No activity logs available for this server.</p>
|
||||
:
|
||||
<div className={'bg-gray-700'}>
|
||||
{data?.items.map((activity) => (
|
||||
<ActivityLogEntry key={activity.timestamp.toString() + activity.event} activity={activity}>
|
||||
<span/>
|
||||
</ActivityLogEntry>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
{data && <PaginationFooter
|
||||
pagination={data.pagination}
|
||||
onPageSelect={page => setFilters(value => ({ ...value, page }))}
|
||||
/>}
|
||||
)}
|
||||
{!data && isValidating ? (
|
||||
<Spinner centered />
|
||||
) : !data?.items.length ? (
|
||||
<p className={'text-sm text-center text-gray-400'}>No activity logs available for this server.</p>
|
||||
) : (
|
||||
<div className={'bg-gray-700'}>
|
||||
{data?.items.map((activity) => (
|
||||
<ActivityLogEntry key={activity.timestamp.toString() + activity.event} activity={activity}>
|
||||
<span />
|
||||
</ActivityLogEntry>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<PaginationFooter
|
||||
pagination={data.pagination}
|
||||
onPageSelect={(page) => setFilters((value) => ({ ...value, page }))}
|
||||
/>
|
||||
)}
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,19 +3,19 @@ import { ServerContext } from '@/state/server';
|
|||
import { SocketEvent } from '@/components/server/events';
|
||||
|
||||
const TransferListener = () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
|
||||
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const getServer = ServerContext.useStoreActions((actions) => actions.server.getServer);
|
||||
const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState);
|
||||
|
||||
// Listen for the transfer status event so we can update the state of the server.
|
||||
useWebsocketEvent(SocketEvent.TRANSFER_STATUS, (status: string) => {
|
||||
if (status === 'starting') {
|
||||
setServerFromState(s => ({ ...s, isTransferring: true }));
|
||||
setServerFromState((s) => ({ ...s, isTransferring: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'failure') {
|
||||
setServerFromState(s => ({ ...s, isTransferring: false }));
|
||||
setServerFromState((s) => ({ ...s, isTransferring: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -24,7 +24,7 @@ const TransferListener = () => {
|
|||
}
|
||||
|
||||
// Refresh the server's information as it's node and allocations were just updated.
|
||||
getServer(uuid).catch(error => console.error(error));
|
||||
getServer(uuid).catch((error) => console.error(error));
|
||||
});
|
||||
|
||||
return null;
|
||||
|
|
|
@ -2,14 +2,22 @@ import React from 'react';
|
|||
|
||||
export default ({ uptime }: { uptime: number }) => {
|
||||
const days = Math.floor(uptime / (24 * 60 * 60));
|
||||
const hours = Math.floor(Math.floor(uptime) / 60 / 60 % 24);
|
||||
const remainder = Math.floor(uptime - (hours * 60 * 60));
|
||||
const minutes = Math.floor(remainder / 60 % 60);
|
||||
const hours = Math.floor((Math.floor(uptime) / 60 / 60) % 24);
|
||||
const remainder = Math.floor(uptime - hours * 60 * 60);
|
||||
const minutes = Math.floor((remainder / 60) % 60);
|
||||
const seconds = remainder % 60;
|
||||
|
||||
if (days > 0) {
|
||||
return <>{days}d {hours}h {minutes}m</>;
|
||||
return (
|
||||
<>
|
||||
{days}d {hours}h {minutes}m
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{hours}h {minutes}m {seconds}s</>;
|
||||
return (
|
||||
<>
|
||||
{hours}h {minutes}m {seconds}s
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,26 +7,23 @@ import { CSSTransition } from 'react-transition-group';
|
|||
import Spinner from '@/components/elements/Spinner';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
const reconnectErrors = [
|
||||
'jwt: exp claim is invalid',
|
||||
'jwt: created too far in past (denylist)',
|
||||
];
|
||||
const reconnectErrors = ['jwt: exp claim is invalid', 'jwt: created too far in past (denylist)'];
|
||||
|
||||
export default () => {
|
||||
let updatingToken = false;
|
||||
const [ error, setError ] = useState<'connecting' | string>('');
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
|
||||
const setServerStatus = ServerContext.useStoreActions(actions => actions.status.setServerStatus);
|
||||
const { setInstance, setConnectionState } = ServerContext.useStoreActions(actions => actions.socket);
|
||||
const [error, setError] = useState<'connecting' | string>('');
|
||||
const { connected, instance } = ServerContext.useStoreState((state) => state.socket);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
|
||||
const setServerStatus = ServerContext.useStoreActions((actions) => actions.status.setServerStatus);
|
||||
const { setInstance, setConnectionState } = ServerContext.useStoreActions((actions) => actions.socket);
|
||||
|
||||
const updateToken = (uuid: string, socket: Websocket) => {
|
||||
if (updatingToken) return;
|
||||
|
||||
updatingToken = true;
|
||||
getWebsocketToken(uuid)
|
||||
.then(data => socket.setToken(data.token, true))
|
||||
.catch(error => console.error(error))
|
||||
.then((data) => socket.setToken(data.token, true))
|
||||
.catch((error) => console.error(error))
|
||||
.then(() => {
|
||||
updatingToken = false;
|
||||
});
|
||||
|
@ -43,7 +40,7 @@ export default () => {
|
|||
});
|
||||
socket.on('status', (status) => setServerStatus(status));
|
||||
|
||||
socket.on('daemon error', message => {
|
||||
socket.on('daemon error', (message) => {
|
||||
console.warn('Got error message from daemon socket:', message);
|
||||
});
|
||||
|
||||
|
@ -53,10 +50,12 @@ export default () => {
|
|||
setConnectionState(false);
|
||||
console.warn('JWT validation error from wings:', error);
|
||||
|
||||
if (reconnectErrors.find(v => error.toLowerCase().indexOf(v) >= 0)) {
|
||||
if (reconnectErrors.find((v) => error.toLowerCase().indexOf(v) >= 0)) {
|
||||
updateToken(uuid, socket);
|
||||
} else {
|
||||
setError('There was an error validating the credentials provided for the websocket. Please refresh the page.');
|
||||
setError(
|
||||
'There was an error validating the credentials provided for the websocket. Please refresh the page.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -75,25 +74,25 @@ export default () => {
|
|||
});
|
||||
|
||||
getWebsocketToken(uuid)
|
||||
.then(data => {
|
||||
.then((data) => {
|
||||
// Connect and then set the authentication token.
|
||||
socket.setToken(data.token).connect(data.socket);
|
||||
|
||||
// Once that is done, set the instance.
|
||||
setInstance(socket);
|
||||
})
|
||||
.catch(error => console.error(error));
|
||||
.catch((error) => console.error(error));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
connected && setError('');
|
||||
}, [ connected ]);
|
||||
}, [connected]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
instance && instance.close();
|
||||
};
|
||||
}, [ instance ]);
|
||||
}, [instance]);
|
||||
|
||||
useEffect(() => {
|
||||
// If there is already an instance or there is no server, just exit out of this process
|
||||
|
@ -103,29 +102,24 @@ export default () => {
|
|||
}
|
||||
|
||||
connect(uuid);
|
||||
}, [ uuid ]);
|
||||
}, [uuid]);
|
||||
|
||||
return (
|
||||
error ?
|
||||
<CSSTransition timeout={150} in appear classNames={'fade'}>
|
||||
<div css={tw`bg-red-500 py-2`}>
|
||||
<ContentContainer css={tw`flex items-center justify-center`}>
|
||||
{error === 'connecting' ?
|
||||
<>
|
||||
<Spinner size={'small'}/>
|
||||
<p css={tw`ml-2 text-sm text-red-100`}>
|
||||
We're having some trouble connecting to your server, please wait...
|
||||
</p>
|
||||
</>
|
||||
:
|
||||
<p css={tw`ml-2 text-sm text-white`}>
|
||||
{error}
|
||||
return error ? (
|
||||
<CSSTransition timeout={150} in appear classNames={'fade'}>
|
||||
<div css={tw`bg-red-500 py-2`}>
|
||||
<ContentContainer css={tw`flex items-center justify-center`}>
|
||||
{error === 'connecting' ? (
|
||||
<>
|
||||
<Spinner size={'small'} />
|
||||
<p css={tw`ml-2 text-sm text-red-100`}>
|
||||
We're having some trouble connecting to your server, please wait...
|
||||
</p>
|
||||
}
|
||||
</ContentContainer>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
:
|
||||
null
|
||||
);
|
||||
</>
|
||||
) : (
|
||||
<p css={tw`ml-2 text-sm text-white`}>{error}</p>
|
||||
)}
|
||||
</ContentContainer>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
) : null;
|
||||
};
|
||||
|
|
|
@ -16,7 +16,7 @@ const BackupContainer = () => {
|
|||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { data: backups, error, isValidating } = getServerBackups();
|
||||
|
||||
const backupLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.backups);
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
|
@ -26,53 +26,49 @@ const BackupContainer = () => {
|
|||
}
|
||||
|
||||
clearAndAddHttpError({ error, key: 'backups' });
|
||||
}, [ error ]);
|
||||
}, [error]);
|
||||
|
||||
if (!backups || (error && isValidating)) {
|
||||
return <Spinner size={'large'} centered/>;
|
||||
return <Spinner size={'large'} centered />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ServerContentBlock title={'Backups'}>
|
||||
<FlashMessageRender byKey={'backups'} css={tw`mb-4`}/>
|
||||
<FlashMessageRender byKey={'backups'} css={tw`mb-4`} />
|
||||
<Pagination data={backups} onPageSelect={setPage}>
|
||||
{({ items }) => (
|
||||
!items.length ?
|
||||
{({ items }) =>
|
||||
!items.length ? (
|
||||
// Don't show any error messages if the server has no backups and the user cannot
|
||||
// create additional ones for the server.
|
||||
!backupLimit ?
|
||||
null
|
||||
:
|
||||
!backupLimit ? null : (
|
||||
<p css={tw`text-center text-sm text-neutral-300`}>
|
||||
{page > 1 ?
|
||||
'Looks like we\'ve run out of backups to show you, try going back a page.'
|
||||
:
|
||||
'It looks like there are no backups currently stored for this server.'
|
||||
}
|
||||
{page > 1
|
||||
? "Looks like we've run out of backups to show you, try going back a page."
|
||||
: 'It looks like there are no backups currently stored for this server.'}
|
||||
</p>
|
||||
:
|
||||
items.map((backup, index) => <BackupRow
|
||||
key={backup.uuid}
|
||||
backup={backup}
|
||||
css={index > 0 ? tw`mt-2` : undefined}
|
||||
/>)
|
||||
)}
|
||||
)
|
||||
) : (
|
||||
items.map((backup, index) => (
|
||||
<BackupRow key={backup.uuid} backup={backup} css={index > 0 ? tw`mt-2` : undefined} />
|
||||
))
|
||||
)
|
||||
}
|
||||
</Pagination>
|
||||
{backupLimit === 0 &&
|
||||
<p css={tw`text-center text-sm text-neutral-300`}>
|
||||
Backups cannot be created for this server because the backup limit is set to 0.
|
||||
</p>
|
||||
}
|
||||
{backupLimit === 0 && (
|
||||
<p css={tw`text-center text-sm text-neutral-300`}>
|
||||
Backups cannot be created for this server because the backup limit is set to 0.
|
||||
</p>
|
||||
)}
|
||||
<Can action={'backup.create'}>
|
||||
<div css={tw`mt-6 sm:flex items-center justify-end`}>
|
||||
{(backupLimit > 0 && backups.backupCount > 0) &&
|
||||
<p css={tw`text-sm text-neutral-300 mb-4 sm:mr-6 sm:mb-0`}>
|
||||
{backups.backupCount} of {backupLimit} backups have been created for this server.
|
||||
</p>
|
||||
}
|
||||
{backupLimit > 0 && backupLimit > backups.backupCount &&
|
||||
<CreateBackupButton css={tw`w-full sm:w-auto`}/>
|
||||
}
|
||||
{backupLimit > 0 && backups.backupCount > 0 && (
|
||||
<p css={tw`text-sm text-neutral-300 mb-4 sm:mr-6 sm:mb-0`}>
|
||||
{backups.backupCount} of {backupLimit} backups have been created for this server.
|
||||
</p>
|
||||
)}
|
||||
{backupLimit > 0 && backupLimit > backups.backupCount && (
|
||||
<CreateBackupButton css={tw`w-full sm:w-auto`} />
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
</ServerContentBlock>
|
||||
|
@ -80,10 +76,10 @@ const BackupContainer = () => {
|
|||
};
|
||||
|
||||
export default () => {
|
||||
const [ page, setPage ] = useState<number>(1);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
return (
|
||||
<ServerBackupContext.Provider value={{ page, setPage }}>
|
||||
<BackupContainer/>
|
||||
<BackupContainer />
|
||||
</ServerBackupContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -28,11 +28,11 @@ interface Props {
|
|||
}
|
||||
|
||||
export default ({ backup }: Props) => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
|
||||
const [ modal, setModal ] = useState('');
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [ truncate, setTruncate ] = useState(false);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState);
|
||||
const [modal, setModal] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [truncate, setTruncate] = useState(false);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { mutate } = getServerBackups();
|
||||
|
||||
|
@ -40,11 +40,11 @@ export default ({ backup }: Props) => {
|
|||
setLoading(true);
|
||||
clearFlashes('backups');
|
||||
getBackupDownloadUrl(uuid, backup.uuid)
|
||||
.then(url => {
|
||||
.then((url) => {
|
||||
// @ts-ignore
|
||||
window.location = url;
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'backups', error });
|
||||
})
|
||||
|
@ -55,12 +55,17 @@ export default ({ backup }: Props) => {
|
|||
setLoading(true);
|
||||
clearFlashes('backups');
|
||||
deleteBackup(uuid, backup.uuid)
|
||||
.then(() => mutate(data => ({
|
||||
...data,
|
||||
items: data.items.filter(b => b.uuid !== backup.uuid),
|
||||
backupCount: data.backupCount - 1,
|
||||
}), false))
|
||||
.catch(error => {
|
||||
.then(() =>
|
||||
mutate(
|
||||
(data) => ({
|
||||
...data,
|
||||
items: data.items.filter((b) => b.uuid !== backup.uuid),
|
||||
backupCount: data.backupCount - 1,
|
||||
}),
|
||||
false
|
||||
)
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'backups', error });
|
||||
setLoading(false);
|
||||
|
@ -72,11 +77,13 @@ export default ({ backup }: Props) => {
|
|||
setLoading(true);
|
||||
clearFlashes('backups');
|
||||
restoreServerBackup(uuid, backup.uuid, truncate)
|
||||
.then(() => setServerFromState(s => ({
|
||||
...s,
|
||||
status: 'restoring_backup',
|
||||
})))
|
||||
.catch(error => {
|
||||
.then(() =>
|
||||
setServerFromState((s) => ({
|
||||
...s,
|
||||
status: 'restoring_backup',
|
||||
}))
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'backups', error });
|
||||
})
|
||||
|
@ -90,14 +97,23 @@ export default ({ backup }: Props) => {
|
|||
}
|
||||
|
||||
http.post(`/api/client/servers/${uuid}/backups/${backup.uuid}/lock`)
|
||||
.then(() => mutate(data => ({
|
||||
...data,
|
||||
items: data.items.map(b => b.uuid !== backup.uuid ? b : {
|
||||
...b,
|
||||
isLocked: !b.isLocked,
|
||||
}),
|
||||
}), false))
|
||||
.catch(error => alert(httpErrorToHuman(error)))
|
||||
.then(() =>
|
||||
mutate(
|
||||
(data) => ({
|
||||
...data,
|
||||
items: data.items.map((b) =>
|
||||
b.uuid !== backup.uuid
|
||||
? b
|
||||
: {
|
||||
...b,
|
||||
isLocked: !b.isLocked,
|
||||
}
|
||||
),
|
||||
}),
|
||||
false
|
||||
)
|
||||
)
|
||||
.catch((error) => alert(httpErrorToHuman(error)))
|
||||
.then(() => setModal(''));
|
||||
};
|
||||
|
||||
|
@ -123,17 +139,14 @@ export default ({ backup }: Props) => {
|
|||
manager, or create additional backups until completed.
|
||||
</p>
|
||||
<p css={tw`mt-4 -mb-2 bg-gray-700 p-3 rounded`}>
|
||||
<label
|
||||
htmlFor={'restore_truncate'}
|
||||
css={tw`text-base flex items-center cursor-pointer`}
|
||||
>
|
||||
<label htmlFor={'restore_truncate'} css={tw`text-base flex items-center cursor-pointer`}>
|
||||
<Input
|
||||
type={'checkbox'}
|
||||
css={tw`text-red-500! w-5! h-5! mr-2`}
|
||||
id={'restore_truncate'}
|
||||
value={'true'}
|
||||
checked={truncate}
|
||||
onChange={() => setTruncate(s => !s)}
|
||||
onChange={() => setTruncate((s) => !s)}
|
||||
/>
|
||||
Delete all files before restoring backup.
|
||||
</label>
|
||||
|
@ -148,28 +161,28 @@ export default ({ backup }: Props) => {
|
|||
>
|
||||
This is a permanent operation. The backup cannot be recovered once deleted.
|
||||
</Dialog.Confirm>
|
||||
<SpinnerOverlay visible={loading} fixed/>
|
||||
{backup.isSuccessful ?
|
||||
<SpinnerOverlay visible={loading} fixed />
|
||||
{backup.isSuccessful ? (
|
||||
<DropdownMenu
|
||||
renderToggle={onClick => (
|
||||
renderToggle={(onClick) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
css={tw`text-gray-200 transition-colors duration-150 hover:text-gray-100 p-2`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH}/>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div css={tw`text-sm`}>
|
||||
<Can action={'backup.download'}>
|
||||
<DropdownButtonRow onClick={doDownload}>
|
||||
<FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`}/>
|
||||
<FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`} />
|
||||
<span css={tw`ml-2`}>Download</span>
|
||||
</DropdownButtonRow>
|
||||
</Can>
|
||||
<Can action={'backup.restore'}>
|
||||
<DropdownButtonRow onClick={() => setModal('restore')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faBoxOpen} css={tw`text-xs`}/>
|
||||
<FontAwesomeIcon fixedWidth icon={faBoxOpen} css={tw`text-xs`} />
|
||||
<span css={tw`ml-2`}>Restore</span>
|
||||
</DropdownButtonRow>
|
||||
</Can>
|
||||
|
@ -183,24 +196,24 @@ export default ({ backup }: Props) => {
|
|||
/>
|
||||
{backup.isLocked ? 'Unlock' : 'Lock'}
|
||||
</DropdownButtonRow>
|
||||
{!backup.isLocked &&
|
||||
<DropdownButtonRow danger onClick={() => setModal('delete')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
|
||||
<span css={tw`ml-2`}>Delete</span>
|
||||
</DropdownButtonRow>
|
||||
}
|
||||
{!backup.isLocked && (
|
||||
<DropdownButtonRow danger onClick={() => setModal('delete')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`} />
|
||||
<span css={tw`ml-2`}>Delete</span>
|
||||
</DropdownButtonRow>
|
||||
)}
|
||||
</>
|
||||
</Can>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
:
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setModal('delete')}
|
||||
css={tw`text-gray-200 transition-colors duration-150 hover:text-gray-100 p-2`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt}/>
|
||||
<FontAwesomeIcon icon={faTrashAlt} />
|
||||
</button>
|
||||
}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -21,20 +21,27 @@ interface Props {
|
|||
export default ({ backup, className }: Props) => {
|
||||
const { mutate } = getServerBackups();
|
||||
|
||||
useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, data => {
|
||||
useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, (data) => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
mutate(data => ({
|
||||
...data,
|
||||
items: data.items.map(b => b.uuid !== backup.uuid ? b : ({
|
||||
...b,
|
||||
isSuccessful: parsed.is_successful || true,
|
||||
checksum: (parsed.checksum_type || '') + ':' + (parsed.checksum || ''),
|
||||
bytes: parsed.file_size || 0,
|
||||
completedAt: new Date(),
|
||||
})),
|
||||
}), false);
|
||||
mutate(
|
||||
(data) => ({
|
||||
...data,
|
||||
items: data.items.map((b) =>
|
||||
b.uuid !== backup.uuid
|
||||
? b
|
||||
: {
|
||||
...b,
|
||||
isSuccessful: parsed.is_successful || true,
|
||||
checksum: (parsed.checksum_type || '') + ':' + (parsed.checksum || ''),
|
||||
bytes: parsed.file_size || 0,
|
||||
completedAt: new Date(),
|
||||
}
|
||||
),
|
||||
}),
|
||||
false
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
@ -44,52 +51,50 @@ export default ({ backup, className }: Props) => {
|
|||
<GreyRowBox css={tw`flex-wrap md:flex-nowrap items-center`} className={className}>
|
||||
<div css={tw`flex items-center truncate w-full md:flex-1`}>
|
||||
<div css={tw`mr-4`}>
|
||||
{backup.completedAt !== null ?
|
||||
backup.isLocked ?
|
||||
<FontAwesomeIcon icon={faLock} css={tw`text-yellow-500`}/>
|
||||
:
|
||||
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
|
||||
:
|
||||
<Spinner size={'small'}/>
|
||||
}
|
||||
{backup.completedAt !== null ? (
|
||||
backup.isLocked ? (
|
||||
<FontAwesomeIcon icon={faLock} css={tw`text-yellow-500`} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`} />
|
||||
)
|
||||
) : (
|
||||
<Spinner size={'small'} />
|
||||
)}
|
||||
</div>
|
||||
<div css={tw`flex flex-col truncate`}>
|
||||
<div css={tw`flex items-center text-sm mb-1`}>
|
||||
{backup.completedAt !== null && !backup.isSuccessful &&
|
||||
<span css={tw`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}>
|
||||
Failed
|
||||
</span>
|
||||
}
|
||||
<p css={tw`break-words truncate`}>
|
||||
{backup.name}
|
||||
</p>
|
||||
{(backup.completedAt !== null && backup.isSuccessful) &&
|
||||
<span css={tw`ml-3 text-neutral-300 text-xs font-extralight hidden sm:inline`}>{bytesToString(backup.bytes)}</span>
|
||||
}
|
||||
{backup.completedAt !== null && !backup.isSuccessful && (
|
||||
<span
|
||||
css={tw`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}
|
||||
>
|
||||
Failed
|
||||
</span>
|
||||
)}
|
||||
<p css={tw`break-words truncate`}>{backup.name}</p>
|
||||
{backup.completedAt !== null && backup.isSuccessful && (
|
||||
<span css={tw`ml-3 text-neutral-300 text-xs font-extralight hidden sm:inline`}>
|
||||
{bytesToString(backup.bytes)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
|
||||
{backup.checksum}
|
||||
</p>
|
||||
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>{backup.checksum}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`flex-1 md:flex-none md:w-48 mt-4 md:mt-0 md:ml-8 md:text-center`}>
|
||||
<p
|
||||
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
|
||||
css={tw`text-sm`}
|
||||
>
|
||||
<p title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')} css={tw`text-sm`}>
|
||||
{formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
|
||||
</p>
|
||||
<p css={tw`text-2xs text-neutral-500 uppercase mt-1`}>Created</p>
|
||||
</div>
|
||||
<Can action={[ 'backup.download', 'backup.restore', 'backup.delete' ]} matchAny>
|
||||
<Can action={['backup.download', 'backup.restore', 'backup.delete']} matchAny>
|
||||
<div css={tw`mt-4 md:mt-0 ml-6`} style={{ marginRight: '-0.5rem' }}>
|
||||
{!backup.completedAt ?
|
||||
{!backup.completedAt ? (
|
||||
<div css={tw`p-2 invisible`}>
|
||||
<FontAwesomeIcon icon={faEllipsisH}/>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</div>
|
||||
:
|
||||
<BackupContextMenu backup={backup}/>
|
||||
}
|
||||
) : (
|
||||
<BackupContextMenu backup={backup} />
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
</GreyRowBox>
|
||||
|
|
|
@ -27,7 +27,7 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
|||
return (
|
||||
<Modal {...props} showSpinnerOverlay={isSubmitting}>
|
||||
<Form>
|
||||
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`}/>
|
||||
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`} />
|
||||
<h2 css={tw`text-2xl mb-6`}>Create server backup</h2>
|
||||
<Field
|
||||
name={'name'}
|
||||
|
@ -45,7 +45,7 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
|||
prefixing the path with an exclamation point.
|
||||
`}
|
||||
>
|
||||
<FormikField as={Textarea} name={'ignored'} rows={6}/>
|
||||
<FormikField as={Textarea} name={'ignored'} rows={6} />
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
<Can action={'backup.delete'}>
|
||||
|
@ -68,23 +68,26 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
|||
};
|
||||
|
||||
export default () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { mutate } = getServerBackups();
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('backups:create');
|
||||
}, [ visible ]);
|
||||
}, [visible]);
|
||||
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('backups:create');
|
||||
createServerBackup(uuid, values)
|
||||
.then(backup => {
|
||||
mutate(data => ({ ...data, items: data.items.concat(backup), backupCount: data.backupCount + 1 }), false);
|
||||
.then((backup) => {
|
||||
mutate(
|
||||
(data) => ({ ...data, items: data.items.concat(backup), backupCount: data.backupCount + 1 }),
|
||||
false
|
||||
);
|
||||
setVisible(false);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
clearAndAddHttpError({ key: 'backups:create', error });
|
||||
setSubmitting(false);
|
||||
});
|
||||
|
@ -92,19 +95,19 @@ export default () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{visible &&
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ name: '', ignored: '', isLocked: false }}
|
||||
validationSchema={object().shape({
|
||||
name: string().max(191),
|
||||
ignored: string(),
|
||||
isLocked: boolean(),
|
||||
})}
|
||||
>
|
||||
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>
|
||||
</Formik>
|
||||
}
|
||||
{visible && (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ name: '', ignored: '', isLocked: false }}
|
||||
validationSchema={object().shape({
|
||||
name: string().max(191),
|
||||
ignored: string(),
|
||||
isLocked: boolean(),
|
||||
})}
|
||||
>
|
||||
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)} />
|
||||
</Formik>
|
||||
)}
|
||||
<Button css={tw`w-full sm:w-auto`} onClick={() => setVisible(true)}>
|
||||
Create backup
|
||||
</Button>
|
||||
|
|
|
@ -11,17 +11,9 @@ interface ChartBlockProps {
|
|||
export default ({ title, legend, children }: ChartBlockProps) => (
|
||||
<div className={classNames(styles.chart_container, 'group')}>
|
||||
<div className={'flex items-center justify-between px-4 py-2'}>
|
||||
<h3 className={'font-header transition-colors duration-100 group-hover:text-gray-50'}>
|
||||
{title}
|
||||
</h3>
|
||||
{legend &&
|
||||
<p className={'text-sm flex items-center'}>
|
||||
{legend}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div className={'z-10 ml-2'}>
|
||||
{children}
|
||||
<h3 className={'font-header transition-colors duration-100 group-hover:text-gray-50'}>{title}</h3>
|
||||
{legend && <p className={'text-sm flex items-center'}>{legend}</p>}
|
||||
</div>
|
||||
<div className={'z-10 ml-2'}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -59,16 +59,15 @@ export default () => {
|
|||
const searchBar = new SearchBarAddon({ searchAddon });
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
const scrollDownHelperAddon = new ScrollDownHelperAddon();
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const [ canSendCommands ] = usePermissions([ 'control.console' ]);
|
||||
const serverId = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
|
||||
const [ history, setHistory ] = usePersistedState<string[]>(`${serverId}:command_history`, []);
|
||||
const [ historyIndex, setHistoryIndex ] = useState(-1);
|
||||
const { connected, instance } = ServerContext.useStoreState((state) => state.socket);
|
||||
const [canSendCommands] = usePermissions(['control.console']);
|
||||
const serverId = ServerContext.useStoreState((state) => state.server.data!.id);
|
||||
const isTransferring = ServerContext.useStoreState((state) => state.server.data!.isTransferring);
|
||||
const [history, setHistory] = usePersistedState<string[]>(`${serverId}:command_history`, []);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
const handleConsoleOutput = (line: string, prelude = false) => terminal.writeln(
|
||||
(prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
|
||||
);
|
||||
const handleConsoleOutput = (line: string, prelude = false) =>
|
||||
terminal.writeln((prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m');
|
||||
|
||||
const handleTransferStatus = (status: string) => {
|
||||
switch (status) {
|
||||
|
@ -79,17 +78,20 @@ export default () => {
|
|||
|
||||
// Sent by the source node whenever the server was archived successfully.
|
||||
case 'archive':
|
||||
terminal.writeln(TERMINAL_PRELUDE + 'Server has been archived successfully, attempting connection to target node..\u001b[0m');
|
||||
terminal.writeln(
|
||||
TERMINAL_PRELUDE +
|
||||
'Server has been archived successfully, attempting connection to target node..\u001b[0m'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDaemonErrorOutput = (line: string) => terminal.writeln(
|
||||
TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
|
||||
);
|
||||
const handleDaemonErrorOutput = (line: string) =>
|
||||
terminal.writeln(
|
||||
TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m'
|
||||
);
|
||||
|
||||
const handlePowerChangeEvent = (state: string) => terminal.writeln(
|
||||
TERMINAL_PRELUDE + 'Server marked as ' + state + '...\u001b[0m',
|
||||
);
|
||||
const handlePowerChangeEvent = (state: string) =>
|
||||
terminal.writeln(TERMINAL_PRELUDE + 'Server marked as ' + state + '...\u001b[0m');
|
||||
|
||||
const handleCommandKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
|
@ -112,7 +114,7 @@ export default () => {
|
|||
|
||||
const command = e.currentTarget.value;
|
||||
if (e.key === 'Enter' && command.length > 0) {
|
||||
setHistory(prevHistory => [ command, ...prevHistory! ].slice(0, 32));
|
||||
setHistory((prevHistory) => [command, ...prevHistory!].slice(0, 32));
|
||||
setHistoryIndex(-1);
|
||||
|
||||
instance && instance.send('send command', command);
|
||||
|
@ -146,13 +148,16 @@ export default () => {
|
|||
return true;
|
||||
});
|
||||
}
|
||||
}, [ terminal, connected ]);
|
||||
}, [terminal, connected]);
|
||||
|
||||
useEventListener('resize', debounce(() => {
|
||||
if (terminal.element) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
}, 100));
|
||||
useEventListener(
|
||||
'resize',
|
||||
debounce(() => {
|
||||
if (terminal.element) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
}, 100)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const listeners: Record<string, (s: string) => void> = {
|
||||
|
@ -161,7 +166,7 @@ export default () => {
|
|||
[SocketEvent.INSTALL_OUTPUT]: handleConsoleOutput,
|
||||
[SocketEvent.TRANSFER_LOGS]: handleConsoleOutput,
|
||||
[SocketEvent.TRANSFER_STATUS]: handleTransferStatus,
|
||||
[SocketEvent.DAEMON_MESSAGE]: line => handleConsoleOutput(line, true),
|
||||
[SocketEvent.DAEMON_MESSAGE]: (line) => handleConsoleOutput(line, true),
|
||||
[SocketEvent.DAEMON_ERROR]: handleDaemonErrorOutput,
|
||||
};
|
||||
|
||||
|
@ -184,15 +189,17 @@ export default () => {
|
|||
});
|
||||
}
|
||||
};
|
||||
}, [ connected, instance ]);
|
||||
}, [connected, instance]);
|
||||
|
||||
return (
|
||||
<div className={styles.terminal}>
|
||||
<SpinnerOverlay visible={!connected} size={'large'}/>
|
||||
<div className={classNames(styles.container, styles.overflows_container, { 'rounded-b': !canSendCommands })}>
|
||||
<div id={styles.terminal} ref={ref}/>
|
||||
<SpinnerOverlay visible={!connected} size={'large'} />
|
||||
<div
|
||||
className={classNames(styles.container, styles.overflows_container, { 'rounded-b': !canSendCommands })}
|
||||
>
|
||||
<div id={styles.terminal} ref={ref} />
|
||||
</div>
|
||||
{canSendCommands &&
|
||||
{canSendCommands && (
|
||||
<div className={classNames('relative', styles.overflows_container)}>
|
||||
<input
|
||||
className={classNames('peer', styles.command_input)}
|
||||
|
@ -204,11 +211,16 @@ export default () => {
|
|||
autoCorrect={'off'}
|
||||
autoCapitalize={'none'}
|
||||
/>
|
||||
<div className={classNames('text-gray-100 peer-focus:text-gray-50 peer-focus:animate-pulse', styles.command_icon)}>
|
||||
<ChevronDoubleRightIcon className={'w-4 h-4'}/>
|
||||
<div
|
||||
className={classNames(
|
||||
'text-gray-100 peer-focus:text-gray-50 peer-focus:animate-pulse',
|
||||
styles.command_icon
|
||||
)}
|
||||
>
|
||||
<ChevronDoubleRightIcon className={'w-4 h-4'} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -10,12 +10,15 @@ interface PowerButtonProps {
|
|||
}
|
||||
|
||||
export default ({ className }: PowerButtonProps) => {
|
||||
const [ open, setOpen ] = useState(false);
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const instance = ServerContext.useStoreState(state => state.socket.instance);
|
||||
const [open, setOpen] = useState(false);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const instance = ServerContext.useStoreState((state) => state.socket.instance);
|
||||
|
||||
const killable = status === 'stopping';
|
||||
const onButtonClick = (action: PowerAction | 'kill-confirmed', e: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
|
||||
const onButtonClick = (
|
||||
action: PowerAction | 'kill-confirmed',
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
): void => {
|
||||
e.preventDefault();
|
||||
if (action === 'kill') {
|
||||
return setOpen(true);
|
||||
|
@ -31,7 +34,7 @@ export default ({ className }: PowerButtonProps) => {
|
|||
if (status === 'offline') {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [ status ]);
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
|
|
|
@ -15,11 +15,11 @@ import ServerDetailsBlock from '@/components/server/console/ServerDetailsBlock';
|
|||
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
||||
|
||||
const ServerConsoleContainer = () => {
|
||||
const name = ServerContext.useStoreState(state => state.server.data!.name);
|
||||
const description = ServerContext.useStoreState(state => state.server.data!.description);
|
||||
const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling);
|
||||
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
|
||||
const eggFeatures = ServerContext.useStoreState(state => state.server.data!.eggFeatures, isEqual);
|
||||
const name = ServerContext.useStoreState((state) => state.server.data!.name);
|
||||
const description = ServerContext.useStoreState((state) => state.server.data!.description);
|
||||
const isInstalling = ServerContext.useStoreState((state) => state.server.data!.isInstalling);
|
||||
const isTransferring = ServerContext.useStoreState((state) => state.server.data!.isTransferring);
|
||||
const eggFeatures = ServerContext.useStoreState((state) => state.server.data!.eggFeatures, isEqual);
|
||||
|
||||
return (
|
||||
<ServerContentBlock title={'Console'} className={'flex flex-col gap-2 sm:gap-4'}>
|
||||
|
@ -29,19 +29,19 @@ const ServerConsoleContainer = () => {
|
|||
<p className={'text-sm line-clamp-2'}>{description}</p>
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
|
||||
<PowerButtons className={'flex sm:justify-end space-x-2'}/>
|
||||
<Can action={['control.start', 'control.stop', 'control.restart']} matchAny>
|
||||
<PowerButtons className={'flex sm:justify-end space-x-2'} />
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'grid grid-cols-4 gap-2 sm:gap-4'}>
|
||||
<ServerDetailsBlock className={'col-span-4 lg:col-span-1 order-last lg:order-none'}/>
|
||||
<ServerDetailsBlock className={'col-span-4 lg:col-span-1 order-last lg:order-none'} />
|
||||
<div className={'col-span-4 lg:col-span-3'}>
|
||||
<Spinner.Suspense>
|
||||
<Console/>
|
||||
<Console />
|
||||
</Spinner.Suspense>
|
||||
</div>
|
||||
{isInstalling ?
|
||||
{isInstalling ? (
|
||||
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
|
||||
<ContentContainer>
|
||||
<p css={tw`text-sm text-yellow-900`}>
|
||||
|
@ -50,26 +50,23 @@ const ServerConsoleContainer = () => {
|
|||
</p>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
:
|
||||
isTransferring ?
|
||||
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
|
||||
<ContentContainer>
|
||||
<p css={tw`text-sm text-yellow-900`}>
|
||||
This server is currently being transferred to another node and all actions
|
||||
are unavailable.
|
||||
</p>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
) : isTransferring ? (
|
||||
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
|
||||
<ContentContainer>
|
||||
<p css={tw`text-sm text-yellow-900`}>
|
||||
This server is currently being transferred to another node and all actions are
|
||||
unavailable.
|
||||
</p>
|
||||
</ContentContainer>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={'grid grid-cols-1 md:grid-cols-3 gap-2 sm:gap-4'}>
|
||||
<Spinner.Suspense>
|
||||
<StatGraphs/>
|
||||
<StatGraphs />
|
||||
</Spinner.Suspense>
|
||||
</div>
|
||||
<Features enabled={eggFeatures}/>
|
||||
<Features enabled={eggFeatures} />
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -19,7 +19,7 @@ import classNames from 'classnames';
|
|||
type Stats = Record<'memory' | 'cpu' | 'disk' | 'uptime' | 'rx' | 'tx', number>;
|
||||
|
||||
const getBackgroundColor = (value: number, max: number | null): string | undefined => {
|
||||
const delta = !max ? 0 : (value / max);
|
||||
const delta = !max ? 0 : value / max;
|
||||
|
||||
if (delta > 0.8) {
|
||||
if (delta > 0.9) {
|
||||
|
@ -32,14 +32,14 @@ const getBackgroundColor = (value: number, max: number | null): string | undefin
|
|||
};
|
||||
|
||||
const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
||||
const [ stats, setStats ] = useState<Stats>({ memory: 0, cpu: 0, disk: 0, uptime: 0, tx: 0, rx: 0 });
|
||||
const [stats, setStats] = useState<Stats>({ memory: 0, cpu: 0, disk: 0, uptime: 0, tx: 0, rx: 0 });
|
||||
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const connected = ServerContext.useStoreState(state => state.socket.connected);
|
||||
const instance = ServerContext.useStoreState(state => state.socket.instance);
|
||||
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
|
||||
const allocation = ServerContext.useStoreState(state => {
|
||||
const match = state.server.data!.allocations.find(allocation => allocation.isDefault);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const connected = ServerContext.useStoreState((state) => state.socket.connected);
|
||||
const instance = ServerContext.useStoreState((state) => state.socket.instance);
|
||||
const limits = ServerContext.useStoreState((state) => state.server.data!.limits);
|
||||
const allocation = ServerContext.useStoreState((state) => {
|
||||
const match = state.server.data!.allocations.find((allocation) => allocation.isDefault);
|
||||
|
||||
return !match ? 'n/a' : `${match.alias || ip(match.ip)}:${match.port}`;
|
||||
});
|
||||
|
@ -50,7 +50,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
|||
}
|
||||
|
||||
instance.send(SocketRequest.SEND_STATS);
|
||||
}, [ instance, connected ]);
|
||||
}, [instance, connected]);
|
||||
|
||||
useWebsocketEvent(SocketEvent.STATS, (data) => {
|
||||
let stats: any = {};
|
||||
|
@ -78,51 +78,42 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
|||
<StatBlock
|
||||
icon={faClock}
|
||||
title={'Uptime'}
|
||||
color={getBackgroundColor(status === 'running' ? 0 : (status !== 'offline' ? 9 : 10), 10)}
|
||||
color={getBackgroundColor(status === 'running' ? 0 : status !== 'offline' ? 9 : 10, 10)}
|
||||
>
|
||||
{stats.uptime > 0 ?
|
||||
<UptimeDuration uptime={stats.uptime / 1000}/>
|
||||
:
|
||||
'Offline'
|
||||
}
|
||||
{stats.uptime > 0 ? <UptimeDuration uptime={stats.uptime / 1000} /> : 'Offline'}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faMicrochip}
|
||||
title={'CPU Load'}
|
||||
color={getBackgroundColor(stats.cpu, limits.cpu)}
|
||||
description={limits.cpu
|
||||
? `This server is allowed to use up to ${limits.cpu}% of the host's available CPU resources.`
|
||||
: 'No CPU limit has been configured for this server.'
|
||||
description={
|
||||
limits.cpu
|
||||
? `This server is allowed to use up to ${limits.cpu}% of the host's available CPU resources.`
|
||||
: 'No CPU limit has been configured for this server.'
|
||||
}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
`${stats.cpu.toFixed(2)}%`
|
||||
}
|
||||
{status === 'offline' ? <span className={'text-gray-400'}>Offline</span> : `${stats.cpu.toFixed(2)}%`}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faMemory}
|
||||
title={'Memory'}
|
||||
color={getBackgroundColor(stats.memory / 1024, limits.memory * 1024)}
|
||||
description={limits.memory
|
||||
? `This server is allowed to use up to ${bytesToString(mbToBytes(limits.memory))} of memory.`
|
||||
: 'No memory limit has been configured for this server.'
|
||||
description={
|
||||
limits.memory
|
||||
? `This server is allowed to use up to ${bytesToString(mbToBytes(limits.memory))} of memory.`
|
||||
: 'No memory limit has been configured for this server.'
|
||||
}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
bytesToString(stats.memory)
|
||||
}
|
||||
{status === 'offline' ? <span className={'text-gray-400'}>Offline</span> : bytesToString(stats.memory)}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faHdd}
|
||||
title={'Disk'}
|
||||
color={getBackgroundColor(stats.disk / 1024, limits.disk * 1024)}
|
||||
description={limits.disk
|
||||
? `This server is allowed to use up to ${bytesToString(mbToBytes(limits.disk))} of disk space.`
|
||||
: 'No disk space limit has been configured for this server.'
|
||||
description={
|
||||
limits.disk
|
||||
? `This server is allowed to use up to ${bytesToString(mbToBytes(limits.disk))} of disk space.`
|
||||
: 'No disk space limit has been configured for this server.'
|
||||
}
|
||||
>
|
||||
{bytesToString(stats.disk)}
|
||||
|
@ -132,22 +123,16 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
|||
title={'Network (Inbound)'}
|
||||
description={'The total amount of network traffic that your server has recieved since it was started.'}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
bytesToString(stats.tx)
|
||||
}
|
||||
{status === 'offline' ? <span className={'text-gray-400'}>Offline</span> : bytesToString(stats.tx)}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faCloudUploadAlt}
|
||||
title={'Network (Outbound)'}
|
||||
description={'The total amount of traffic your server has sent across the internet since it was started.'}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
bytesToString(stats.rx)
|
||||
description={
|
||||
'The total amount of traffic your server has sent across the internet since it was started.'
|
||||
}
|
||||
>
|
||||
{status === 'offline' ? <span className={'text-gray-400'}>Offline</span> : bytesToString(stats.rx)}
|
||||
</StatBlock>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -21,7 +21,7 @@ export default ({ title, icon, color, description, className, children }: StatBl
|
|||
return (
|
||||
<Tooltip arrow placement={'top'} disabled={!description} content={description || ''}>
|
||||
<div className={classNames(styles.stat_block, 'bg-gray-600', className)}>
|
||||
<div className={classNames(styles.status_bar, color || 'bg-gray-700')}/>
|
||||
<div className={classNames(styles.status_bar, color || 'bg-gray-700')} />
|
||||
<div className={classNames(styles.icon, color || 'bg-gray-700')}>
|
||||
<Icon
|
||||
icon={icon}
|
||||
|
|
|
@ -12,8 +12,8 @@ import ChartBlock from '@/components/server/console/ChartBlock';
|
|||
import Tooltip from '@/components/elements/tooltip/Tooltip';
|
||||
|
||||
export default () => {
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const limits = ServerContext.useStoreState((state) => state.server.data!.limits);
|
||||
const previous = useRef<Record<'tx' | 'rx', number>>({ tx: -1, rx: -1 });
|
||||
|
||||
const cpu = useChartTickLabel('CPU', limits.cpu, '%');
|
||||
|
@ -24,14 +24,14 @@ export default () => {
|
|||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
callback (value) {
|
||||
callback(value) {
|
||||
return bytesToString(typeof value === 'string' ? parseInt(value, 10) : value);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
callback (opts, index) {
|
||||
callback(opts, index) {
|
||||
return {
|
||||
...opts,
|
||||
label: !index ? 'Network In' : 'Network Out',
|
||||
|
@ -47,7 +47,7 @@ export default () => {
|
|||
memory.clear();
|
||||
network.clear();
|
||||
}
|
||||
}, [ status ]);
|
||||
}, [status]);
|
||||
|
||||
useWebsocketEvent(SocketEvent.STATS, (data: string) => {
|
||||
let values: any = {};
|
||||
|
@ -70,25 +70,25 @@ export default () => {
|
|||
return (
|
||||
<>
|
||||
<ChartBlock title={'CPU Load'}>
|
||||
<Line {...cpu.props}/>
|
||||
<Line {...cpu.props} />
|
||||
</ChartBlock>
|
||||
<ChartBlock title={'Memory'}>
|
||||
<Line {...memory.props}/>
|
||||
<Line {...memory.props} />
|
||||
</ChartBlock>
|
||||
<ChartBlock
|
||||
title={'Network'}
|
||||
legend={
|
||||
<>
|
||||
<Tooltip arrow content={'Inbound'}>
|
||||
<CloudDownloadIcon className={'mr-2 w-4 h-4 text-yellow-400'}/>
|
||||
<CloudDownloadIcon className={'mr-2 w-4 h-4 text-yellow-400'} />
|
||||
</Tooltip>
|
||||
<Tooltip arrow content={'Outbound'}>
|
||||
<CloudUploadIcon className={'w-4 h-4 text-cyan-400'}/>
|
||||
<CloudUploadIcon className={'w-4 h-4 text-cyan-400'} />
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Line {...network.props}/>
|
||||
<Line {...network.props} />
|
||||
</ChartBlock>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -70,24 +70,33 @@ const options: ChartOptions<'line'> = {
|
|||
},
|
||||
};
|
||||
|
||||
function getOptions (opts?: DeepPartial<ChartOptions<'line'>> | undefined): ChartOptions<'line'> {
|
||||
function getOptions(opts?: DeepPartial<ChartOptions<'line'>> | undefined): ChartOptions<'line'> {
|
||||
return deepmerge(options, opts || {});
|
||||
}
|
||||
|
||||
type ChartDatasetCallback = (value: ChartDataset<'line'>, index: number) => ChartDataset<'line'>;
|
||||
|
||||
function getEmptyData (label: string, sets = 1, callback?: ChartDatasetCallback | undefined): ChartData<'line'> {
|
||||
const next = callback || (value => value);
|
||||
function getEmptyData(label: string, sets = 1, callback?: ChartDatasetCallback | undefined): ChartData<'line'> {
|
||||
const next = callback || ((value) => value);
|
||||
|
||||
return {
|
||||
labels: Array(20).fill(0).map((_, index) => index),
|
||||
datasets: Array(sets).fill(0).map((_, index) => next({
|
||||
fill: true,
|
||||
label,
|
||||
data: Array(20).fill(0),
|
||||
borderColor: theme('colors.cyan.400'),
|
||||
backgroundColor: hexToRgba(theme('colors.cyan.700'), 0.5),
|
||||
}, index)),
|
||||
labels: Array(20)
|
||||
.fill(0)
|
||||
.map((_, index) => index),
|
||||
datasets: Array(sets)
|
||||
.fill(0)
|
||||
.map((_, index) =>
|
||||
next(
|
||||
{
|
||||
fill: true,
|
||||
label,
|
||||
data: Array(20).fill(0),
|
||||
borderColor: theme('colors.cyan.400'),
|
||||
backgroundColor: hexToRgba(theme('colors.cyan.700'), 0.5),
|
||||
},
|
||||
index
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -99,28 +108,36 @@ interface UseChartOptions {
|
|||
callback?: ChartDatasetCallback | undefined;
|
||||
}
|
||||
|
||||
function useChart (label: string, opts?: UseChartOptions) {
|
||||
const options = getOptions(typeof opts?.options === 'number' ? { scales: { y: { min: 0, suggestedMax: opts.options } } } : opts?.options);
|
||||
const [ data, setData ] = useState(getEmptyData(label, opts?.sets || 1, opts?.callback));
|
||||
function useChart(label: string, opts?: UseChartOptions) {
|
||||
const options = getOptions(
|
||||
typeof opts?.options === 'number' ? { scales: { y: { min: 0, suggestedMax: opts.options } } } : opts?.options
|
||||
);
|
||||
const [data, setData] = useState(getEmptyData(label, opts?.sets || 1, opts?.callback));
|
||||
|
||||
const push = (items: number | null | ((number | null)[])) => setData(state => merge(state, {
|
||||
datasets: (Array.isArray(items) ? items : [ items ]).map((item, index) => ({
|
||||
...state.datasets[index],
|
||||
data: state.datasets[index].data.slice(1).concat(item),
|
||||
})),
|
||||
}));
|
||||
const push = (items: number | null | (number | null)[]) =>
|
||||
setData((state) =>
|
||||
merge(state, {
|
||||
datasets: (Array.isArray(items) ? items : [items]).map((item, index) => ({
|
||||
...state.datasets[index],
|
||||
data: state.datasets[index].data.slice(1).concat(item),
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
const clear = () => setData(state => merge(state, {
|
||||
datasets: state.datasets.map(value => ({
|
||||
...value,
|
||||
data: Array(20).fill(0),
|
||||
})),
|
||||
}));
|
||||
const clear = () =>
|
||||
setData((state) =>
|
||||
merge(state, {
|
||||
datasets: state.datasets.map((value) => ({
|
||||
...value,
|
||||
data: Array(20).fill(0),
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
return { props: { data, options }, push, clear };
|
||||
}
|
||||
|
||||
function useChartTickLabel (label: string, max: number, tickLabel: string) {
|
||||
function useChartTickLabel(label: string, max: number, tickLabel: string) {
|
||||
return useChart(label, {
|
||||
sets: 1,
|
||||
options: {
|
||||
|
@ -128,7 +145,7 @@ function useChartTickLabel (label: string, max: number, tickLabel: string) {
|
|||
y: {
|
||||
suggestedMax: max,
|
||||
ticks: {
|
||||
callback (value) {
|
||||
callback(value) {
|
||||
return `${value}${tickLabel}`;
|
||||
},
|
||||
},
|
||||
|
|
|
@ -21,16 +21,19 @@ const schema = object().shape({
|
|||
.required('A database name must be provided.')
|
||||
.min(3, 'Database name must be at least 3 characters.')
|
||||
.max(48, 'Database name must not exceed 48 characters.')
|
||||
.matches(/^[\w\-.]{3,48}$/, 'Database name should only contain alphanumeric characters, underscores, dashes, and/or periods.'),
|
||||
.matches(
|
||||
/^[\w\-.]{3,48}$/,
|
||||
'Database name should only contain alphanumeric characters, underscores, dashes, and/or periods.'
|
||||
),
|
||||
connectionsFrom: string().matches(/^[\w\-/.%:]+$/, 'A valid host address must be provided.'),
|
||||
});
|
||||
|
||||
export default () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase);
|
||||
const appendDatabase = ServerContext.useStoreActions((actions) => actions.databases.appendDatabase);
|
||||
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('database:create');
|
||||
|
@ -38,11 +41,11 @@ export default () => {
|
|||
databaseName: values.databaseName,
|
||||
connectionsFrom: values.connectionsFrom || '%',
|
||||
})
|
||||
.then(database => {
|
||||
.then((database) => {
|
||||
appendDatabase(database);
|
||||
setVisible(false);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
addError({ key: 'database:create', message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
|
@ -55,57 +58,55 @@ export default () => {
|
|||
initialValues={{ databaseName: '', connectionsFrom: '' }}
|
||||
validationSchema={schema}
|
||||
>
|
||||
{
|
||||
({ isSubmitting, resetForm }) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => {
|
||||
resetForm();
|
||||
setVisible(false);
|
||||
}}
|
||||
>
|
||||
<FlashMessageRender byKey={'database:create'} css={tw`mb-6`}/>
|
||||
<h2 css={tw`text-2xl mb-6`}>Create new database</h2>
|
||||
<Form css={tw`m-0`}>
|
||||
{({ isSubmitting, resetForm }) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => {
|
||||
resetForm();
|
||||
setVisible(false);
|
||||
}}
|
||||
>
|
||||
<FlashMessageRender byKey={'database:create'} css={tw`mb-6`} />
|
||||
<h2 css={tw`text-2xl mb-6`}>Create new database</h2>
|
||||
<Form css={tw`m-0`}>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'database_name'}
|
||||
name={'databaseName'}
|
||||
label={'Database Name'}
|
||||
description={'A descriptive name for your database instance.'}
|
||||
/>
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'database_name'}
|
||||
name={'databaseName'}
|
||||
label={'Database Name'}
|
||||
description={'A descriptive name for your database instance.'}
|
||||
id={'connections_from'}
|
||||
name={'connectionsFrom'}
|
||||
label={'Connections From'}
|
||||
description={
|
||||
'Where connections should be allowed from. Leave blank to allow connections from anywhere.'
|
||||
}
|
||||
/>
|
||||
<div css={tw`mt-6`}>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'connections_from'}
|
||||
name={'connectionsFrom'}
|
||||
label={'Connections From'}
|
||||
description={'Where connections should be allowed from. Leave blank to allow connections from anywhere.'}
|
||||
/>
|
||||
</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 Database
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
</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 Database
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
<Button onClick={() => setVisible(true)}>
|
||||
New Database
|
||||
</Button>
|
||||
<Button onClick={() => setVisible(true)}>New Database</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -26,18 +26,18 @@ interface Props {
|
|||
}
|
||||
|
||||
export default ({ database, className }: Props) => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ connectionVisible, setConnectionVisible ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [connectionVisible, setConnectionVisible] = useState(false);
|
||||
|
||||
const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase);
|
||||
const removeDatabase = ServerContext.useStoreActions(actions => actions.databases.removeDatabase);
|
||||
const appendDatabase = ServerContext.useStoreActions((actions) => actions.databases.appendDatabase);
|
||||
const removeDatabase = ServerContext.useStoreActions((actions) => actions.databases.removeDatabase);
|
||||
|
||||
const schema = object().shape({
|
||||
confirm: string()
|
||||
.required('The database name must be provided.')
|
||||
.oneOf([ database.name.split('_', 2)[1], database.name ], 'The database name must be provided.'),
|
||||
.oneOf([database.name.split('_', 2)[1], database.name], 'The database name must be provided.'),
|
||||
});
|
||||
|
||||
const submit = (values: { confirm: string }, { setSubmitting }: FormikHelpers<{ confirm: string }>) => {
|
||||
|
@ -47,7 +47,7 @@ export default ({ database, className }: Props) => {
|
|||
setVisible(false);
|
||||
setTimeout(() => removeDatabase(database.id), 150);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setSubmitting(false);
|
||||
addError({ key: 'database:delete', message: httpErrorToHuman(error) });
|
||||
|
@ -56,65 +56,51 @@ export default ({ database, className }: Props) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ confirm: '' }}
|
||||
validationSchema={schema}
|
||||
isInitialValid={false}
|
||||
>
|
||||
{
|
||||
({ isSubmitting, isValid, resetForm }) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => {
|
||||
setVisible(false);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
<FlashMessageRender byKey={'database:delete'} css={tw`mb-6`}/>
|
||||
<h2 css={tw`text-2xl mb-6`}>Confirm database deletion</h2>
|
||||
<p css={tw`text-sm`}>
|
||||
Deleting a database is a permanent action, it cannot be undone. This will permanently
|
||||
delete the <strong>{database.name}</strong> database and remove all associated data.
|
||||
</p>
|
||||
<Form css={tw`m-0 mt-6`}>
|
||||
<Field
|
||||
type={'text'}
|
||||
id={'confirm_name'}
|
||||
name={'confirm'}
|
||||
label={'Confirm Database Name'}
|
||||
description={'Enter the database name to confirm deletion.'}
|
||||
/>
|
||||
<div css={tw`mt-6 text-right`}>
|
||||
<Button
|
||||
type={'button'}
|
||||
isSecondary
|
||||
css={tw`mr-2`}
|
||||
onClick={() => setVisible(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type={'submit'}
|
||||
color={'red'}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Delete Database
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
<Formik onSubmit={submit} initialValues={{ confirm: '' }} validationSchema={schema} isInitialValid={false}>
|
||||
{({ isSubmitting, isValid, resetForm }) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => {
|
||||
setVisible(false);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
<FlashMessageRender byKey={'database:delete'} css={tw`mb-6`} />
|
||||
<h2 css={tw`text-2xl mb-6`}>Confirm database deletion</h2>
|
||||
<p css={tw`text-sm`}>
|
||||
Deleting a database is a permanent action, it cannot be undone. This will permanently delete
|
||||
the <strong>{database.name}</strong> database and remove all associated data.
|
||||
</p>
|
||||
<Form css={tw`m-0 mt-6`}>
|
||||
<Field
|
||||
type={'text'}
|
||||
id={'confirm_name'}
|
||||
name={'confirm'}
|
||||
label={'Confirm Database Name'}
|
||||
description={'Enter the database name to confirm deletion.'}
|
||||
/>
|
||||
<div css={tw`mt-6 text-right`}>
|
||||
<Button type={'button'} isSecondary css={tw`mr-2`} onClick={() => setVisible(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type={'submit'} color={'red'} disabled={!isValid}>
|
||||
Delete Database
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
<Modal visible={connectionVisible} onDismissed={() => setConnectionVisible(false)}>
|
||||
<FlashMessageRender byKey={'database-connection-modal'} css={tw`mb-6`}/>
|
||||
<FlashMessageRender byKey={'database-connection-modal'} css={tw`mb-6`} />
|
||||
<h3 css={tw`mb-6 text-2xl`}>Database connection details</h3>
|
||||
<div>
|
||||
<Label>Endpoint</Label>
|
||||
<CopyOnClick text={database.connectionString}><Input type={'text'} readOnly value={database.connectionString} /></CopyOnClick>
|
||||
<CopyOnClick text={database.connectionString}>
|
||||
<Input type={'text'} readOnly value={database.connectionString} />
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Label>Connections from</Label>
|
||||
|
@ -122,17 +108,23 @@ export default ({ database, className }: Props) => {
|
|||
</div>
|
||||
<div css={tw`mt-6`}>
|
||||
<Label>Username</Label>
|
||||
<CopyOnClick text={database.username}><Input type={'text'} readOnly value={database.username} /></CopyOnClick>
|
||||
<CopyOnClick text={database.username}>
|
||||
<Input type={'text'} readOnly value={database.username} />
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
<Can action={'database.view_password'}>
|
||||
<div css={tw`mt-6`}>
|
||||
<Label>Password</Label>
|
||||
<CopyOnClick text={database.password}><Input type={'text'} readOnly value={database.password}/></CopyOnClick>
|
||||
<CopyOnClick text={database.password}>
|
||||
<Input type={'text'} readOnly value={database.password} />
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
</Can>
|
||||
<div css={tw`mt-6`}>
|
||||
<Label>JDBC Connection String</Label>
|
||||
<CopyOnClick text={`jdbc:mysql://${database.username}:${database.password}@${database.connectionString}/${database.name}`}>
|
||||
<CopyOnClick
|
||||
text={`jdbc:mysql://${database.username}:${database.password}@${database.connectionString}/${database.name}`}
|
||||
>
|
||||
<Input
|
||||
type={'text'}
|
||||
readOnly
|
||||
|
@ -142,7 +134,7 @@ export default ({ database, className }: Props) => {
|
|||
</div>
|
||||
<div css={tw`mt-6 text-right`}>
|
||||
<Can action={'database.update'}>
|
||||
<RotatePasswordButton databaseId={database.id} onUpdate={appendDatabase}/>
|
||||
<RotatePasswordButton databaseId={database.id} onUpdate={appendDatabase} />
|
||||
</Can>
|
||||
<Button isSecondary onClick={() => setConnectionVisible(false)}>
|
||||
Close
|
||||
|
@ -151,13 +143,17 @@ export default ({ database, className }: Props) => {
|
|||
</Modal>
|
||||
<GreyRowBox $hoverable={false} className={className} css={tw`mb-2`}>
|
||||
<div css={tw`hidden md:block`}>
|
||||
<FontAwesomeIcon icon={faDatabase} fixedWidth/>
|
||||
<FontAwesomeIcon icon={faDatabase} fixedWidth />
|
||||
</div>
|
||||
<div css={tw`flex-1 ml-4`}>
|
||||
<CopyOnClick text={database.name}><p css={tw`text-lg`}>{database.name}</p></CopyOnClick>
|
||||
<CopyOnClick text={database.name}>
|
||||
<p css={tw`text-lg`}>{database.name}</p>
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
<div css={tw`ml-8 text-center hidden md:block`}>
|
||||
<CopyOnClick text={database.connectionString}><p css={tw`text-sm`}>{database.connectionString}</p></CopyOnClick>
|
||||
<CopyOnClick text={database.connectionString}>
|
||||
<p css={tw`text-sm`}>{database.connectionString}</p>
|
||||
</CopyOnClick>
|
||||
<p css={tw`mt-1 text-2xs text-neutral-500 uppercase select-none`}>Endpoint</p>
|
||||
</div>
|
||||
<div css={tw`ml-8 text-center hidden md:block`}>
|
||||
|
@ -165,16 +161,18 @@ export default ({ database, className }: Props) => {
|
|||
<p css={tw`mt-1 text-2xs text-neutral-500 uppercase select-none`}>Connections from</p>
|
||||
</div>
|
||||
<div css={tw`ml-8 text-center hidden md:block`}>
|
||||
<CopyOnClick text={database.username}><p css={tw`text-sm`}>{database.username}</p></CopyOnClick>
|
||||
<CopyOnClick text={database.username}>
|
||||
<p css={tw`text-sm`}>{database.username}</p>
|
||||
</CopyOnClick>
|
||||
<p css={tw`mt-1 text-2xs text-neutral-500 uppercase select-none`}>Username</p>
|
||||
</div>
|
||||
<div css={tw`ml-8`}>
|
||||
<Button isSecondary css={tw`mr-2`} onClick={() => setConnectionVisible(true)}>
|
||||
<FontAwesomeIcon icon={faEye} fixedWidth/>
|
||||
<FontAwesomeIcon icon={faEye} fixedWidth />
|
||||
</Button>
|
||||
<Can action={'database.delete'}>
|
||||
<Button color={'red'} isSecondary onClick={() => setVisible(true)}>
|
||||
<FontAwesomeIcon icon={faTrashAlt} fixedWidth/>
|
||||
<FontAwesomeIcon icon={faTrashAlt} fixedWidth />
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
|
|
|
@ -14,22 +14,22 @@ import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
|||
import { useDeepMemoize } from '@/plugins/useDeepMemoize';
|
||||
|
||||
export default () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const databaseLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.databases);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const databaseLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.databases);
|
||||
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const databases = useDeepMemoize(ServerContext.useStoreState(state => state.databases.data));
|
||||
const setDatabases = ServerContext.useStoreActions(state => state.databases.setDatabases);
|
||||
const databases = useDeepMemoize(ServerContext.useStoreState((state) => state.databases.data));
|
||||
const setDatabases = ServerContext.useStoreActions((state) => state.databases.setDatabases);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(!databases.length);
|
||||
clearFlashes('databases');
|
||||
|
||||
getServerDatabases(uuid)
|
||||
.then(databases => setDatabases(databases))
|
||||
.catch(error => {
|
||||
.then((databases) => setDatabases(databases))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: 'databases', message: httpErrorToHuman(error) });
|
||||
})
|
||||
|
@ -38,13 +38,13 @@ export default () => {
|
|||
|
||||
return (
|
||||
<ServerContentBlock title={'Databases'}>
|
||||
<FlashMessageRender byKey={'databases'} css={tw`mb-4`}/>
|
||||
{(!databases.length && loading) ?
|
||||
<Spinner size={'large'} centered/>
|
||||
:
|
||||
<FlashMessageRender byKey={'databases'} css={tw`mb-4`} />
|
||||
{!databases.length && loading ? (
|
||||
<Spinner size={'large'} centered />
|
||||
) : (
|
||||
<Fade timeout={150}>
|
||||
<>
|
||||
{databases.length > 0 ?
|
||||
{databases.length > 0 ? (
|
||||
databases.map((database, index) => (
|
||||
<DatabaseRow
|
||||
key={database.id}
|
||||
|
@ -52,31 +52,29 @@ export default () => {
|
|||
className={index > 0 ? 'mt-1' : undefined}
|
||||
/>
|
||||
))
|
||||
:
|
||||
) : (
|
||||
<p css={tw`text-center text-sm text-neutral-300`}>
|
||||
{databaseLimit > 0 ?
|
||||
'It looks like you have no databases.'
|
||||
:
|
||||
'Databases cannot be created for this server.'
|
||||
}
|
||||
{databaseLimit > 0
|
||||
? 'It looks like you have no databases.'
|
||||
: 'Databases cannot be created for this server.'}
|
||||
</p>
|
||||
}
|
||||
)}
|
||||
<Can action={'database.create'}>
|
||||
<div css={tw`mt-6 flex items-center justify-end`}>
|
||||
{(databaseLimit > 0 && databases.length > 0) &&
|
||||
<p css={tw`text-sm text-neutral-300 mb-4 sm:mr-6 sm:mb-0`}>
|
||||
{databases.length} of {databaseLimit} databases have been allocated to this
|
||||
server.
|
||||
</p>
|
||||
}
|
||||
{databaseLimit > 0 && databaseLimit !== databases.length &&
|
||||
<CreateDatabaseButton css={tw`flex justify-end mt-6`}/>
|
||||
}
|
||||
{databaseLimit > 0 && databases.length > 0 && (
|
||||
<p css={tw`text-sm text-neutral-300 mb-4 sm:mr-6 sm:mb-0`}>
|
||||
{databases.length} of {databaseLimit} databases have been allocated to this
|
||||
server.
|
||||
</p>
|
||||
)}
|
||||
{databaseLimit > 0 && databaseLimit !== databases.length && (
|
||||
<CreateDatabaseButton css={tw`flex justify-end mt-6`} />
|
||||
)}
|
||||
</div>
|
||||
</Can>
|
||||
</>
|
||||
</Fade>
|
||||
}
|
||||
)}
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -8,13 +8,10 @@ import { httpErrorToHuman } from '@/api/http';
|
|||
import Button from '@/components/elements/Button';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
export default ({ databaseId, onUpdate }: {
|
||||
databaseId: string;
|
||||
onUpdate: (database: ServerDatabase) => void;
|
||||
}) => {
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
export default ({ databaseId, onUpdate }: { databaseId: string; onUpdate: (database: ServerDatabase) => void }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const server = ServerContext.useStoreState(state => state.server.data!);
|
||||
const server = ServerContext.useStoreState((state) => state.server.data!);
|
||||
|
||||
if (!databaseId) {
|
||||
return null;
|
||||
|
@ -25,8 +22,8 @@ export default ({ databaseId, onUpdate }: {
|
|||
clearFlashes();
|
||||
|
||||
rotateDatabasePassword(server.uuid, databaseId)
|
||||
.then(database => onUpdate(database))
|
||||
.catch(error => {
|
||||
.then((database) => onUpdate(database))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addFlash({
|
||||
type: 'error',
|
||||
|
|
|
@ -16,5 +16,5 @@ export enum SocketEvent {
|
|||
export enum SocketRequest {
|
||||
SEND_LOGS = 'send logs',
|
||||
SEND_STATS = 'send stats',
|
||||
SET_STATE = 'set state'
|
||||
SET_STATE = 'set state',
|
||||
}
|
||||
|
|
|
@ -2,18 +2,20 @@ import React, { useMemo } from 'react';
|
|||
import features from './index';
|
||||
import { getObjectKeys } from '@/lib/objects';
|
||||
|
||||
type ListItems = [ string, React.ComponentType ][];
|
||||
type ListItems = [string, React.ComponentType][];
|
||||
|
||||
export default ({ enabled }: { enabled: string[] }) => {
|
||||
const mapped: ListItems = useMemo(() => {
|
||||
return getObjectKeys(features)
|
||||
.filter(key => enabled.map((v) => v.toLowerCase()).includes(key.toLowerCase()))
|
||||
.reduce((arr, key) => [ ...arr, [ key, features[key] ] ], [] as ListItems);
|
||||
}, [ enabled ]);
|
||||
.filter((key) => enabled.map((v) => v.toLowerCase()).includes(key.toLowerCase()))
|
||||
.reduce((arr, key) => [...arr, [key, features[key]]], [] as ListItems);
|
||||
}, [enabled]);
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
{mapped.map(([ key, Component ]) => <Component key={key}/>)}
|
||||
{mapped.map(([key, Component]) => (
|
||||
<Component key={key} />
|
||||
))}
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -15,24 +15,21 @@ interface Values {
|
|||
}
|
||||
|
||||
const GSLTokenModalFeature = () => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const { connected, instance } = ServerContext.useStoreState((state) => state.socket);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || !instance || status === 'running') return;
|
||||
|
||||
const errors = [
|
||||
'(gsl token expired)',
|
||||
'(account not found)',
|
||||
];
|
||||
const errors = ['(gsl token expired)', '(account not found)'];
|
||||
|
||||
const listener = (line: string) => {
|
||||
if (errors.some(p => line.toLowerCase().includes(p))) {
|
||||
if (errors.some((p) => line.toLowerCase().includes(p))) {
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
|
@ -42,7 +39,7 @@ const GSLTokenModalFeature = () => {
|
|||
return () => {
|
||||
instance.removeListener(SocketEvent.CONSOLE_OUTPUT, listener);
|
||||
};
|
||||
}, [ connected, instance, status ]);
|
||||
}, [connected, instance, status]);
|
||||
|
||||
const updateGSLToken = (values: Values) => {
|
||||
setLoading(true);
|
||||
|
@ -57,7 +54,7 @@ const GSLTokenModalFeature = () => {
|
|||
setLoading(false);
|
||||
setVisible(false);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ key: 'feature:gslToken', error });
|
||||
})
|
||||
|
@ -69,16 +66,23 @@ const GSLTokenModalFeature = () => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={updateGSLToken}
|
||||
initialValues={{ gslToken: '' }}
|
||||
>
|
||||
<Modal visible={visible} onDismissed={() => setVisible(false)} closeOnBackground={false} showSpinnerOverlay={loading}>
|
||||
<FlashMessageRender key={'feature:gslToken'} css={tw`mb-4`}/>
|
||||
<Formik onSubmit={updateGSLToken} initialValues={{ gslToken: '' }}>
|
||||
<Modal
|
||||
visible={visible}
|
||||
onDismissed={() => setVisible(false)}
|
||||
closeOnBackground={false}
|
||||
showSpinnerOverlay={loading}
|
||||
>
|
||||
<FlashMessageRender key={'feature:gslToken'} css={tw`mb-4`} />
|
||||
<Form>
|
||||
<h2 css={tw`text-2xl mb-4 text-neutral-100`}>Invalid GSL token!</h2>
|
||||
<p css={tw`mt-4`}>It seems like your Gameserver Login Token (GSL token) is invalid or has expired.</p>
|
||||
<p css={tw`mt-4`}>You can either generate a new one and enter it below or leave the field blank to remove it completely.</p>
|
||||
<p css={tw`mt-4`}>
|
||||
It seems like your Gameserver Login Token (GSL token) is invalid or has expired.
|
||||
</p>
|
||||
<p css={tw`mt-4`}>
|
||||
You can either generate a new one and enter it below or leave the field blank to remove it
|
||||
completely.
|
||||
</p>
|
||||
<div css={tw`sm:flex items-center mt-4`}>
|
||||
<Field
|
||||
name={'gslToken'}
|
||||
|
|
|
@ -22,14 +22,14 @@ const MATCH_ERRORS = [
|
|||
];
|
||||
|
||||
const JavaVersionModalFeature = () => {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [ selectedVersion, setSelectedVersion ] = useState('');
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedVersion, setSelectedVersion] = useState('');
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
const status = ServerContext.useStoreState((state) => state.status.value);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const { instance } = ServerContext.useStoreState((state) => state.socket);
|
||||
|
||||
const { data, isValidating, mutate } = getServerStartup(uuid, null, { revalidateOnMount: false });
|
||||
|
||||
|
@ -39,12 +39,12 @@ const JavaVersionModalFeature = () => {
|
|||
mutate().then((value) => {
|
||||
setSelectedVersion(Object.keys(value?.dockerImages || [])[0] || '');
|
||||
});
|
||||
}, [ visible ]);
|
||||
}, [visible]);
|
||||
|
||||
useWebsocketEvent(SocketEvent.CONSOLE_OUTPUT, (data) => {
|
||||
if (status === 'running') return;
|
||||
|
||||
if (MATCH_ERRORS.some(p => data.toLowerCase().includes(p.toLowerCase()))) {
|
||||
if (MATCH_ERRORS.some((p) => data.toLowerCase().includes(p.toLowerCase()))) {
|
||||
setVisible(true);
|
||||
}
|
||||
});
|
||||
|
@ -60,7 +60,7 @@ const JavaVersionModalFeature = () => {
|
|||
}
|
||||
setVisible(false);
|
||||
})
|
||||
.catch(error => clearAndAddHttpError({ key: 'feature:javaVersion', error }))
|
||||
.catch((error) => clearAndAddHttpError({ key: 'feature:javaVersion', error }))
|
||||
.then(() => setLoading(false));
|
||||
};
|
||||
|
||||
|
@ -75,7 +75,7 @@ const JavaVersionModalFeature = () => {
|
|||
closeOnBackground={false}
|
||||
showSpinnerOverlay={loading}
|
||||
>
|
||||
<FlashMessageRender key={'feature:javaVersion'} css={tw`mb-4`}/>
|
||||
<FlashMessageRender key={'feature:javaVersion'} css={tw`mb-4`} />
|
||||
<h2 css={tw`text-2xl mb-4 text-neutral-100`}>Unsupported Java Version</h2>
|
||||
<p css={tw`mt-4`}>
|
||||
This server is currently running an unsupported version of Java and cannot be started.
|
||||
|
@ -86,13 +86,16 @@ const JavaVersionModalFeature = () => {
|
|||
<Can action={'startup.docker-image'}>
|
||||
<div css={tw`mt-4`}>
|
||||
<InputSpinner visible={!data || isValidating}>
|
||||
<Select disabled={!data} onChange={e => setSelectedVersion(e.target.value)}>
|
||||
{!data
|
||||
? <option disabled/>
|
||||
: Object.keys((data.dockerImages)).map((key) => (
|
||||
<option key={key} value={data.dockerImages[key]}>{key}</option>
|
||||
<Select disabled={!data} onChange={(e) => setSelectedVersion(e.target.value)}>
|
||||
{!data ? (
|
||||
<option disabled />
|
||||
) : (
|
||||
Object.keys(data.dockerImages).map((key) => (
|
||||
<option key={key} value={data.dockerImages[key]}>
|
||||
{key}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
)}
|
||||
</Select>
|
||||
</InputSpinner>
|
||||
</div>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue