ux: add link to doc for LAN command checkbox

This commit is contained in:
bsourisse 2023-06-11 20:16:17 +02:00
parent 2074f90c66
commit e21a0ac896
2 changed files with 463 additions and 448 deletions

View file

@ -9,7 +9,6 @@ export default function QuickCommands(props) {
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled. //Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
let HOSTNAME; let HOSTNAME;
let SSH_SERVER_PORT; let SSH_SERVER_PORT;
console.log(props.lanCommand);
if ( if (
props.lanCommand && props.lanCommand &&
process.env.NEXT_PUBLIC_HOSTNAME_LAN && process.env.NEXT_PUBLIC_HOSTNAME_LAN &&

View file

@ -8,458 +8,474 @@ import 'react-toastify/dist/ReactToastify.css';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { SpinnerDotted } from 'spinners-react'; import { SpinnerDotted } from 'spinners-react';
import Select from 'react-select'; import Select from 'react-select';
import Link from 'next/link';
import { IconExternalLink } from '@tabler/icons';
export default function RepoManage(props) { export default function RepoManage(props) {
////Var ////Var
let targetRepo; let targetRepo;
const router = useRouter(); const router = useRouter();
const { const {
register, register,
handleSubmit, handleSubmit,
control, control,
formState: { errors, isSubmitting, isValid }, formState: { errors, isSubmitting, isValid },
} = useForm({ mode: 'onChange' }); } = useForm({ mode: 'onChange' });
//List of possible times for alerts //List of possible times for alerts
const alertOptions = [ const alertOptions = [
{ value: 3600, label: '1 hour' }, { value: 3600, label: '1 hour' },
{ value: 21600, label: '6 hours' }, { value: 21600, label: '6 hours' },
{ value: 43200, label: '12 hours' }, { value: 43200, label: '12 hours' },
{ value: 90000, label: '1 day' }, { value: 90000, label: '1 day' },
{ value: 172800, label: '2 days' }, { value: 172800, label: '2 days' },
{ value: 259200, label: '3 days' }, { value: 259200, label: '3 days' },
{ value: 345600, label: '4 days' }, { value: 345600, label: '4 days' },
{ value: 432000, label: '5 days' }, { value: 432000, label: '5 days' },
{ value: 518400, label: '6 days' }, { value: 518400, label: '6 days' },
{ value: 604800, label: '7 days' }, { value: 604800, label: '7 days' },
{ value: 864000, label: '10 days' }, { value: 864000, label: '10 days' },
{ value: 1209600, label: '14 days' }, { value: 1209600, label: '14 days' },
{ value: 2592000, label: '30 days' }, { value: 2592000, label: '30 days' },
]; ];
const toastOptions = { const toastOptions = {
position: 'top-right', position: 'top-right',
autoClose: 5000, autoClose: 5000,
hideProgressBar: false, hideProgressBar: false,
closeOnClick: true, closeOnClick: true,
pauseOnHover: true, pauseOnHover: true,
draggable: true, draggable: true,
progress: undefined, progress: undefined,
}; };
////State ////State
const [deleteDialog, setDeleteDialog] = useState(false); const [deleteDialog, setDeleteDialog] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
////Functions ////Functions
//router.query.slug is undefined for few milliseconds on first render for a direct URL access (https://github.com/vercel/next.js/discussions/11484). //router.query.slug is undefined for few milliseconds on first render for a direct URL access (https://github.com/vercel/next.js/discussions/11484).
//If I call repoManage with edit mode (props), i'm firstly waiting that router.query.slug being available before rendering. //If I call repoManage with edit mode (props), i'm firstly waiting that router.query.slug being available before rendering.
if (!router.query.slug && props.mode == 'edit') { if (!router.query.slug && props.mode == 'edit') {
return ( return (
<SpinnerDotted <SpinnerDotted
size={30} size={30}
thickness={100} thickness={100}
speed={180} speed={180}
color='rgba(109, 74, 255, 1)' color='rgba(109, 74, 255, 1)'
/> />
); );
} else if (props.mode == 'edit') { } else if (props.mode == 'edit') {
for (let element in props.repoList) { for (let element in props.repoList) {
if (props.repoList[element].id == router.query.slug) { if (props.repoList[element].id == router.query.slug) {
targetRepo = props.repoList[element]; targetRepo = props.repoList[element];
} }
} }
//If the ID does not exist > 404 //If the ID does not exist > 404
if (!targetRepo) { if (!targetRepo) {
router.push('/404'); router.push('/404');
return null; return null;
} }
} }
//Delete a repo //Delete a repo
const deleteHandler = async () => { const deleteHandler = async () => {
//API Call for delete //API Call for delete
fetch('/api/repo/id/' + router.query.slug + '/delete', { fetch('/api/repo/id/' + router.query.slug + '/delete', {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Content-type': 'application/json', 'Content-type': 'application/json',
}, },
body: JSON.stringify({ toDelete: true }), body: JSON.stringify({ toDelete: true }),
}) })
.then((response) => { .then((response) => {
if (response.ok) { if (response.ok) {
toast.success( toast.success(
'🗑 The repository #' + '🗑 The repository #' +
router.query.slug + router.query.slug +
' has been successfully deleted', ' has been successfully deleted',
toastOptions toastOptions
); );
router.replace('/'); router.replace('/');
} else { } else {
toast.error('An error has occurred', toastOptions); toast.error('An error has occurred', toastOptions);
router.replace('/'); router.replace('/');
console.log('Fail to delete'); console.log('Fail to delete');
} }
}) })
.catch((error) => { .catch((error) => {
toast.error('An error has occurred', toastOptions); toast.error('An error has occurred', toastOptions);
router.replace('/'); router.replace('/');
console.log(error); console.log(error);
}); });
}; };
//Form submit Handler for ADD or EDIT a repo //Form submit Handler for ADD or EDIT a repo
const formSubmitHandler = async (dataForm) => { const formSubmitHandler = async (dataForm) => {
//Loading button on submit to avoid multiple send. //Loading button on submit to avoid multiple send.
setIsLoading(true); setIsLoading(true);
//ADD a repo //ADD a repo
if (props.mode == 'add') { if (props.mode == 'add') {
const newRepo = { const newRepo = {
alias: dataForm.alias, alias: dataForm.alias,
size: dataForm.size, size: dataForm.size,
sshPublicKey: dataForm.sshkey, sshPublicKey: dataForm.sshkey,
comment: dataForm.comment, comment: dataForm.comment,
alert: dataForm.alert.value, alert: dataForm.alert.value,
lanCommand: dataForm.lanCommand, lanCommand: dataForm.lanCommand,
}; };
//POST API to send new repo //POST API to send new repo
await fetch('/api/repo/add', { await fetch('/api/repo/add', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-type': 'application/json', 'Content-type': 'application/json',
}, },
body: JSON.stringify(newRepo), body: JSON.stringify(newRepo),
}) })
.then((response) => { .then((response) => {
if (response.ok) { if (response.ok) {
toast.success( toast.success(
'New repository added ! 🥳', 'New repository added ! 🥳',
toastOptions toastOptions
); );
router.replace('/'); router.replace('/');
} else { } else {
toast.error('An error has occurred', toastOptions); toast.error('An error has occurred', toastOptions);
router.replace('/'); router.replace('/');
console.log('Fail to post'); console.log('Fail to post');
} }
}) })
.catch((error) => { .catch((error) => {
toast.error('An error has occurred', toastOptions); toast.error('An error has occurred', toastOptions);
router.replace('/'); router.replace('/');
console.log(error); console.log(error);
}); });
//EDIT a repo //EDIT a repo
} else if (props.mode == 'edit') { } else if (props.mode == 'edit') {
const dataEdited = { const dataEdited = {
alias: dataForm.alias, alias: dataForm.alias,
size: dataForm.size, size: dataForm.size,
sshPublicKey: dataForm.sshkey, sshPublicKey: dataForm.sshkey,
comment: dataForm.comment, comment: dataForm.comment,
alert: dataForm.alert.value, alert: dataForm.alert.value,
lanCommand: dataForm.lanCommand, lanCommand: dataForm.lanCommand,
}; };
await fetch('/api/repo/id/' + router.query.slug + '/edit', { await fetch('/api/repo/id/' + router.query.slug + '/edit', {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-type': 'application/json', 'Content-type': 'application/json',
}, },
body: JSON.stringify(dataEdited), body: JSON.stringify(dataEdited),
}) })
.then((response) => { .then((response) => {
if (response.ok) { if (response.ok) {
toast.success( toast.success(
'The repository #' + 'The repository #' +
targetRepo.id + targetRepo.id +
' has been successfully edited !', ' has been successfully edited !',
toastOptions toastOptions
); );
router.replace('/'); router.replace('/');
} else { } else {
toast.error('An error has occurred', toastOptions); toast.error('An error has occurred', toastOptions);
router.replace('/'); router.replace('/');
console.log('Fail to PUT'); console.log('Fail to PUT');
} }
}) })
.catch((error) => { .catch((error) => {
toast.error('An error has occurred', toastOptions); toast.error('An error has occurred', toastOptions);
router.replace('/'); router.replace('/');
console.log(error); console.log(error);
}); });
} }
}; };
return ( return (
<> <>
<div className={classes.modaleWrapper} /> <div className={classes.modaleWrapper} />
<div className={classes.modale}> <div className={classes.modale}>
<div onClick={props.closeHandler} className={classes.close}> <div onClick={props.closeHandler} className={classes.close}>
<IconX size={36} /> <IconX size={36} />
</div> </div>
{deleteDialog ? ( {deleteDialog ? (
<div className={classes.deleteDialogWrapper}> <div className={classes.deleteDialogWrapper}>
<div> <div>
<IconAlertCircle <IconAlertCircle
size={80} size={80}
color='red' color='red'
style={{ margin: 'auto' }} style={{ margin: 'auto' }}
/> />
<h1 style={{ textAlign: 'center' }}> <h1 style={{ textAlign: 'center' }}>
Delete the repository{' '} Delete the repository{' '}
<span <span
style={{ style={{
color: 'rgba(99, 115, 129, 0.38)', color: 'rgba(99, 115, 129, 0.38)',
}} }}
> >
#{targetRepo.id} #{targetRepo.id}
</span>{' '} </span>{' '}
? ?
</h1> </h1>
</div> </div>
<div className={classes.deleteDialogMessage}> <div className={classes.deleteDialogMessage}>
<div style={{ marginBottom: '5px' }}> <div style={{ marginBottom: '5px' }}>
You are about to permanently delete the You are about to permanently delete the
repository <b>#{targetRepo.id}</b> and all the repository <b>#{targetRepo.id}</b> and all the
backups it contains. backups it contains.
</div> </div>
<div> <div>
The data will not be recoverable and it will not The data will not be recoverable and it will not
be possible to go back. be possible to go back.
</div> </div>
</div> </div>
<div className={classes.deleteDialogButtonWrapper}> <div className={classes.deleteDialogButtonWrapper}>
{isLoading ? ( {isLoading ? (
<SpinnerDotted <SpinnerDotted
size={30} size={30}
thickness={150} thickness={150}
speed={100} speed={100}
color='#6d4aff' color='#6d4aff'
/> />
) : ( ) : (
<> <>
<button <button
onClick={props.closeHandler} onClick={props.closeHandler}
className={classes.cancelButton} className={classes.cancelButton}
> >
Cancel Cancel
</button> </button>
<button <button
onClick={() => { onClick={() => {
deleteHandler(); deleteHandler();
setIsLoading(true); setIsLoading(true);
}} }}
className={classes.deleteButton} className={classes.deleteButton}
> >
Yes, delete it ! Yes, delete it !
</button> </button>
</> </>
)} )}
</div> </div>
</div> </div>
) : ( ) : (
<div className={classes.formWrapper}> <div className={classes.formWrapper}>
{props.mode == 'edit' && ( {props.mode == 'edit' && (
<h1> <h1>
Edit the repository{' '} Edit the repository{' '}
<span <span
style={{ style={{
color: 'rgba(99, 115, 129, 0.38)', color: 'rgba(99, 115, 129, 0.38)',
}} }}
> >
#{targetRepo.id} #{targetRepo.id}
</span> </span>
</h1> </h1>
)} )}
{props.mode == 'add' && <h1>Add a repository</h1>} {props.mode == 'add' && <h1>Add a repository</h1>}
<form <form
className={classes.repoManageForm} className={classes.repoManageForm}
onSubmit={handleSubmit(formSubmitHandler)} onSubmit={handleSubmit(formSubmitHandler)}
> >
{/* ALIAS */} {/* ALIAS */}
<label htmlFor='alias'>Alias</label> <label htmlFor='alias'>Alias</label>
<input <input
className='form-control is-invalid' className='form-control is-invalid'
placeholder='Alias for the repository, e.g."Server 1"' placeholder='Alias for the repository, e.g."Server 1"'
type='text' type='text'
defaultValue={ defaultValue={
props.mode == 'edit' props.mode == 'edit'
? targetRepo.alias ? targetRepo.alias
: null : null
} }
{...register('alias', { {...register('alias', {
required: 'An alias is required.', required: 'An alias is required.',
minLength: { minLength: {
value: 2, value: 2,
message: '2 characters min', message: '2 characters min',
}, },
maxLength: { maxLength: {
value: 40, value: 40,
message: '40 characters max', message: '40 characters max',
}, },
})} })}
/> />
{errors.alias && ( {errors.alias && (
<span className={classes.errorMessage}> <span className={classes.errorMessage}>
{errors.alias.message} {errors.alias.message}
</span> </span>
)} )}
{/* SSH KEY */} {/* SSH KEY */}
<label htmlFor='sshkey'>SSH public key</label> <label htmlFor='sshkey'>SSH public key</label>
<textarea <textarea
placeholder='Public key in OpenSSH format (rsa, ed25519, ed25519-sk)' placeholder='Public key in OpenSSH format (rsa, ed25519, ed25519-sk)'
type='text' type='text'
defaultValue={ defaultValue={
props.mode == 'edit' props.mode == 'edit'
? targetRepo.sshPublicKey ? targetRepo.sshPublicKey
: null : null
} }
{...register('sshkey', { {...register('sshkey', {
required: 'SSH public key is required.', required: 'SSH public key is required.',
pattern: { pattern: {
value: /^(ssh-ed25519 AAAAC3NzaC1lZDI1NTE5|sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t|ssh-rsa AAAAB3NzaC1yc2)[0-9A-Za-z+/]+[=]{0,3}(\s.*)?$/, value: /^(ssh-ed25519 AAAAC3NzaC1lZDI1NTE5|sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t|ssh-rsa AAAAB3NzaC1yc2)[0-9A-Za-z+/]+[=]{0,3}(\s.*)?$/,
message: message:
'Invalid public key. The SSH key needs to be in OpenSSH format (rsa, ed25519, ed25519-sk)', 'Invalid public key. The SSH key needs to be in OpenSSH format (rsa, ed25519, ed25519-sk)',
}, },
})} })}
/> />
{errors.sshkey && ( {errors.sshkey && (
<span className={classes.errorMessage}> <span className={classes.errorMessage}>
{errors.sshkey.message} {errors.sshkey.message}
</span> </span>
)} )}
{/* SIZE */} {/* SIZE */}
<label htmlFor='size'>Storage Size (Go)</label> <label htmlFor='size'>Storage Size (Go)</label>
<input <input
type='number' type='number'
defaultValue={ defaultValue={
props.mode == 'edit' props.mode == 'edit'
? targetRepo.storageSize ? targetRepo.storageSize
: null : null
} }
{...register('size', { {...register('size', {
required: 'A size is required.', required: 'A size is required.',
})} })}
/> />
{errors.size && ( {errors.size && (
<span className={classes.errorMessage}> <span className={classes.errorMessage}>
{errors.size.message} {errors.size.message}
</span> </span>
)} )}
{/* COMMENT */} {/* COMMENT */}
<label htmlFor='comment'>Comment</label> <label htmlFor='comment'>Comment</label>
<textarea <textarea
type='text' type='text'
placeholder='Little comment for your repository...' placeholder='Little comment for your repository...'
defaultValue={ defaultValue={
props.mode == 'edit' props.mode == 'edit'
? targetRepo.comment ? targetRepo.comment
: null : null
} }
{...register('comment', { {...register('comment', {
required: false, required: false,
maxLength: { maxLength: {
value: 200, value: 200,
message: '200 characters maximum.', message: '200 characters maximum.',
}, },
})} })}
/> />
{errors.comment && ( {errors.comment && (
<span className={classes.errorMessage}> <span className={classes.errorMessage}>
{errors.comment.message} {errors.comment.message}
</span> </span>
)} )}
{/* LAN COMMAND GENERATION */} {/* LAN COMMAND GENERATION */}
<div className={classes.lanCommandWrapper}> <div className={classes.lanCommandWrapper}>
<input <input
type='checkbox' type='checkbox'
name='lanCommand' name='lanCommand'
defaultChecked={ defaultChecked={
props.mode == 'edit' props.mode == 'edit'
? targetRepo.lanCommand ? targetRepo.lanCommand
: false : false
} }
{...register('lanCommand')} {...register('lanCommand')}
/> />
<label htmlFor='lanCommand'> <label htmlFor='lanCommand'>
Generates commands for use over LAN. Generates commands for use over LAN.
</label> </label>
</div> <Link
{/* ALERT */} style={{
<label alignSelf: 'baseline',
style={{ margin: '25px auto 10px auto' }} marginLeft: '5px',
htmlFor='alert' }}
> href='https://borgwarehouse.com/docs/user-manual/repositories/#generates-commands-for-use-over-lan'
Alert if there is no backup since : rel='noreferrer'
</label> target='_blank'
<div className={classes.selectAlert}> >
<Controller <IconExternalLink
name='alert' size={16}
defaultValue={ color='#6c737f'
props.mode == 'edit' />
? alertOptions.find( </Link>
(x) => </div>
x.value === {/* ALERT */}
targetRepo.alert <label
) style={{ margin: '25px auto 10px auto' }}
: alertOptions[3] htmlFor='alert'
} >
control={control} Alert if there is no backup since :
render={({ </label>
field: { onChange, value }, <div className={classes.selectAlert}>
}) => ( <Controller
<Select name='alert'
onChange={onChange} defaultValue={
value={value} props.mode == 'edit'
options={alertOptions} ? alertOptions.find(
isSearchable={false} (x) =>
maxMenuHeight={150} x.value ===
menuPlacement='top' targetRepo.alert
theme={(theme) => ({ )
...theme, : alertOptions[3]
borderRadius: '5px', }
colors: { control={control}
...theme.colors, render={({
primary25: '#c3b6fa', field: { onChange, value },
primary: '#6d4aff', }) => (
}, <Select
})} onChange={onChange}
/> value={value}
)} options={alertOptions}
/> isSearchable={false}
</div> maxMenuHeight={150}
{isLoading ? ( menuPlacement='top'
<div theme={(theme) => ({
style={{ ...theme,
textAlign: 'center', borderRadius: '5px',
marginTop: '8px', colors: {
}} ...theme.colors,
> primary25: '#c3b6fa',
<SpinnerDotted primary: '#6d4aff',
size={30} },
thickness={150} })}
speed={100} />
color='#6d4aff' )}
/> />
</div> </div>
) : ( {isLoading ? (
<button <div
type='submit' style={{
className='defaultButton' textAlign: 'center',
disabled={!isValid || isSubmitting} marginTop: '8px',
> }}
{props.mode == 'edit' && 'Edit'} >
{props.mode == 'add' && 'Add'} <SpinnerDotted
</button> size={30}
)} thickness={150}
</form> speed={100}
{props.mode == 'edit' ? ( color='#6d4aff'
<button />
className={classes.littleDeleteButton} </div>
onClick={() => setDeleteDialog(true)} ) : (
> <button
Delete this repository type='submit'
</button> className='defaultButton'
) : null} disabled={!isValid || isSubmitting}
</div> >
)} {props.mode == 'edit' && 'Edit'}
</div> {props.mode == 'add' && 'Add'}
</> </button>
); )}
</form>
{props.mode == 'edit' ? (
<button
className={classes.littleDeleteButton}
onClick={() => setDeleteDialog(true)}
>
Delete this repository
</button>
) : null}
</div>
)}
</div>
</>
);
} }