Browse Source

feat(relocation): Backup upload page (#61441)

Adds the backup upload page to relocation onboarding process



https://github.com/getsentry/sentry/assets/25517925/b4d03110-a628-40fb-932a-a9339e8db201
Hubert Deng 1 year ago
parent
commit
2eadb6ece9

+ 1 - 1
static/app/views/relocation/encryptBackup.tsx

@@ -18,7 +18,7 @@ export function EncryptBackup(props: StepProps) {
   return (
     <Wrapper>
       <StepHeading step={3}>
-        {t('Create an encrypted backup of current self-hosted instance')}
+        {t('Create an encrypted backup of your current self-hosted instance')}
       </StepHeading>
       <motion.div
         transition={testableTransition()}

+ 8 - 7
static/app/views/relocation/getStarted.tsx

@@ -16,16 +16,15 @@ import {StepProps} from './types';
 
 function GetStarted(props: StepProps) {
   const regions = ConfigStore.get('regions');
-  const [region, setRegion] = useState('');
+  const [regionUrl, setRegionUrl] = useState('');
   const [orgSlugs, setOrgSlugs] = useState('');
   const relocationOnboardingContext = useContext(RelocationOnboardingContext);
 
   const handleContinue = (event: any) => {
     event.preventDefault();
-    relocationOnboardingContext.setData({orgSlugs, region});
+    relocationOnboardingContext.setData({orgSlugs, regionUrl});
     props.onComplete();
   };
-  // TODO(getsentry/team-ospo#214): Make a popup to warn users about data region selection
   return (
     <Wrapper>
       <StepHeading step={1}>{t('Basic information needed to get started')}</StepHeading>
@@ -55,16 +54,18 @@ function GetStarted(props: StepProps) {
           />
           <Label>{t('Choose a datacenter region')}</Label>
           <RegionSelect
-            value={region}
+            value={regionUrl}
             name="region"
             aria-label="region"
             placeholder="Select Region"
             options={regions.map(r => ({label: r.name, value: r.name}))}
-            onChange={opt => setRegion(opt.value)}
+            onChange={opt => setRegionUrl(opt.value)}
           />
-          {region && <p>{t('This is an important decision and cannot be changed.')}</p>}
+          {regionUrl && (
+            <p>{t('This is an important decision and cannot be changed.')}</p>
+          )}
           <ContinueButton
-            disabled={!orgSlugs || !region}
+            disabled={!orgSlugs || !regionUrl}
             size="md"
             priority="primary"
             type="submit"

+ 188 - 4
static/app/views/relocation/relocation.spec.tsx

@@ -1,9 +1,17 @@
 import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+import {
+  fireEvent,
+  render,
+  screen,
+  userEvent,
+  waitFor,
+} from 'sentry-test/reactTestingLibrary';
 
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
 import ConfigStore from 'sentry/stores/configStore';
 import Relocation from 'sentry/views/relocation/relocation';
 
+jest.mock('sentry/actionCreators/indicator');
 const fakePublicKey = `-----BEGIN PUBLIC KEY-----
 MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw5Or1zsGE1XJTL4q+1c4
 Ztu8+7SC/exrnEYlWH+LVLI8TVyuGwDTAXrgKHGwaMM5ZnjijP5i8+ph8lfLrybT
@@ -145,17 +153,193 @@ describe('Relocation', function () {
     });
   });
 
-  describe('Select Platform', function () {
+  describe('Encrypt Backup', function () {
     it('renders', async function () {
       renderPage('encrypt-backup');
       await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
 
       expect(
         await screen.findByText(
-          'Create an encrypted backup of current self-hosted instance'
+          'Create an encrypted backup of your current self-hosted instance'
         )
       ).toBeInTheDocument();
-      expect(await screen.findByText('./sentry-admin.sh')).toBeInTheDocument();
+    });
+  });
+
+  describe('Upload Backup', function () {
+    it('renders', async function () {
+      renderPage('upload-backup');
+      expect(
+        await screen.findByText('Upload Tarball to begin the relocation process')
+      ).toBeInTheDocument();
+    });
+
+    it('accepts a file upload', async function () {
+      renderPage('upload-backup');
+      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
+      const input = screen.getByLabelText('file-upload');
+      await userEvent.upload(input, relocationFile);
+      expect(await screen.findByText('hello.tar')).toBeInTheDocument();
+      expect(await screen.findByText('Start Relocation')).toBeInTheDocument();
+    });
+
+    it('accepts a file upload through drag and drop', async function () {
+      renderPage('upload-backup');
+      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
+      const dropzone = screen.getByLabelText('dropzone');
+      fireEvent.drop(dropzone, {dataTransfer: {files: [relocationFile]}});
+      expect(await screen.findByText('hello.tar')).toBeInTheDocument();
+      expect(await screen.findByText('Start Relocation')).toBeInTheDocument();
+    });
+
+    it('correctly removes file and prompts for file upload', async function () {
+      renderPage('upload-backup');
+      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
+      const input = screen.getByLabelText('file-upload');
+      await userEvent.upload(input, relocationFile);
+      await userEvent.click(screen.getByText('Remove file'));
+      expect(screen.queryByText('hello.tar')).not.toBeInTheDocument();
+      expect(
+        await screen.findByText('Upload Tarball to begin the relocation process')
+      ).toBeInTheDocument();
+    });
+
+    it('fails to starts relocation job if some form data is missing', async function () {
+      const mockapi = MockApiClient.addMockResponse({
+        url: `/relocations/`,
+        method: 'POST',
+      });
+      renderPage('upload-backup');
+      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
+      const input = screen.getByLabelText('file-upload');
+      await userEvent.upload(input, relocationFile);
+      await userEvent.click(await screen.findByText('Start Relocation'));
+      await waitFor(() => expect(mockapi).not.toHaveBeenCalled());
+      expect(addErrorMessage).toHaveBeenCalledWith(
+        'An error has occurred while trying to start relocation job. Please contact support for further assistance.'
+      );
+    });
+
+    it('starts relocation job if form data is available from previous steps', async function () {
+      const mockapi = MockApiClient.addMockResponse({
+        url: `/relocations/`,
+        method: 'POST',
+      });
+      renderPage('get-started');
+      ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
+      const orgSlugsInput = await screen.getByLabelText('org-slugs');
+      const continueButton = await screen.getByRole('button', {name: 'Continue'});
+      await userEvent.type(orgSlugsInput, 'test-org');
+      await userEvent.type(screen.getByLabelText('region'), 'U');
+      await userEvent.click(screen.getByRole('menuitemradio'));
+      await userEvent.click(continueButton);
+      renderPage('upload-backup');
+      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
+      const input = screen.getByLabelText('file-upload');
+      await userEvent.upload(input, relocationFile);
+      await userEvent.click(await screen.findByText('Start Relocation'));
+      await waitFor(() => expect(mockapi).toHaveBeenCalled());
+      expect(addSuccessMessage).toHaveBeenCalledWith(
+        "Your relocation has started - we'll email you with updates as soon as we have 'em!"
+      );
+    });
+
+    it('throws error if user already has an in-progress relocation job', async function () {
+      const mockapi = MockApiClient.addMockResponse({
+        url: `/relocations/`,
+        method: 'POST',
+        statusCode: 409,
+      });
+      renderPage('get-started');
+      ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
+      const orgSlugsInput = screen.getByLabelText('org-slugs');
+      const continueButton = screen.getByRole('button', {name: 'Continue'});
+      await userEvent.type(orgSlugsInput, 'test-org');
+      await userEvent.type(screen.getByLabelText('region'), 'U');
+      await userEvent.click(screen.getByRole('menuitemradio'));
+      await userEvent.click(continueButton);
+      renderPage('upload-backup');
+      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
+      const input = screen.getByLabelText('file-upload');
+      await userEvent.upload(input, relocationFile);
+      await userEvent.click(await screen.findByText('Start Relocation'));
+      await waitFor(() => expect(mockapi).toHaveBeenCalled());
+      expect(addErrorMessage).toHaveBeenCalledWith(
+        'You already have an in-progress relocation job.'
+      );
+    });
+
+    it('throws error if daily limit of relocations has been reached', async function () {
+      const mockapi = MockApiClient.addMockResponse({
+        url: `/relocations/`,
+        method: 'POST',
+        statusCode: 429,
+      });
+      renderPage('get-started');
+      ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
+      const orgSlugsInput = screen.getByLabelText('org-slugs');
+      const continueButton = screen.getByRole('button', {name: 'Continue'});
+      await userEvent.type(orgSlugsInput, 'test-org');
+      await userEvent.type(screen.getByLabelText('region'), 'U');
+      await userEvent.click(screen.getByRole('menuitemradio'));
+      await userEvent.click(continueButton);
+      renderPage('upload-backup');
+      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
+      const input = screen.getByLabelText('file-upload');
+      await userEvent.upload(input, relocationFile);
+      await userEvent.click(await screen.findByText('Start Relocation'));
+      await waitFor(() => expect(mockapi).toHaveBeenCalled());
+      expect(addErrorMessage).toHaveBeenCalledWith(
+        'We have reached the daily limit of relocations - please try again tomorrow, or contact support.'
+      );
+    });
+
+    it('throws error if user session has expired', async function () {
+      const mockapi = MockApiClient.addMockResponse({
+        url: `/relocations/`,
+        method: 'POST',
+        statusCode: 401,
+      });
+      renderPage('get-started');
+      ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
+      const orgSlugsInput = screen.getByLabelText('org-slugs');
+      const continueButton = screen.getByRole('button', {name: 'Continue'});
+      await userEvent.type(orgSlugsInput, 'test-org');
+      await userEvent.type(screen.getByLabelText('region'), 'U');
+      await userEvent.click(screen.getByRole('menuitemradio'));
+      await userEvent.click(continueButton);
+      renderPage('upload-backup');
+      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
+      const input = screen.getByLabelText('file-upload');
+      await userEvent.upload(input, relocationFile);
+      await userEvent.click(await screen.findByText('Start Relocation'));
+      await waitFor(() => expect(mockapi).toHaveBeenCalled());
+      expect(addErrorMessage).toHaveBeenCalledWith('Your session has expired.');
+    });
+
+    it('throws error for 500 error', async function () {
+      const mockapi = MockApiClient.addMockResponse({
+        url: `/relocations/`,
+        method: 'POST',
+        statusCode: 500,
+      });
+      renderPage('get-started');
+      ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
+      const orgSlugsInput = screen.getByLabelText('org-slugs');
+      const continueButton = screen.getByRole('button', {name: 'Continue'});
+      await userEvent.type(orgSlugsInput, 'test-org');
+      await userEvent.type(screen.getByLabelText('region'), 'U');
+      await userEvent.click(screen.getByRole('menuitemradio'));
+      await userEvent.click(continueButton);
+      renderPage('upload-backup');
+      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
+      const input = screen.getByLabelText('file-upload');
+      await userEvent.upload(input, relocationFile);
+      await userEvent.click(await screen.findByText('Start Relocation'));
+      await waitFor(() => expect(mockapi).toHaveBeenCalled());
+      expect(addErrorMessage).toHaveBeenCalledWith(
+        'An error has occurred while trying to start relocation job. Please contact support for further assistance.'
+      );
     });
   });
 });

+ 7 - 0
static/app/views/relocation/relocation.tsx

@@ -23,6 +23,7 @@ import EncryptBackup from './encryptBackup';
 import GetStarted from './getStarted';
 import PublicKey from './publicKey';
 import {StepDescriptor} from './types';
+import UploadBackup from './uploadBackup';
 
 type RouteParams = {
   step: string;
@@ -50,6 +51,12 @@ function getOrganizationOnboardingSteps(): StepDescriptor[] {
       Component: EncryptBackup,
       cornerVariant: 'top-left',
     },
+    {
+      id: 'upload-backup',
+      title: t('Upload backup'),
+      Component: UploadBackup,
+      cornerVariant: 'top-left',
+    },
   ];
 }
 

+ 3 - 3
static/app/views/relocation/relocationOnboardingContext.tsx

@@ -4,7 +4,7 @@ import {useSessionStorage} from 'sentry/utils/useSessionStorage';
 
 type Data = {
   orgSlugs: string;
-  region: string;
+  regionUrl: string;
   file?: File;
 };
 
@@ -17,7 +17,7 @@ export const RelocationOnboardingContext =
   createContext<RelocationOnboardingContextProps>({
     data: {
       orgSlugs: '',
-      region: '',
+      regionUrl: '',
       file: undefined,
     },
     setData: () => {},
@@ -33,7 +33,7 @@ export function RelocationOnboardingContextProvider({children, value}: ProviderP
     'relocationOnboarding',
     {
       orgSlugs: value?.orgSlugs || '',
-      region: value?.region || '',
+      regionUrl: value?.regionUrl || '',
       file: value?.file || undefined,
     }
   );

+ 267 - 0
static/app/views/relocation/uploadBackup.tsx

@@ -0,0 +1,267 @@
+import {useContext, useRef, useState} from 'react';
+import styled from '@emotion/styled';
+import {motion} from 'framer-motion';
+
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import {Client} from 'sentry/api';
+import {Button} from 'sentry/components/button';
+import Well from 'sentry/components/well';
+import {IconFile, IconUpload} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import ConfigStore from 'sentry/stores/configStore';
+import {space} from 'sentry/styles/space';
+import testableTransition from 'sentry/utils/testableTransition';
+import useApi from 'sentry/utils/useApi';
+import StepHeading from 'sentry/views/relocation/components/stepHeading';
+import {RelocationOnboardingContext} from 'sentry/views/relocation/relocationOnboardingContext';
+
+import {StepProps} from './types';
+
+type UploadWellProps = {
+  centered: boolean;
+  draggedOver: boolean;
+  onDragEnter: Function;
+  onDragLeave: Function;
+  onDragOver: Function;
+  onDrop: Function;
+};
+
+const DEFAULT_ERROR_MSG = t(
+  'An error has occurred while trying to start relocation job. Please contact support for further assistance.'
+);
+const IN_PROGRESS_RELOCATION_ERROR_MSG = t(
+  'You already have an in-progress relocation job.'
+);
+const THROTTLED_RELOCATION_ERROR_MSG = t(
+  'We have reached the daily limit of relocations - please try again tomorrow, or contact support.'
+);
+const SESSION_EXPIRED_ERROR_MSG = t('Your session has expired.');
+
+export function UploadBackup(__props: StepProps) {
+  const api = useApi({
+    api: new Client({headers: {Accept: 'application/json; charset=utf-8'}}),
+  });
+  const [file, setFile] = useState<File>();
+  const [dragCounter, setDragCounter] = useState(0);
+  const inputFileRef = useRef<HTMLInputElement>(null);
+  const relocationOnboardingContext = useContext(RelocationOnboardingContext);
+  const user = ConfigStore.get('user');
+
+  const handleDragEnter = event => {
+    event.preventDefault();
+    setDragCounter(dragCounter + 1);
+  };
+
+  const handleDragLeave = () => {
+    setDragCounter(dragCounter - 1);
+  };
+
+  const handleDrop = event => {
+    event.preventDefault();
+    setDragCounter(0);
+
+    setFile(event.dataTransfer.files[0]);
+  };
+
+  const handleFileChange = event => {
+    const newFile = event.target.files?.[0];
+
+    // No file selected (e.g. user clicked "cancel")
+    if (!newFile) {
+      return;
+    }
+
+    setFile(newFile);
+  };
+
+  const onFileUploadLinkClick = () => {
+    inputFileRef.current && inputFileRef.current.click();
+  };
+
+  const handleStartRelocation = async () => {
+    const {orgSlugs, regionUrl} = relocationOnboardingContext.data;
+    if (!orgSlugs || !regionUrl || !file) {
+      addErrorMessage(DEFAULT_ERROR_MSG);
+      return;
+    }
+    const formData = new FormData();
+    formData.set('orgs', orgSlugs);
+    formData.set('file', file);
+    formData.set('owner', user.email);
+    try {
+      await api.requestPromise(`/relocations/`, {
+        method: 'POST',
+        host: regionUrl,
+        data: formData,
+      });
+
+      addSuccessMessage(
+        t(
+          "Your relocation has started - we'll email you with updates as soon as we have 'em!"
+        )
+      );
+    } catch (error) {
+      if (error.status === 409) {
+        addErrorMessage(IN_PROGRESS_RELOCATION_ERROR_MSG);
+      } else if (error.status === 429) {
+        addErrorMessage(THROTTLED_RELOCATION_ERROR_MSG);
+      } else if (error.status === 401) {
+        addErrorMessage(SESSION_EXPIRED_ERROR_MSG);
+      } else {
+        addErrorMessage(DEFAULT_ERROR_MSG);
+      }
+    }
+  };
+
+  return (
+    <Wrapper>
+      <StepHeading step={4}>
+        {t('Upload Tarball to begin the relocation process')}
+      </StepHeading>
+      <motion.div
+        transition={testableTransition()}
+        variants={{
+          initial: {y: 30, opacity: 0},
+          animate: {y: 0, opacity: 1},
+          exit: {opacity: 0},
+        }}
+      >
+        <p>
+          {t(
+            'Nearly done! The file is being uploaded to sentry for the relocation process. You can close this tab if you like. We will email  when complete.'
+          )}
+        </p>
+        {file ? (
+          <FinishedWell centered>
+            <IconFile className="file-icon" size="xl" />
+            <div>
+              <p>{file.name}</p>
+              <a onClick={() => setFile(undefined)}>{t('Remove file')}</a>
+            </div>
+            <StartRelocationButton
+              size="md"
+              priority="primary"
+              onClick={handleStartRelocation}
+              icon={<IconUpload className="upload-icon" size="xs" />}
+            >
+              {t('Start Relocation')}
+            </StartRelocationButton>
+          </FinishedWell>
+        ) : (
+          <UploadWell
+            onDragEnter={handleDragEnter}
+            onDragOver={event => event.preventDefault()}
+            onDragLeave={handleDragLeave}
+            onDrop={handleDrop}
+            centered
+            aria-label="dropzone"
+            draggedOver={dragCounter > 0}
+          >
+            <StyledUploadIcon className="upload-icon" size="xl" />
+            <UploadWrapper>
+              <p>{t('Drag and Drop file here or')}</p>
+              <a onClick={onFileUploadLinkClick}>{t('Choose file')}</a>
+              <UploadInput
+                name="file"
+                type="file"
+                aria-label="file-upload"
+                accept=".tar"
+                ref={inputFileRef}
+                onChange={e => handleFileChange(e)}
+                hidden
+                title=""
+              />
+            </UploadWrapper>
+          </UploadWell>
+        )}
+      </motion.div>
+    </Wrapper>
+  );
+}
+
+export default UploadBackup;
+
+const StyledUploadIcon = styled(IconUpload)`
+  margin-top: ${space(2)};
+  margin-bottom: ${space(1)};
+`;
+
+const Wrapper = styled('div')`
+  max-width: 769px;
+  max-height: 525px;
+  margin-left: auto;
+  margin-right: auto;
+  padding: ${space(4)};
+  background-color: ${p => p.theme.surface400};
+  z-index: 100;
+  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.05);
+  border-radius: 10px;
+  width: 100%;
+  font-size: 16px;
+  color: ${p => p.theme.gray300};
+  mark {
+    border-radius: 8px;
+    padding: ${space(0.25)} ${space(0.5)} ${space(0.25)} ${space(0.5)};
+    background: ${p => p.theme.gray100};
+    margin-right: ${space(1)};
+  }
+  h2 {
+    color: ${p => p.theme.gray500};
+  }
+  p {
+    margin-bottom: ${space(1)};
+  }
+  .encrypt-help {
+    color: ${p => p.theme.gray500};
+  }
+`;
+
+const StartRelocationButton = styled(Button)`
+  margin-left: auto;
+`;
+
+const FinishedWell = styled(Well)`
+  display: flex;
+  align-items: center;
+  text-align: left;
+  div {
+    margin-left: ${space(2)};
+    line-height: 1;
+  }
+  a {
+    color: ${p => p.theme.translucentGray200};
+    font-size: 14px;
+  }
+  a:hover {
+    color: ${p => p.theme.gray300};
+  }
+`;
+
+const UploadWell = styled(Well)<UploadWellProps>`
+  margin-top: ${space(2)};
+  height: 140px;
+  border-style: ${props => (props.draggedOver ? 'solid' : 'dashed')};
+  border-width: medium;
+  align-items: center;
+  .file-icon,
+  .upload-icon {
+    color: ${p => p.theme.gray500};
+  }
+  background: ${props =>
+    props.draggedOver ? p => p.theme.purple100 : p => p.theme.surface400};
+`;
+
+const UploadInput = styled('input')`
+  opacity: 0;
+`;
+
+const UploadWrapper = styled('div')`
+  display: flex;
+  justify-content: center;
+  a {
+    padding-left: ${space(0.5)};
+  }
+  input[type='file'] {
+    display: none;
+  }
+`;