diff --git a/packages/design/src/ButtonIcon/ButtonIcon.jsx b/packages/design/src/ButtonIcon/ButtonIcon.jsx index 886b254f8..dcc47035f 100644 --- a/packages/design/src/ButtonIcon/ButtonIcon.jsx +++ b/packages/design/src/ButtonIcon/ButtonIcon.jsx @@ -50,8 +50,9 @@ const fromProps = props => { return { '&:disabled': { color: theme.colors.action.disabled, + cursor: 'default', }, - '&:hover, &:focus': { + '&:hover:enabled, &:focus:enabled': { background: theme.colors.action.hover, }, }; diff --git a/packages/design/src/theme/theme.js b/packages/design/src/theme/theme.js index 4d409e6a0..705479f31 100644 --- a/packages/design/src/theme/theme.js +++ b/packages/design/src/theme/theme.js @@ -32,6 +32,7 @@ const contrastThreshold = 3; const colors = { accent: '#651FFF', + progressBarColor: '#00BFA5', dark: '#000', diff --git a/packages/design/src/utils/testing.tsx b/packages/design/src/utils/testing.tsx index 4d14badd3..7c584a79a 100644 --- a/packages/design/src/utils/testing.tsx +++ b/packages/design/src/utils/testing.tsx @@ -23,6 +23,7 @@ import { screen, prettyDOM, getByTestId, + waitForElementToBeRemoved, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter as Router } from 'react-router-dom'; @@ -60,4 +61,5 @@ export { getByTestId, Router, userEvent, + waitForElementToBeRemoved, }; diff --git a/packages/shared/components/FileTransfer/FileTransfer.test.tsx b/packages/shared/components/FileTransfer/FileTransfer.test.tsx new file mode 100644 index 000000000..60da0278d --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransfer.test.tsx @@ -0,0 +1,170 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + act, + fireEvent, + render, + screen, + waitForElementToBeRemoved, +} from 'design/utils/testing'; + +import { FileTransfer, TransferHandlers } from './FileTransfer'; +import { FileTransferContextProvider } from './FileTransferContextProvider'; +import { FileTransferDialogDirection } from './FileTransferStateless'; +import { createFileTransferEventsEmitter } from './createFileTransferEventsEmitter'; + +test('click opens correct dialog', () => { + render( + + + + ); + expect(screen.getByText('Download Files')).toBeInTheDocument(); +}); + +test('downloads component changes when file transfer callbacks are called', async () => { + const fileTransferEvents = createFileTransferEventsEmitter(); + + const handler: TransferHandlers = { + getDownloader: async () => fileTransferEvents, + getUploader: async () => undefined, + }; + render( + + + + ); + fireEvent.change(screen.getByLabelText('File Path'), { + target: { value: '/Users/g/file.txt' }, + }); + fireEvent.click(screen.getByText('Download')); + const listItem = await screen.findByRole('listitem'); + expect(listItem).toHaveTextContent('/Users/g/file.txt'); + + act(() => fileTransferEvents.emitProgress(50)); + expect(listItem).toHaveTextContent('50%'); + + act(() => fileTransferEvents.emitComplete()); + expect(listItem).toContainElement(screen.getByTitle('Transfer completed')); + + act(() => fileTransferEvents.emitError(new Error('Network error'))); + expect(listItem).toHaveTextContent('Network error'); +}); + +test('onAbort is called when user cancels upload', async () => { + let abortControllerMock: AbortController; + + const handler: TransferHandlers = { + getDownloader: async (_, abortController) => { + abortControllerMock = abortController; + return createFileTransferEventsEmitter(); + }, + getUploader: async () => undefined, + }; + render( + + + + ); + fireEvent.change(screen.getByLabelText('File Path'), { + target: { value: '/Users/g/file.txt' }, + }); + fireEvent.click(screen.getByText('Download')); + fireEvent.click(await screen.findByTitle('Cancel')); + expect(abortControllerMock.signal.aborted).toBeTruthy(); +}); + +test('file is not added when transferHandler does not return anything', async () => { + const handler: TransferHandlers = { + getDownloader: async () => undefined, + getUploader: async () => undefined, + }; + const filePath = '/Users/g/file.txt'; + + render( + + + + ); + fireEvent.change(screen.getByLabelText('File Path'), { + target: { value: filePath }, + }); + fireEvent.click(screen.getByText('Download')); + expect(screen.queryByText('/Users/g/file.txt')).not.toBeInTheDocument(); +}); + +describe('handleAfterClose', () => { + const getSetup = async () => { + const handleBeforeClose = jest.fn(); + const handleAfterClose = jest.fn(); + const handler: TransferHandlers = { + getDownloader: async () => createFileTransferEventsEmitter(), + getUploader: async () => undefined, + }; + + render( + + + + ); + + fireEvent.change(screen.getByLabelText('File Path'), { + target: { value: '~/abc' }, + }); + + fireEvent.click(screen.getByText('Download')); + await screen.findByRole('listitem'); + + return { handleBeforeClose, handleAfterClose }; + }; + + test('is not called when closing the dialog has been aborted', async () => { + const { handleBeforeClose, handleAfterClose } = await getSetup(); + handleBeforeClose.mockReturnValue(Promise.resolve(false)); + fireEvent.click(screen.getByTitle('Close')); + expect(handleBeforeClose).toHaveBeenCalled(); + expect(handleAfterClose).not.toHaveBeenCalled(); + }); + + test('is called when closing the dialog has been confirmed', async () => { + const { handleBeforeClose, handleAfterClose } = await getSetup(); + handleBeforeClose.mockReturnValue(Promise.resolve(true)); + fireEvent.click(screen.getByTitle('Close')); + expect(handleBeforeClose).toHaveBeenCalled(); + + // wait for dialog to close + await waitForElementToBeRemoved(() => + screen.queryByTestId('file-transfer-container') + ); + expect(handleAfterClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/components/FileTransfer/FileTransfer.tsx b/packages/shared/components/FileTransfer/FileTransfer.tsx new file mode 100644 index 000000000..5ad29cd07 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransfer.tsx @@ -0,0 +1,135 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { useFileTransferContext } from './FileTransferContextProvider'; +import { useFilesStore } from './useFilesStore'; +import { + FileTransferDialogDirection, + FileTransferListeners, + FileTransferStateless, +} from './FileTransferStateless'; + +interface FileTransferProps { + backgroundColor?: string; + transferHandlers: TransferHandlers; + + /** + * `beforeClose` is called when an attempt to close the dialog was made + * and there is a file transfer in progress. + * Returning `true` will close the dialog, returning `false` will not. + */ + beforeClose?(): Promise | boolean; + + afterClose?(): void; +} + +/** + * Both `getDownloader` and `getUploader` can return a promise containing `FileTransferListeners` function or nothing. + * In the latter case, the file will not be added to the list and the download will not start. + */ +export interface TransferHandlers { + getDownloader: ( + sourcePath: string, + abortController: AbortController + ) => Promise; + getUploader: ( + destinationPath: string, + file: File, + abortController: AbortController + ) => Promise; +} + +export function FileTransfer(props: FileTransferProps) { + const { openedDialog, closeDialog } = useFileTransferContext(); + + async function handleCloseDialog( + isAnyTransferInProgress: boolean + ): Promise { + const runCloseCallbacks = () => { + closeDialog(); + props.afterClose?.(); + }; + + if (!isAnyTransferInProgress || !props.beforeClose) { + runCloseCallbacks(); + return; + } + + if (await props.beforeClose()) { + runCloseCallbacks(); + } + } + + if (!openedDialog) { + return null; + } + + return ( + + ); +} + +export function FileTransferDialog( + props: Pick & { + openedDialog: FileTransferDialogDirection; + onCloseDialog(isAnyTransferInProgress: boolean): void; + } +) { + const filesStore = useFilesStore(); + + function handleAddDownload(sourcePath: string): void { + filesStore.start({ + name: sourcePath, + runFileTransfer: abortController => + props.transferHandlers.getDownloader(sourcePath, abortController), + }); + } + + function handleAddUpload(destinationPath: string, file: File): void { + filesStore.start({ + name: file.name, + runFileTransfer: abortController => + props.transferHandlers.getUploader( + destinationPath, + file, + abortController + ), + }); + } + + function handleClose(): void { + props.onCloseDialog(filesStore.isAnyTransferInProgress()); + } + + return ( + + ); +} diff --git a/packages/shared/components/FileTransfer/FileTransferActionBar.tsx b/packages/shared/components/FileTransfer/FileTransferActionBar.tsx new file mode 100644 index 000000000..8abee0c61 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferActionBar.tsx @@ -0,0 +1,54 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Flex, ButtonIcon } from 'design'; +import * as Icons from 'design/Icon'; + +import { useFileTransferContext } from './FileTransferContextProvider'; + +type FileTransferActionBarProps = { + isConnected: boolean; +}; + +export function FileTransferActionBar({ + isConnected, +}: FileTransferActionBarProps) { + const fileTransferContext = useFileTransferContext(); + const areFileTransferButtonsDisabled = + fileTransferContext.openedDialog || !isConnected; + + return ( + + + + + + + + + ); +} diff --git a/packages/shared/components/FileTransfer/FileTransferContextProvider.tsx b/packages/shared/components/FileTransfer/FileTransferContextProvider.tsx new file mode 100644 index 000000000..837ca6ef9 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferContextProvider.tsx @@ -0,0 +1,70 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useContext, useState, FC } from 'react'; + +import { FileTransferDialogDirection } from './FileTransferStateless'; + +const FileTransferContext = React.createContext<{ + openedDialog: FileTransferDialogDirection; + openDownloadDialog(): void; + openUploadDialog(): void; + closeDialog(): void; +}>(null); + +export const FileTransferContextProvider: FC<{ + openedDialog?: FileTransferDialogDirection; +}> = props => { + const [openedDialog, setOpenedDialog] = useState< + FileTransferDialogDirection | undefined + >(props.openedDialog); + + function openDownloadDialog(): void { + setOpenedDialog(FileTransferDialogDirection.Download); + } + + function openUploadDialog(): void { + setOpenedDialog(FileTransferDialogDirection.Upload); + } + + function closeDialog(): void { + setOpenedDialog(undefined); + } + + return ( + + ); +}; + +export const useFileTransferContext = () => { + const context = useContext(FileTransferContext); + + if (!context) { + throw new Error( + 'FileTransfer requires FileTransferContextProvider context.' + ); + } + + return context; +}; diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/CommonElements.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/CommonElements.tsx new file mode 100644 index 000000000..785b15c9d --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/CommonElements.tsx @@ -0,0 +1,66 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { forwardRef } from 'react'; +import styled from 'styled-components'; + +import FieldInput from 'shared/components/FieldInput'; +import { requiredField } from 'shared/components/Validation/rules'; +import Validation from 'shared/components/Validation'; + +export const Form = styled.form.attrs(() => ({ + 'aria-label': 'form', +}))``; + +export const PathInput = forwardRef< + HTMLInputElement, + React.ComponentProps +>((props, ref) => { + function moveCaretAtEnd(e: React.ChangeEvent): void { + const tmp = e.target.value; + e.target.value = ''; + e.target.value = tmp; + } + + return ( + + {({ validator }) => ( + validator.validate()} + rule={requiredField('Path is required')} + /> + )} + + ); +}); + +const StyledFieldInput = styled(FieldInput)` + input { + border: 1px solid rgba(255, 255, 255, 0.1); + background: transparent; + color: white; + box-shadow: none; + font-size: 14px; + height: 32px; + } +`; diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/DownloadForm.test.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/DownloadForm.test.tsx new file mode 100644 index 000000000..5bc06c084 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/DownloadForm.test.tsx @@ -0,0 +1,57 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { fireEvent, render, screen } from 'design/utils/testing'; + +import { DownloadForm } from './DownloadForm'; + +function getFilePathInput(): HTMLElement { + return screen.getByLabelText('File Path'); +} + +test('button is disabled when path does not point to file', () => { + render( {}} />); + fireEvent.change(getFilePathInput(), { + target: { value: '/Users/' }, + }); + + expect(screen.getByTitle('Download')).toBeDisabled(); +}); + +test('button is enabled when path points to file', () => { + render( {}} />); + fireEvent.change(getFilePathInput(), { + target: { value: '/Users/file.txt' }, + }); + + expect(screen.getByTitle('Download')).toBeEnabled(); +}); + +test('onAddDownload is invoked when the from is submitted', () => { + const handleAddDownload = jest.fn(); + const filePath = '/Users/file.txt'; + + render(); + + fireEvent.change(getFilePathInput(), { + target: { value: filePath }, + }); + expect(screen.getByTitle('Download')).toBeEnabled(); + + fireEvent.submit(screen.getByRole('form')); + expect(handleAddDownload).toHaveBeenCalledWith(filePath); +}); diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/DownloadForm.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/DownloadForm.tsx new file mode 100644 index 000000000..97d10ed9d --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/DownloadForm.tsx @@ -0,0 +1,62 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { Flex } from 'design'; +import { ButtonPrimary } from 'design/Button'; + +import { Form, PathInput } from '../CommonElements'; + +interface DownloadFormProps { + onAddDownload(sourcePath: string): void; +} + +export function DownloadForm(props: DownloadFormProps) { + const [sourcePath, setSourcePath] = useState('~/'); + const isSourcePathValid = !sourcePath.endsWith('/'); + + function download(): void { + props.onAddDownload(sourcePath); + } + + return ( +
{ + e.preventDefault(); + download(); + }} + > + + setSourcePath(e.target.value)} + value={sourcePath} + /> + + Download + + +
+ ); +} diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/index.ts b/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/index.ts new file mode 100644 index 000000000..483a37508 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './DownloadForm'; diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileList.test.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileList.test.tsx new file mode 100644 index 000000000..cab3fd198 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileList.test.tsx @@ -0,0 +1,58 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { fireEvent, render, screen } from 'design/utils/testing'; + +import { TransferredFile } from '../types'; + +import { FileList } from './FileList'; + +const files: TransferredFile[] = [ + { + id: '1', + name: '~/mona-lisa.jpg', + transferState: { type: 'processing', progress: 0 }, + }, + { + id: '2', + name: '~/time.jpg', + transferState: { type: 'processing', progress: 1 }, + }, +]; + +test('list items are rendered', () => { + render(); + const [listItem] = screen.getAllByRole('listitem'); + expect(listItem).toHaveTextContent('~/mona-lisa.jpg'); + expect(listItem).toHaveTextContent('0%'); +}); + +test('transfer is cancelled when component unmounts', () => { + const handleCancel = jest.fn(); + const { unmount } = render( + + ); + unmount(); + expect(handleCancel).toHaveBeenCalledTimes(1); +}); + +test('transfer is cancelled when user clicks Cancel button', () => { + const handleCancel = jest.fn(); + render(); + fireEvent.click(screen.getByTitle('Cancel')); + expect(handleCancel).toHaveBeenCalledTimes(1); +}); diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileList.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileList.tsx new file mode 100644 index 000000000..33fd8a65d --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileList.tsx @@ -0,0 +1,53 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import styled from 'styled-components'; + +import { TransferredFile } from '../types'; + +import { FileListItem } from './FileListItem'; + +interface FileListProps { + files: TransferredFile[]; + + onCancel(id: string): void; +} + +export function FileList(props: FileListProps) { + if (!props.files.length) { + return null; + } + + return ( +
    + {props.files.map(file => ( + + ))} +
+ ); +} + +const Ul = styled.ul` + padding-left: 0; + overflow: auto; + max-height: 300px; + margin-top: 0; + margin-bottom: 0; + // scrollbars + padding-right: 16px; + margin-right: -16px; +`; diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileListItem.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileListItem.tsx new file mode 100644 index 000000000..391141b72 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileListItem.tsx @@ -0,0 +1,126 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useEffect } from 'react'; +import styled from 'styled-components'; +import { Box, ButtonIcon, Flex, Text } from 'design'; +import { CircleCheck, Cross, Warning } from 'design/Icon'; + +import { TransferredFile } from '../types'; + +type FileListItemProps = { + file: TransferredFile; + onCancel(id: string): void; +}; + +export function FileListItem(props: FileListItemProps) { + const { name, transferState, id } = props.file; + + useEffect(() => { + return () => props.onCancel(id); + }, [props.onCancel]); + + return ( +
  • + + + + + {name} + + {transferState.type === 'completed' && ( + + )} + + {transferState.type === 'processing' && ( + props.onCancel(id)} + > + + + )} + + {(transferState.type === 'processing' || + transferState.type === 'error') && ( + + + {transferState.progress}% + + + + + + )} + + {transferState.type === 'error' && ( + {transferState.error.message} + )} +
  • + ); +} + +const Error: FC = props => { + return ( + + + {props.children} + + ); +}; + +const ProgressPercentage = styled(Text)` + line-height: 14px; + width: 36px; +`; + +const Li = styled.li` + list-style: none; + margin-top: ${props => props.theme.space[3]}px; + font-size: ${props => props.theme.fontSizes[1]}px; +`; + +const ProgressBackground = styled.div` + border-radius: 50px; + background: rgba(255, 255, 255, 0.05); + width: 100%; +`; + +const ProgressIndicator = styled.div` + border-radius: 50px; + background: ${props => + props.isFailure + ? props.theme.colors.disabled + : props.theme.colors.progressBarColor}; + + height: 8px; + width: ${props => props.progress}%; +`; diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/FileList/index.ts b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/index.ts new file mode 100644 index 000000000..87be85f3c --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { FileList } from './FileList'; diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.story.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.story.tsx new file mode 100644 index 000000000..b3206f599 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.story.tsx @@ -0,0 +1,184 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { + FileTransferStateless, + FileTransferStatelessProps, +} from './FileTransferStateless'; +import { FileTransferDialogDirection, TransferredFile } from './types'; + +export default { + title: 'Shared/FileTransfer', +}; + +const defaultFiles: TransferredFile[] = [ + { + id: '1547581437406~/test', + name: '~/Users/grzegorz/Makefile', + transferState: { + type: 'processing', + progress: 10, + }, + }, + { + id: '1547581437406~/test', + name: '~Users/grzegorz/very/long/path/that/does/not/exist/but/is/very/useful/for/storybook/stories', + transferState: { + type: 'processing', + progress: 64, + }, + }, +]; + +function GetFileTransfer( + props: Pick +) { + return ( + undefined} + onAddDownload={() => undefined} + onAddUpload={() => undefined} + onCancel={() => undefined} + /> + ); +} + +export const DownloadProgress = () => ( + +); + +export const DownloadError = () => ( + +); + +export const DownloadCompleted = () => ( + +); + +export const DownloadLongList = () => ( + +); + +export const UploadProgress = () => ( + +); + +export const UploadError = () => ( + +); + +export const UploadCompleted = () => ( + +); diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.tsx new file mode 100644 index 000000000..3d4573179 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.tsx @@ -0,0 +1,99 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { ButtonIcon, Flex, Text } from 'design'; +import { Close as CloseIcon } from 'design/Icon'; + +import { FileTransferDialogDirection, TransferredFile } from './types'; +import { DownloadForm } from './DownloadForm'; +import { UploadForm } from './UploadForm'; +import { FileList } from './FileList'; + +export interface FileTransferStatelessProps { + openedDialog: FileTransferDialogDirection; + files: TransferredFile[]; + backgroundColor?: string; + + onClose(): void; + + onAddDownload(sourcePath: string): void; + + onAddUpload(destinationPath: string, file: File): void; + + onCancel(id: string): void; +} + +export function FileTransferStateless(props: FileTransferStatelessProps) { + const items = + props.openedDialog === FileTransferDialogDirection.Download + ? { + header: 'Download Files', + Form: , + } + : { + header: 'Upload Files', + Form: , + }; + + return ( + { + if (e.key !== 'Escape') { + return; + } + + e.preventDefault(); + e.stopPropagation(); + props.onClose(); + }} + > + + + {items.header} + + + + {items.Form} + + + ); +} + +function ButtonClose(props: { onClick(): void }) { + return ( + + + + ); +} + +const Container = styled.div` + background: ${props => + props.backgroundColor || props.theme.colors.primary.light}; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + box-sizing: border-box; + border-radius: ${props => props.theme.radii[2]}px; + padding: 8px 16px 16px; + position: absolute; + right: 8px; + top: 8px; + width: 500px; + z-index: 10; +`; diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/UploadForm.test.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/UploadForm.test.tsx new file mode 100644 index 000000000..e436c2bda --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/UploadForm.test.tsx @@ -0,0 +1,69 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { fireEvent, render, screen } from 'design/utils/testing'; + +import { UploadForm } from './UploadForm'; + +function getUploadDestinationInput(): HTMLElement { + return screen.getByLabelText('Upload destination'); +} + +function getFileInput(): HTMLElement { + return screen.getByTestId('file-input'); +} + +const files = [ + new File(['(⌐□_□)'], 'chuck-norris.png', { type: 'image/png' }), + new File(['(⌐□_□)'], 'tommy-lee.png', { type: 'image/png' }), +]; + +test('file input is disabled when path is empty', () => { + render( {}} />); + fireEvent.change(getUploadDestinationInput(), { + target: { value: '' }, + }); + expect(getFileInput()).toBeDisabled(); +}); + +test('files can be selected using input', () => { + const handleAddUpload = jest.fn(); + + render(); + + fireEvent.change(getFileInput(), { + target: { files }, + }); + + expect(handleAddUpload).toHaveBeenCalledTimes(2); + expect(handleAddUpload).toHaveBeenCalledWith('~/', files[0]); + expect(handleAddUpload).toHaveBeenCalledWith('~/', files[1]); +}); + +test('files can be dropped into upload area', () => { + const handleAddUpload = jest.fn(); + + render(); + + fireEvent.drop(screen.getByText('Drag your files here'), { + dataTransfer: { files }, + }); + + expect(handleAddUpload).toHaveBeenCalledTimes(2); + expect(handleAddUpload).toHaveBeenCalledWith('~/', files[0]); + expect(handleAddUpload).toHaveBeenCalledWith('~/', files[1]); +}); diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/UploadForm.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/UploadForm.tsx new file mode 100644 index 000000000..516b607b4 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/UploadForm.tsx @@ -0,0 +1,141 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useRef, useState } from 'react'; +import styled from 'styled-components'; +import { Text } from 'design'; +import { NoteAdded } from 'design/Icon'; + +import { PathInput, Form } from '../CommonElements'; + +interface UploadFormProps { + onAddUpload(destinationPath: string, file: File): void; +} + +export function UploadForm(props: UploadFormProps) { + const dropzoneRef = useRef(); + const fileSelectorRef = useRef(); + const [destinationPath, setDestinationPath] = useState('~/'); + + function onFileSelected(e: React.ChangeEvent): void { + upload(Array.from(e.target.files)); + } + + function upload(files: File[]): void { + files.forEach(file => { + props.onAddUpload(destinationPath, file); + }); + } + + function openFilePicker(): void { + // reset all selected files + fileSelectorRef.current.value = ''; + fileSelectorRef.current.click(); + } + + function handleDrop(e: React.DragEvent): void { + removeDropzoneStyle(e); + + const { files } = e.dataTransfer; + e.preventDefault(); + e.stopPropagation(); + upload(Array.from(files)); + } + + function handleKeyDown(event: React.KeyboardEvent): void { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + openFilePicker(); + } + } + + function addDropzoneStyle(e: React.DragEvent): void { + e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.1)'; + } + + function removeDropzoneStyle(e: React.DragEvent): void { + e.currentTarget.style.removeProperty('background-color'); + } + + const isUploadDisabled = !destinationPath; + + return ( +
    + setDestinationPath(e.target.value)} + onKeyDown={handleKeyDown} + /> + + { + e.preventDefault(); + addDropzoneStyle(e); + }} + onDragLeave={removeDropzoneStyle} + onDrop={handleDrop} + onClick={e => { + e.preventDefault(); + openFilePicker(); + }} + > + + Drag your files here + + or Browse your computer to start uploading + + + + ); +} + +const Dropzone = styled.button` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + color: inherit; + background-color: rgba(255, 255, 255, 0.05); + margin-top: ${props => props.theme.space[3]}px; + border: 1px dashed rgba(255, 255, 255, 0.1); + height: 128px; + text-align: center; + cursor: pointer; + opacity: ${props => (props.disabled ? 0.7 : 1)}; + pointer-events: ${props => (props.disabled ? 'none' : 'unset')}; + border-radius: ${props => props.theme.radii[2]}px; + + :focus { + border-color: ${props => props.theme.colors.action.selected}; + } +`; diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/index.ts b/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/index.ts new file mode 100644 index 000000000..9136c0af9 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './UploadForm'; diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/index.ts b/packages/shared/components/FileTransfer/FileTransferStateless/index.ts new file mode 100644 index 000000000..7aee508d0 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './types'; +export * from './FileTransferStateless'; diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/types.ts b/packages/shared/components/FileTransfer/FileTransferStateless/types.ts new file mode 100644 index 000000000..4c9ec5ea3 --- /dev/null +++ b/packages/shared/components/FileTransfer/FileTransferStateless/types.ts @@ -0,0 +1,37 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type TransferState = + | { type: 'processing'; progress: number } + | { type: 'error'; error: Error; progress: number } + | { type: 'completed' }; + +export type TransferredFile = { + id: string; + name: string; + transferState: TransferState; +}; + +export type FileTransferListeners = { + onProgress(callback: (percentage: number) => void): void; + onError(callback: (error: Error) => void): void; + onComplete(callback: () => void): void; +}; + +export enum FileTransferDialogDirection { + Download = 'Download', + Upload = 'Upload', +} diff --git a/packages/shared/components/FileTransfer/createFileTransferEventsEmitter.ts b/packages/shared/components/FileTransfer/createFileTransferEventsEmitter.ts new file mode 100644 index 000000000..efae1cfe6 --- /dev/null +++ b/packages/shared/components/FileTransfer/createFileTransferEventsEmitter.ts @@ -0,0 +1,55 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter } from 'events'; + +import { FileTransferListeners } from './FileTransferStateless/types'; + +export interface FileTransferEventsEmitter extends FileTransferListeners { + emitProgress(progress: number): void; + + emitError(error: Error): void; + + emitComplete(): void; +} + +/** + * `createFileTransferEventsEmitter` is a utility function that helps with + * generating events that can be consumed by a function expecting `FileTransferListeners`. + */ +export function createFileTransferEventsEmitter(): FileTransferEventsEmitter { + const events = new EventEmitter(); + return { + emitProgress: progress => { + events.emit('progress', progress); + }, + emitComplete: () => { + events.emit('complete'); + }, + emitError: error => { + events.emit('error', error); + }, + onProgress: callback => { + events.on('progress', callback); + }, + onComplete: callback => { + events.on('complete', callback); + }, + onError: callback => { + events.on('error', callback); + }, + }; +} diff --git a/packages/shared/components/FileTransfer/index.ts b/packages/shared/components/FileTransfer/index.ts new file mode 100644 index 000000000..01be5f8f4 --- /dev/null +++ b/packages/shared/components/FileTransfer/index.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { FileTransfer } from './FileTransfer'; +export { + FileTransferContextProvider, + useFileTransferContext, +} from './FileTransferContextProvider'; +export type { FileTransferListeners } from './FileTransferStateless/types'; +export { FileTransferActionBar } from './FileTransferActionBar'; +export * from './createFileTransferEventsEmitter'; diff --git a/packages/shared/components/FileTransfer/useFilesStore.ts b/packages/shared/components/FileTransfer/useFilesStore.ts new file mode 100644 index 000000000..2931121e3 --- /dev/null +++ b/packages/shared/components/FileTransfer/useFilesStore.ts @@ -0,0 +1,166 @@ +/** + * Copyright 2022 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useMemo, useReducer, useRef } from 'react'; + +import { + FileTransferListeners, + TransferredFile, + TransferState, +} from './FileTransferStateless'; + +type FilesStoreState = { + ids: string[]; + filesById: Record; +}; + +type FilesStoreActions = + | { + type: 'add'; + payload: Pick; + } + | { + type: 'updateTransferState'; + payload: { + id: string; + transferState: TransferState; + }; + }; + +const initialState: FilesStoreState = { + ids: [], + filesById: {}, +}; + +function reducer( + state: typeof initialState, + action: FilesStoreActions +): typeof initialState { + switch (action.type) { + case 'add': { + return { + ids: [...state.ids, action.payload.id], + filesById: { + ...state.filesById, + [action.payload.id]: { + ...action.payload, + transferState: { type: 'processing', progress: 0 }, + }, + }, + }; + } + case 'updateTransferState': { + const getNextTransferState = (): TransferState => { + if (action.payload.transferState.type === 'error') { + const { transferState: currentTransferState } = + state.filesById[action.payload.id]; + return { + ...action.payload.transferState, + progress: + currentTransferState.type === 'processing' + ? currentTransferState.progress + : 0, + }; + } + return action.payload.transferState; + }; + + return { + ...state, + filesById: { + ...state.filesById, + [action.payload.id]: { + ...state.filesById[action.payload.id], + transferState: getNextTransferState(), + }, + }, + }; + } + default: + throw new Error('Unhandled action', action); + } +} + +export const useFilesStore = () => { + const [state, dispatch] = useReducer(reducer, initialState); + const abortControllers = useRef(new Map()); + + const start = async (options: { + name: string; + runFileTransfer( + abortController: AbortController + ): Promise; + }) => { + const abortController = new AbortController(); + const fileTransfer = await options.runFileTransfer(abortController); + + if (!fileTransfer) { + return; + } + + const id = new Date().getTime() + options.name; + + dispatch({ type: 'add', payload: { id, name: options.name } }); + abortControllers.current.set(id, abortController); + + fileTransfer.onProgress(progress => { + updateTransferState(id, { + type: 'processing', + progress, + }); + }); + fileTransfer.onError(error => { + updateTransferState(id, { + type: 'error', + progress: undefined, + error, + }); + }); + fileTransfer.onComplete(() => { + updateTransferState(id, { + type: 'completed', + }); + }); + }; + + const updateTransferState = useCallback( + (id: string, transferState: TransferState) => { + dispatch({ type: 'updateTransferState', payload: { id, transferState } }); + }, + [] + ); + + const cancel = useCallback((id: string) => { + abortControllers.current?.get(id).abort(); + }, []); + + const files = useMemo( + () => state.ids.map(id => state.filesById[id]), + [state.ids, state.filesById] + ); + + const isAnyTransferInProgress = useCallback( + () => files.some(file => file.transferState.type === 'processing'), + [files] + ); + + return { + files, + start, + cancel, + isAnyTransferInProgress, + }; +}; diff --git a/packages/teleport/src/Console/DocumentSsh/ActionBar/ActionBar.tsx b/packages/teleport/src/Console/DocumentSsh/ActionBar/ActionBar.tsx deleted file mode 100644 index 61d57cbcf..000000000 --- a/packages/teleport/src/Console/DocumentSsh/ActionBar/ActionBar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import * as Icons from 'design/Icon'; -import { Flex, ButtonIcon } from 'design'; - -export default function ActionBar({ - isConnected, - isDownloadOpen, - isUploadOpen, - onOpenDownload, - onOpenUpload, -}: Props) { - const isScpDisabled = isDownloadOpen || isUploadOpen || !isConnected; - - return ( - - - - - - - - - ); -} - -type Props = { - isConnected: boolean; - isDownloadOpen: boolean; - isUploadOpen: boolean; - onOpenDownload: () => void; - onOpenUpload: () => void; -}; diff --git a/packages/teleport/src/Console/DocumentSsh/ActionBar/index.ts b/packages/teleport/src/Console/DocumentSsh/ActionBar/index.ts deleted file mode 100644 index 5053904b0..000000000 --- a/packages/teleport/src/Console/DocumentSsh/ActionBar/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import ActionBar from './ActionBar'; -export default ActionBar; diff --git a/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx b/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx index 7b3fd91cc..a30dd2611 100644 --- a/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx +++ b/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx @@ -20,8 +20,15 @@ import * as Icons from 'design/Icon'; import { Indicator, Text, Box, ButtonPrimary } from 'design'; import * as Alerts from 'design/Alert'; +import { + FileTransferActionBar, + FileTransfer, + FileTransferContextProvider, +} from 'shared/components/FileTransfer'; + import cfg from 'teleport/config'; import * as stores from 'teleport/Console/stores'; +import { colors } from 'teleport/Console/colors'; import AuthnDialog from 'teleport/components/AuthnDialog'; import useWebAuthn from 'teleport/lib/useWebAuthn'; @@ -29,18 +36,15 @@ import useWebAuthn from 'teleport/lib/useWebAuthn'; import Document from '../Document'; import Terminal from './Terminal'; -import FileTransfer, { useFileTransferDialogs } from './../FileTransfer'; import useSshSession from './useSshSession'; -import ActionBar from './ActionBar'; +import { getHttpFileTransferHandlers } from './httpFileTransferHandlers'; export default function DocumentSsh({ doc, visible }: PropTypes) { const refTerminal = useRef(); - const scpDialogs = useFileTransferDialogs(); const { tty, status, statusText, closeDocument } = useSshSession(doc); const webauthn = useWebAuthn(tty); - function onCloseScpDialogs() { - scpDialogs.close(); + function handleCloseFileTransfer() { refTerminal.current.terminal.term.focus(); } @@ -53,42 +57,62 @@ export default function DocumentSsh({ doc, visible }: PropTypes) { return ( - - {status === 'loading' && ( - - - - )} - {status === 'error' && ( - - Connection error: {statusText} - - )} - {status === 'notfound' && ( - - )} - {webauthn.requested && ( - + + {status === 'loading' && ( + + + + )} + {status === 'error' && ( + + Connection error: {statusText} + + )} + {status === 'notfound' && ( + + )} + {webauthn.requested && ( + + )} + {status === 'initialized' && } + + window.confirm('Are you sure you want to cancel file transfers?') + } + afterClose={handleCloseFileTransfer} + backgroundColor={colors.terminalDark} + transferHandlers={{ + getDownloader: async (location, abortController) => + getHttpFileTransferHandlers().download( + cfg.getScpUrl({ + location, + clusterId: doc.clusterId, + serverId: doc.serverId, + login: doc.login, + filename: location, + }), + abortController + ), + getUploader: async (location, file, abortController) => + getHttpFileTransferHandlers().upload( + cfg.getScpUrl({ + location, + clusterId: doc.clusterId, + serverId: doc.serverId, + login: doc.login, + filename: file.name, + }), + file, + abortController + ), + }} /> - )} - {status === 'initialized' && } - + ); } diff --git a/packages/teleport/src/Console/DocumentSsh/httpFileTransferHandlers.ts b/packages/teleport/src/Console/DocumentSsh/httpFileTransferHandlers.ts new file mode 100644 index 000000000..9b66f8cd3 --- /dev/null +++ b/packages/teleport/src/Console/DocumentSsh/httpFileTransferHandlers.ts @@ -0,0 +1,197 @@ +import { + FileTransferEventsEmitter, + FileTransferListeners, + createFileTransferEventsEmitter, +} from 'shared/components/FileTransfer'; + +import { getAuthHeaders, getNoCacheHeaders } from 'teleport/services/api'; + +export function getHttpFileTransferHandlers() { + return { + upload( + url: string, + file: File, + abortController?: AbortController + ): FileTransferListeners { + const eventEmitter = createFileTransferEventsEmitter(); + const xhr = getBaseXhrRequest({ + method: 'post', + url, + eventEmitter, + abortController, + transformFailedResponse: () => getErrorText(xhr.response), + }); + + xhr.upload.addEventListener('progress', e => { + eventEmitter.emitProgress(calculateProgress(e)); + }); + xhr.send(file); + return eventEmitter; + }, + download( + url: string, + abortController?: AbortController + ): FileTransferListeners { + const eventEmitter = createFileTransferEventsEmitter(); + const xhr = getBaseXhrRequest({ + method: 'get', + url, + eventEmitter, + abortController, + transformSuccessfulResponse: () => { + const fileName = getDispositionFileName(xhr); + if (!fileName) { + throw new Error('Bad response'); + } else { + saveOnDisk(fileName, xhr.response); + } + }, + transformFailedResponse: () => getFileReaderErrorAsText(xhr.response), + }); + + xhr.onprogress = e => { + if (xhr.status === 200) { + eventEmitter.emitProgress(calculateProgress(e)); + } + }; + xhr.responseType = 'blob'; + xhr.send(); + return eventEmitter; + }, + }; +} + +function getBaseXhrRequest({ + method, + url, + abortController, + eventEmitter, + transformSuccessfulResponse, + transformFailedResponse, +}: { + method: string; + url: string; + eventEmitter: FileTransferEventsEmitter; + abortController: AbortController; + transformSuccessfulResponse?(): void; + transformFailedResponse?(): Promise | string; +}): XMLHttpRequest { + function setHeaders(): void { + const headers = { + ...getAuthHeaders(), + ...getNoCacheHeaders(), + }; + + Object.keys(headers).forEach(key => { + xhr.setRequestHeader(key, headers[key]); + }); + } + + function attachHandlers(): void { + if (abortController) { + abortController.signal.onabort = () => { + xhr.abort(); + }; + } + + xhr.onload = async () => { + if (xhr.status !== 200) { + eventEmitter.emitError(new Error(await transformFailedResponse())); + return; + } + + try { + transformSuccessfulResponse?.(); + eventEmitter.emitComplete(); + } catch (error) { + eventEmitter.emitError(error); + } + }; + + xhr.onerror = async () => { + eventEmitter.emitError(new Error(await transformFailedResponse())); + }; + + xhr.ontimeout = () => { + eventEmitter.emitError(new Error('Request timed out.')); + }; + + xhr.onabort = () => { + eventEmitter.emitError(new DOMException('Aborted', 'AbortError')); + }; + } + + const xhr = new XMLHttpRequest(); + xhr.open(method, url, true); + setHeaders(); + attachHandlers(); + + return xhr; +} + +function getFileReaderErrorAsText(xhrResponse: Blob): Promise { + return new Promise(resolve => { + const reader = new FileReader(); + + reader.onerror = () => { + resolve(reader.error.message); + }; + + reader.onload = () => { + const text = getErrorText(reader.result as string); + resolve(text); + }; + + reader.readAsText(xhrResponse); + }); +} + +function saveOnDisk(fileName: string, blob: Blob): void { + const a = document.createElement('a'); + a.href = window.URL.createObjectURL(blob); + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +// backend may return errors in different formats, +// look at different JSON structures to retrieve the error message +function getErrorText(response: string | undefined): string { + const badRequest = 'Bad request'; + if (!response) { + return badRequest; + } + + try { + const json = JSON.parse(response); + return json.error?.message || json.message || badRequest; + } catch (err) { + return 'Bad request, failed to parse error message.'; + } +} + +function calculateProgress(e: ProgressEvent): number { + // if Content-Length is present + if (e.lengthComputable) { + return Math.round((e.loaded / e.total) * 100); + } else { + const done = e.loaded; + const total = e.total; + return Math.floor((done / total) * 1000) / 10; + } +} + +function getDispositionFileName(xhr: XMLHttpRequest) { + let fileName = ''; + const disposition = xhr.getResponseHeader('Content-Disposition'); + if (disposition) { + const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; + const matches = filenameRegex.exec(disposition); + if (matches != null && matches[1]) { + fileName = matches[1].replace(/['"]/g, ''); + } + } + + return decodeURIComponent(fileName); +} diff --git a/packages/teleport/src/Console/FileTransfer/DownloadForm/DownloadForm.tsx b/packages/teleport/src/Console/FileTransfer/DownloadForm/DownloadForm.tsx deleted file mode 100644 index da999ad41..000000000 --- a/packages/teleport/src/Console/FileTransfer/DownloadForm/DownloadForm.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; - -import { Flex } from 'design'; - -import * as Elements from './../Elements'; - -export default class FileDownloadSelector extends React.Component<{ - onDownload: (path: string) => void; -}> { - inputRef = React.createRef(); - - state = { - path: '~/', - }; - - onChangePath = e => { - this.setState({ - path: e.target.value, - }); - }; - - isValidPath(path: string) { - return path && path[path.length - 1] !== '/'; - } - - onDownload = () => { - if (this.isValidPath(this.state.path)) { - this.props.onDownload(this.state.path); - } - }; - - onKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault(); - event.stopPropagation(); - this.onDownload(); - } - }; - - moveCaretAtEnd(e: React.FocusEvent) { - const tmp = e.target.value; - e.target.value = ''; - e.target.value = tmp; - } - - render() { - const { path } = this.state; - const isBtnDisabled = !this.isValidPath(path); - return ( - - (SCP) Download Files - File Path - - (this.inputRef = e)} - value={path} - mb={0} - autoFocus - onFocus={this.moveCaretAtEnd} - onKeyDown={this.onKeyDown} - /> - - Download - - - - ); - } -} diff --git a/packages/teleport/src/Console/FileTransfer/DownloadForm/index.ts b/packages/teleport/src/Console/FileTransfer/DownloadForm/index.ts deleted file mode 100644 index 231c8ee1a..000000000 --- a/packages/teleport/src/Console/FileTransfer/DownloadForm/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import DownloadForm from './DownloadForm'; -export default DownloadForm; diff --git a/packages/teleport/src/Console/FileTransfer/Elements/Button.jsx b/packages/teleport/src/Console/FileTransfer/Elements/Button.jsx deleted file mode 100644 index 34b83ea40..000000000 --- a/packages/teleport/src/Console/FileTransfer/Elements/Button.jsx +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import styled from 'styled-components'; -import { space } from 'design/system'; - -import { colors } from '../../colors'; - -const Button = styled.button` - background: none; - border-color: ${colors.terminal}; - border: 1px solid; - box-sizing: border-box; - cursor: pointer; - text-transform: uppercase; - - &:disabled { - border: 1px solid ${colors.subtle}; - color: ${colors.subtle}; - opacity: 0.24; - } - - color: ${colors.terminal}; - background-color: none; - ${space} -`; - -Button.defaultProps = { - px: '8fdpx', - py: '4px', - border: 1, -}; - -export default Button; diff --git a/packages/teleport/src/Console/FileTransfer/Elements/ButtonClose.jsx b/packages/teleport/src/Console/FileTransfer/Elements/ButtonClose.jsx deleted file mode 100644 index 0f0aaa766..000000000 --- a/packages/teleport/src/Console/FileTransfer/Elements/ButtonClose.jsx +++ /dev/null @@ -1,55 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import styled from 'styled-components'; -import { space } from 'design/system'; -import { Close as CloseIcon } from 'design/Icon'; - -export default function ButtonClose(props) { - return ( - - - - ); -} - -const StyledCloseButton = styled.button` - background: #0000; - border-radius: 2px; - border: none; - color: #fff; - cursor: pointer; - height: 20px; - opacity: 0.56; - outline: none; - padding: 0; - position: absolute; - right: 8px; - top: 8px; - transition: all 0.3s; - width: 20px; - &:hover { - opacity: 1; - } - - &:hover { - background: ${props => props.theme.colors.error}; - } - font-size: ${props => props.theme.fontSizes[4]}px; - - ${space} -`; diff --git a/packages/teleport/src/Console/FileTransfer/Elements/Input.jsx b/packages/teleport/src/Console/FileTransfer/Elements/Input.jsx deleted file mode 100644 index b9dff964a..000000000 --- a/packages/teleport/src/Console/FileTransfer/Elements/Input.jsx +++ /dev/null @@ -1,40 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import styled from 'styled-components'; -import { width, space } from 'styled-system'; - -import { colors } from 'teleport/Console/colors'; - -const Input = styled.input` - border: none; - box-sizing: border-box; - outline: none; - width: 360px; - background-color: ${colors.bgTerminal}; - color: ${colors.terminal}; - ${space} - ${width} -`; - -Input.defaultProps = { - mb: 3, - mr: 2, - px: 2, - py: '4px', -}; - -export default Input; diff --git a/packages/teleport/src/Console/FileTransfer/Elements/Label.jsx b/packages/teleport/src/Console/FileTransfer/Elements/Label.jsx deleted file mode 100644 index be025bf56..000000000 --- a/packages/teleport/src/Console/FileTransfer/Elements/Label.jsx +++ /dev/null @@ -1,33 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import styled from 'styled-components'; -import { Text } from 'design'; - -import { colors } from 'teleport/Console/colors'; - -const Label = styled(Text)` - display: block; -`; - -Label.defaultProps = { - caps: true, - color: colors.terminal, - mb: 2, - mt: 2, -}; - -export default Label; diff --git a/packages/teleport/src/Console/FileTransfer/Elements/index.js b/packages/teleport/src/Console/FileTransfer/Elements/index.js deleted file mode 100644 index 3bfbd4f9a..000000000 --- a/packages/teleport/src/Console/FileTransfer/Elements/index.js +++ /dev/null @@ -1,38 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import styled from 'styled-components'; -import { Text, Box } from 'design'; - -import { colors } from '../../colors'; - -import Button from './Button'; -import ButtonClose from './ButtonClose'; -import Input from './Input'; -import Label from './Label'; - -export { Button, ButtonClose, Input, Label }; - -export const Header = ({ children }) => ( - -); - -export const Form = styled(Box)` - font-size: ${props => props.theme.fontSizes[0]}px; - background-color: ${colors.dark}; - color: ${colors.terminal}; -`; diff --git a/packages/teleport/src/Console/FileTransfer/FileList/FileList.tsx b/packages/teleport/src/Console/FileTransfer/FileList/FileList.tsx deleted file mode 100644 index d10e8fe78..000000000 --- a/packages/teleport/src/Console/FileTransfer/FileList/FileList.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import styled from 'styled-components'; -import { Box } from 'design'; - -import FileListItem from './../FileListItem'; -import { File } from './../types'; - -type FileListProps = { - files: File[]; - onUpdate: (partial: Partial) => void; - onRemove: (id: number) => void; -}; - -export default function FileList({ files, onUpdate, onRemove }: FileListProps) { - if (files.length === 0) { - return null; - } - - const $files = files.map(file => { - const key = file.id; - const props = { - onUpdate, - key, - file, - onRemove, - }; - - return ; - }); - - return ( - - - File - - Status - - - {$files} - - ); -} - -const ListHeaders = styled.div` - display: flex; - justify-content: space-between; - text-transform: uppercase; - font-weight: ${props => props.theme.bold}; -`; - -const ListItems = styled.div` - overflow: auto; - max-height: 300px; - // scrollbars - padding-right: 16px; - margin-right: -16px; -`; diff --git a/packages/teleport/src/Console/FileTransfer/FileList/index.ts b/packages/teleport/src/Console/FileTransfer/FileList/index.ts deleted file mode 100644 index aece99a77..000000000 --- a/packages/teleport/src/Console/FileTransfer/FileList/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import FileList from './FileList'; -export default FileList; diff --git a/packages/teleport/src/Console/FileTransfer/FileListItem/FileListItem.tsx b/packages/teleport/src/Console/FileTransfer/FileListItem/FileListItem.tsx deleted file mode 100644 index b542211f8..000000000 --- a/packages/teleport/src/Console/FileTransfer/FileListItem/FileListItem.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { useEffect, useRef } from 'react'; -import styled from 'styled-components'; -import * as Icons from 'design/Icon'; -import { Box } from 'design'; - -import { colors } from 'teleport/Console/colors'; -import useHttpTransfer from 'teleport/Console/FileTransfer/useHttpTransfer'; - -import { File } from './../types'; - -type FileListItemProps = { - file: File; - onUpdate: (partial: Partial) => void; - onRemove: (id: number) => void; -}; - -export default function FileListItem(props: FileListItemProps) { - const { file, onUpdate } = props; - const { name, id, isUpload, error, url, blob, status } = file; - - const saved = useRef(false); - const httpStatus = useHttpTransfer({ - blob, - url, - isUpload, - }); - - useEffect(() => { - const { state, response } = httpStatus; - if (state === 'completed' && !isUpload) { - if (!saved.current) { - saved.current = true; - saveOnDisk(response.fileName, response.blob); - } - } - - onUpdate({ id, status: httpStatus.state, error: httpStatus.error }); - }, [httpStatus.state]); - - function onRemove() { - props.onRemove(id); - } - - const completed = status === 'completed'; - const failed = status === 'error'; - const processing = status === 'processing'; - - let statusText = `${httpStatus.progress}%`; - if (failed) { - statusText = 'failed'; - } else if (completed) { - statusText = 'completed'; - } - - return ( - - - - {name} - - {processing && } - {statusText} - - {failed && {error}} - - ); -} - -const CancelButton = ({ onClick }) => { - return ( - - - - ); -}; - -function saveOnDisk(fileName: string, blob: any) { - const a = document.createElement('a'); - a.href = window.URL.createObjectURL(blob); - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); -} - -const StyledError = styled.div` - line-height: 1.4; - width: 360px; - color: ${colors.error}; -`; - -const Progress = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; -`; - -const ProgressStatus = styled.div` - font-size: 12px; - height: 24px; - line-height: 24px; - width: 80px; - text-align: right; - color: ${props => (props.isFailed ? colors.error : colors.terminal)}; -`; - -const ProgressIndicator = styled.div` - display: flex; - align-items: center; - word-break: break-word; - background-image: linear-gradient( - to right, - ${colors.terminalDark} 0%, - ${colors.terminalDark} ${props => props.progress}%, - ${colors.bgTerminal} 0%, - ${colors.bgTerminal} 100% - ); - - background: ${props => (props.isCompleted ? 'none' : '')}; - color: ${props => (props.isCompleted ? colors.inverse : colors.terminal)}; - - min-height: 24px; - line-height: 1.4; - - width: 360px; -`; - -const StyledButton = styled.button` - background: ${colors.error}; - border-radius: 2px; - border: none; - color: ${colors.light}; - cursor: pointer; - font-size: 12px; - height: 12px; - outline: none; - padding: 0; - width: 12px; - &:hover { - background: ${colors.error}; - } -`; diff --git a/packages/teleport/src/Console/FileTransfer/FileListItem/index.ts b/packages/teleport/src/Console/FileTransfer/FileListItem/index.ts deleted file mode 100644 index b84c8adfa..000000000 --- a/packages/teleport/src/Console/FileTransfer/FileListItem/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import FileListItem from './FileListItem'; -export default FileListItem; diff --git a/packages/teleport/src/Console/FileTransfer/FileTransfer.story.tsx b/packages/teleport/src/Console/FileTransfer/FileTransfer.story.tsx deleted file mode 100644 index 7037d78bc..000000000 --- a/packages/teleport/src/Console/FileTransfer/FileTransfer.story.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; - -import { Uploader, Downloader } from 'teleport/Console/services/fileTransfer'; - -import { FileTransfer } from './FileTransfer'; -import { ScpContext } from './scpContextProvider'; -import { Scp } from './scpContext'; - -export default { - title: 'Teleport/Console/FileTransfer', -}; - -const props = { - onClose: () => null, -}; - -export const DownloadError = () => { - const context = makeContext({ - ...defaultFile, - status: 'error', - error: 'stat /root/test: no such file or directory', - }); - - return ( - - - - ); -}; - -export const DownloadProgress = () => { - const context = makeContext({ - ...defaultFile, - status: 'processing', - }); - - return ( - - - - ); -}; -export const DownloadCompleted = () => { - const context = makeContext({ - ...defaultFile, - status: 'completed', - }); - - return ( - - - - ); -}; - -export const Upload = () => { - const context = makeContext({ - ...defaultFile, - status: 'completed', - fileName: 'test', - }); - - return ( - - - - ); -}; - -function makeContext(file) { - const context = new Scp({} as any); - - context.updateFile = () => null; - context.store.state.files = [file]; - context.createUploader = () => { - const uploader = new Uploader(); - uploader.do = () => null; - return uploader; - }; - - context.createDownloader = () => { - const downloader = new Downloader(); - downloader.do = () => null; - return downloader; - }; - - return context; -} - -const defaultFile = { - location: '~test', - id: '1547581437406~/test', - url: '/v1/webapi/sites/one/nodes/', - name: '~/test~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf', - blob: [], -}; diff --git a/packages/teleport/src/Console/FileTransfer/FileTransfer.tsx b/packages/teleport/src/Console/FileTransfer/FileTransfer.tsx deleted file mode 100644 index c0f2c92e7..000000000 --- a/packages/teleport/src/Console/FileTransfer/FileTransfer.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import styled from 'styled-components'; - -import { colors } from 'teleport/Console/colors'; - -import DownloadForm from './DownloadForm'; -import UploadForm from './UploadForm'; -import FileList from './FileList'; -import { ButtonClose } from './Elements'; -import useScpContext, { ScpContextProvider } from './scpContextProvider'; -import { Scp } from './scpContext'; - -export default function FileTransferDialogs(props: FileTransferDialogsProps) { - const { isDownloadOpen, isUploadOpen, onClose, clusterId, serverId, login } = - props; - const isOpen = isDownloadOpen || isUploadOpen; - if (!isOpen) { - return null; - } - - const ctx = React.useMemo( - () => new Scp({ clusterId, serverId, login }), - [clusterId, serverId, login] - ); - - return ( - - - - ); -} - -export function FileTransfer({ - isDownloadOpen = false, - isUploadOpen = false, - onClose, -}: FileTransferProps) { - const scpContext = useScpContext(); - const { files } = scpContext.store.state; - - function onRemove(id: number) { - scpContext.removeFile(id); - } - - function onUpdate(json) { - scpContext.updateFile(json); - } - - function onDownload(location: string) { - scpContext.addDownload(location); - } - - function onUpload(location: string, filename: string, blob: any) { - scpContext.addUpload(location, filename, blob); - } - - function onBeforeClose() { - const isTransfering = scpContext.isTransfering(); - if (!isTransfering) { - onClose(); - } - - if ( - isTransfering && - window.confirm('Are you sure you want to cancel file transfers?') - ) { - onClose(); - } - } - - function onKeyDown(e) { - if (e.key !== 'Escape') { - return; - } - - e.preventDefault(); - e.stopPropagation(); - onBeforeClose(); - } - - return ( - - {isDownloadOpen && } - {isUploadOpen && } - - - - ); -} - -type FileTransferDialogsProps = { - clusterId: string; - isDownloadOpen: boolean; - isUploadOpen: boolean; - onClose: () => void; - login: string; - serverId: string; -}; - -type FileTransferProps = { - isDownloadOpen?: boolean; - isUploadOpen?: boolean; - onClose: () => void; -}; - -const StyledFileTransfer = styled.div` - background: ${colors.dark}; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.24); - box-sizing: border-box; - border: 1px dashed #263238; - font-size: ${props => props.theme.fontSizes[0]}px; - color: #28fe14; - - padding: 16px; - // replace it with the Portal component - position: absolute; - right: 0; - top: 0; - width: 496px; - z-index: 3; -`; diff --git a/packages/teleport/src/Console/FileTransfer/UploadForm/UploadForm.story.tsx b/packages/teleport/src/Console/FileTransfer/UploadForm/UploadForm.story.tsx deleted file mode 100644 index 19235d66b..000000000 --- a/packages/teleport/src/Console/FileTransfer/UploadForm/UploadForm.story.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; - -import UploadForm from './UploadForm'; - -export default { - title: 'Teleport/Console/FileTransfer/UploadWithFiles', -}; - -export const UploadWithFiles = () => { - const ref = React.useRef(); - React.useEffect(() => { - const blobs = [{ length: 4343 }, { length: 4343 }]; - ref.current.addFiles([], blobs); - }, []); - - return null} />; -}; diff --git a/packages/teleport/src/Console/FileTransfer/UploadForm/UploadForm.tsx b/packages/teleport/src/Console/FileTransfer/UploadForm/UploadForm.tsx deleted file mode 100644 index e850036bc..000000000 --- a/packages/teleport/src/Console/FileTransfer/UploadForm/UploadForm.tsx +++ /dev/null @@ -1,186 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import styled from 'styled-components'; - -import { colors } from 'teleport/Console/colors'; - -import * as Elements from './../Elements'; - -type PropType = { - onUpload: (location: string, filename: string, blob: Blob) => void; -}; - -export default class UploadForm extends React.Component { - refDropzone = React.createRef(); - refInput = React.createRef(); - refFileSelector = React.createRef(); - - state = { - files: [], - remoteLocation: '~/', - }; - - componentWillUnmount() { - document.removeEventListener('drop', this.onDocumentDrop); - document.removeEventListener('dragover', this.preventDefault); - } - - componentDidMount() { - document.addEventListener('dragover', this.preventDefault, false); - document.addEventListener('drop', this.onDocumentDrop, false); - } - - preventDefault(e) { - e.preventDefault(); - } - - onDocumentDrop(e) { - if ( - this.refDropzone.current && - this.refDropzone.current.contains(e.target) - ) { - return; - } - - e.preventDefault(); - e.dataTransfer.effectAllowed = 'none'; - e.dataTransfer.dropEffect = 'none'; - } - - onFileSelected = e => { - this.addFiles([], e.target.files); - this.refInput.current.focus(); - }; - - onFilePathChanged = e => { - this.setState({ - remoteLocation: e.target.value, - }); - }; - - onUpload = () => { - const { files, remoteLocation } = this.state; - for (var i = 0; i < files.length; i++) { - this.props.onUpload(remoteLocation, files[i].name, files[i]); - } - - this.setState({ files: [] }); - this.setFocus(); - }; - - onOpenFilePicker = () => { - // reset all selected files - this.refFileSelector.current.value = ''; - this.refFileSelector.current.click(); - }; - - onDrop = e => { - e.preventDefault(); - e.stopPropagation(); - this.addFiles(this.state.files, e.dataTransfer.files); - this.setFocus(); - }; - - onKeyDown = event => { - if (event.key === 'Enter') { - event.preventDefault(); - event.stopPropagation(); - this.onOpenFilePicker(); - } - }; - - setFocus() { - this.refInput.current.focus(); - } - - moveCaretAtEnd(e) { - const tmp = e.target.value; - e.target.value = ''; - e.target.value = tmp; - } - - addFiles(files, blobs = []) { - for (var i = 0; i < blobs.length; i++) { - files.push(blobs[i]); - } - - this.setState({ - files, - }); - } - - render() { - const { remoteLocation, files } = this.state; - const isDldBtnDisabled = !remoteLocation || files.length === 0; - const hasFiles = files.length > 0; - const dropZoneText = hasFiles - ? `${files.length} files selected` - : `Select files to upload or drag & drop them here`; - - return ( - - (SCP) UPLOAD Files - Upload destination - - - e.preventDefault()} - onDrop={this.onDrop} - onClick={this.onOpenFilePicker} - > - {dropZoneText} - - - Upload - - - ); - } -} - -const Dropzone = styled.div` - background: ${colors.bgTerminal}; - border: 1px dashed ${colors.text}; - color: ${colors.terminal}; - display: block; - margin: 16px 0; - height: 72px; - line-height: 72px; - text-align: center; - text-transform: uppercase; - cursor: pointer; -`; diff --git a/packages/teleport/src/Console/FileTransfer/UploadForm/index.ts b/packages/teleport/src/Console/FileTransfer/UploadForm/index.ts deleted file mode 100644 index 6327efd4b..000000000 --- a/packages/teleport/src/Console/FileTransfer/UploadForm/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import UploadForm from './UploadForm'; -export default UploadForm; diff --git a/packages/teleport/src/Console/FileTransfer/index.ts b/packages/teleport/src/Console/FileTransfer/index.ts deleted file mode 100644 index 89fad5177..000000000 --- a/packages/teleport/src/Console/FileTransfer/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import FileTransfer from './FileTransfer'; -import useFileTransferDialogs from './useFileTransferDialogs'; -export default FileTransfer; -export { useFileTransferDialogs }; diff --git a/packages/teleport/src/Console/FileTransfer/scpContext.tsx b/packages/teleport/src/Console/FileTransfer/scpContext.tsx deleted file mode 100644 index 6ed35e4bf..000000000 --- a/packages/teleport/src/Console/FileTransfer/scpContext.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Uploader, Downloader } from 'teleport/Console/services/fileTransfer'; - -import StoreFiles from './storeFiles'; - -export class Scp { - store = new StoreFiles(); - - constructor({ clusterId, serverId, login }: ScpParams) { - this.store = new StoreFiles({ - clusterId, - serverId, - login, - }); - } - - removeFile(id: number) { - this.store.remove(id); - } - - updateFile(partial: Partial) { - this.store.update(partial); - } - - addDownload(location: string) { - this.store.add({ - location, - name: location, - isUpload: false, - blob: [], - }); - } - - addUpload(location, filename, blob) { - this.store.add({ - location, - name: filename, - isUpload: true, - blob, - }); - } - - isTransfering() { - return this.store.state.files.some(f => f.status === 'processing'); - } - - createUploader() { - return new Uploader(); - } - - createDownloader() { - return new Downloader(); - } -} - -type ScpParams = { - clusterId: string; - serverId: string; - login: string; -}; diff --git a/packages/teleport/src/Console/FileTransfer/scpContextProvider.tsx b/packages/teleport/src/Console/FileTransfer/scpContextProvider.tsx deleted file mode 100644 index 9cdaee18b..000000000 --- a/packages/teleport/src/Console/FileTransfer/scpContextProvider.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import { useStore } from 'shared/libs/stores'; - -import { Scp } from './scpContext'; - -export const ScpContext = React.createContext(null); - -export const ScpContextProvider: React.FC<{ value: Scp }> = props => { - return ; -}; - -export default function useScpContext() { - const context = React.useContext(ScpContext); - - // subscribe to store updates - useStore(context.store); - - if (!context) { - throw new Error('ScpContext is missing a context'); - } - - return context; -} diff --git a/packages/teleport/src/Console/FileTransfer/storeFiles.ts b/packages/teleport/src/Console/FileTransfer/storeFiles.ts deleted file mode 100644 index 528e023bb..000000000 --- a/packages/teleport/src/Console/FileTransfer/storeFiles.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Store } from 'shared/libs/stores'; - -import cfg from 'teleport/config'; - -import { File } from './types'; - -const defaultState = { - files: [] as File[], - clusterId: '', - serverId: '', - login: '', -}; - -type State = typeof defaultState; - -export default class StoreFiles extends Store { - state = { - ...defaultState, - }; - - constructor(json?: Partial) { - super(); - json && this.setState(json); - } - - makeUrl(location: string, filename: string) { - const { clusterId, serverId, login } = this.state; - return cfg.getScpUrl({ - clusterId, - serverId, - login, - location, - filename, - }); - } - - remove(id: number) { - const files = this.state.files.filter(f => f.id !== id); - return this.setState({ files }); - } - - add({ location, name, blob, isUpload }) { - const url = this.makeUrl(location, name); - const file = makeFile({ - url, - name, - isUpload, - blob, - }); - - return this.setState({ - files: [...this.state.files, file], - }); - } - - update(partial: Partial) { - const index = this.state.files.findIndex(f => f.id === partial.id); - const file = this.state.files[index]; - this.state.files[index] = { - ...file, - ...partial, - }; - - this.setState({ - files: [...this.state.files], - }); - } -} - -export function makeFile(json): File { - const { url, name, isUpload, blob } = json; - return { - id: new Date().getTime() + name, - url, - name, - isUpload, - blob, - status: 'processing', - error: '', - }; -} diff --git a/packages/teleport/src/Console/FileTransfer/types.ts b/packages/teleport/src/Console/FileTransfer/types.ts deleted file mode 100644 index dec3a20ed..000000000 --- a/packages/teleport/src/Console/FileTransfer/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -export type FileState = 'processing' | 'completed' | 'error'; - -export type File = { - id: number; - url: string; - name: string; - isUpload: boolean; - blob: Blob; - status: FileState; - error: string; -}; diff --git a/packages/teleport/src/Console/FileTransfer/useFileTransferDialogs.ts b/packages/teleport/src/Console/FileTransfer/useFileTransferDialogs.ts deleted file mode 100644 index 47d375564..000000000 --- a/packages/teleport/src/Console/FileTransfer/useFileTransferDialogs.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; - -export default function useFileTransferDialogs() { - const [isUploadOpen, setUploadOpen] = React.useState(false); - const [isDownloadOpen, setDownloadOpen] = React.useState(false); - - function openDownload() { - setDownloadOpen(true); - } - - function openUpload() { - setUploadOpen(true); - } - - function close() { - setUploadOpen(false); - setDownloadOpen(false); - } - - return { - isUploadOpen, - isDownloadOpen, - close, - openDownload, - openUpload, - }; -} diff --git a/packages/teleport/src/Console/FileTransfer/useHttpTransfer.ts b/packages/teleport/src/Console/FileTransfer/useHttpTransfer.ts deleted file mode 100644 index db3cd3213..000000000 --- a/packages/teleport/src/Console/FileTransfer/useHttpTransfer.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; - -import useScpContext from './scpContextProvider'; -import { FileState } from './types'; - -export default function useHttpTransfer({ blob, url, isUpload }) { - const { createDownloader, createUploader } = useScpContext(); - const [http] = React.useState(() => { - return isUpload ? createUploader() : createDownloader(); - }); - - const [status, setStatus] = React.useState({ - response: null, - progress: 0, - state: 'processing' as FileState, - error: '', - }); - - React.useEffect(() => { - function handleProgress(progress: number) { - setStatus({ - ...status, - progress, - }); - } - - function handleCompleted(response) { - setStatus({ - ...status, - response, - state: 'completed', - }); - } - - function handleFailed(err) { - setStatus({ - ...status, - error: err.message, - state: 'error', - }); - } - - http.onProgress(handleProgress); - http.onCompleted(handleCompleted); - http.onError(handleFailed); - http.do(url, blob); - - function cleanup() { - http.removeAllListeners(); - http.abort(); - } - - return cleanup; - }, []); - - return status; -} diff --git a/packages/teleport/src/Console/Tabs/__snapshots__/Tabs.story.test.tsx.snap b/packages/teleport/src/Console/Tabs/__snapshots__/Tabs.story.test.tsx.snap index 6f4dd04b6..e82f60faf 100644 --- a/packages/teleport/src/Console/Tabs/__snapshots__/Tabs.story.test.tsx.snap +++ b/packages/teleport/src/Console/Tabs/__snapshots__/Tabs.story.test.tsx.snap @@ -45,10 +45,11 @@ exports[`render ConsoleTabs 1`] = ` .c9:disabled { color: rgba(255,255,255,0.3); + cursor: default; } -.c9:hover, -.c9:focus { +.c9:hover:enabled, +.c9:focus:enabled { background: rgba(255,255,255,0.1); } diff --git a/packages/teleport/src/Console/services/fileTransfer.ts b/packages/teleport/src/Console/services/fileTransfer.ts deleted file mode 100644 index 6c4ee95c6..000000000 --- a/packages/teleport/src/Console/services/fileTransfer.ts +++ /dev/null @@ -1,206 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { EventEmitter } from 'events'; -import Logger from 'shared/libs/logger'; - -import { getAuthHeaders, getNoCacheHeaders } from 'teleport/services/api'; - -const logger = Logger.create('console/services/fileTransfer'); - -const REQ_FAILED_TXT = 'Network request failed'; - -class Transfer extends EventEmitter { - _xhr: XMLHttpRequest; - - constructor() { - super(); - this._xhr = new XMLHttpRequest(); - const xhr = this._xhr; - - xhr.onload = () => { - const { status } = xhr; - if (status === 200) { - this.handleSuccess(xhr); - return; - } - - this.handleError(xhr); - }; - - xhr.onerror = () => { - this.emit('error', new Error(REQ_FAILED_TXT)); - }; - - xhr.ontimeout = () => { - this.emit('error', new Error(REQ_FAILED_TXT)); - }; - - xhr.onabort = () => { - this.emit('error', new DOMException('Aborted', 'AbortError')); - }; - } - - abort() { - this._xhr.abort(); - } - - onProgress(cb: (progress: number) => void) { - this.on('progress', cb); - } - - onCompleted(cb: (response: any) => void) { - this.on('completed', cb); - } - - onError(cb: (err: Error) => void) { - this.on('error', cb); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - handleSuccess(xhr?: XMLHttpRequest) { - throw Error('not implemented'); - } - - handleError(xhr: XMLHttpRequest) { - const errText = getErrorText(xhr.response); - this.emit('error', new Error(errText)); - } - - handleProgress(e) { - let progress = 0; - // if Content-Length is present - if (e.lengthComputable) { - progress = Math.round((e.loaded / e.total) * 100); - } else { - const done = e.position || e.loaded; - const total = e.totalSize || e.total; - progress = Math.floor((done / total) * 1000) / 10; - } - - this.emit('progress', progress); - } -} - -export class Uploader extends Transfer { - constructor() { - super(); - } - - handleSuccess() { - this.emit('completed'); - } - - do(url: string, blob: any) { - this._xhr.upload.addEventListener('progress', e => { - this.handleProgress(e); - }); - - this._xhr.open('post', url, true); - setHeaders(this._xhr); - this._xhr.send(blob); - } -} - -export class Downloader extends Transfer { - constructor() { - super(); - } - - do(url: string) { - this._xhr.open('get', url, true); - this._xhr.onprogress = e => { - this.handleProgress(e); - }; - - setHeaders(this._xhr); - this._xhr.responseType = 'blob'; - this._xhr.send(); - } - - handleSuccess(xhr: XMLHttpRequest) { - const fileName = getDispositionFileName(xhr); - if (!fileName) { - this.emit('error', new Error('Bad response')); - } else { - this.emit('completed', { - fileName: fileName, - blob: xhr.response, - }); - } - } - - // parses blob response to get an error text - handleError(xhr: XMLHttpRequest) { - const reader = new FileReader(); - - reader.onerror = err => { - this.emit('error', err); - }; - - reader.onload = () => { - const text = getErrorText(reader.result as string); - this.emit('error', new Error(text)); - }; - - reader.readAsText(xhr.response); - } -} - -function getDispositionFileName(xhr: XMLHttpRequest) { - let fileName = ''; - const disposition = xhr.getResponseHeader('Content-Disposition'); - if (disposition) { - const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; - const matches = filenameRegex.exec(disposition); - if (matches != null && matches[1]) { - fileName = matches[1].replace(/['"]/g, ''); - } - } - - return decodeURIComponent(fileName); -} - -// TODO: as backend may return errors in different -// formats, look at different JSON structures to retrieve the error message -function getErrorText(response: string) { - const errText = 'Bad request'; - if (!response) { - return errText; - } - - try { - const json = JSON.parse(response); - if (json.message) { - return json.message; - } - } catch (err) { - logger.error('failed to parse error message', err); - } - - return errText; -} - -function setHeaders(xhr: XMLHttpRequest) { - const headers = { - ...getAuthHeaders(), - ...getNoCacheHeaders(), - }; - - Object.keys(headers).forEach(key => { - xhr.setRequestHeader(key, headers[key]); - }); -} diff --git a/packages/teleport/src/DesktopSession/__snapshots__/DesktopSession.story.test.tsx.snap b/packages/teleport/src/DesktopSession/__snapshots__/DesktopSession.story.test.tsx.snap index b3da4a124..3f1ff175b 100644 --- a/packages/teleport/src/DesktopSession/__snapshots__/DesktopSession.story.test.tsx.snap +++ b/packages/teleport/src/DesktopSession/__snapshots__/DesktopSession.story.test.tsx.snap @@ -242,10 +242,11 @@ exports[`connected settings false 1`] = ` .c9:disabled { color: rgba(255,255,255,0.3); + cursor: default; } -.c9:hover, -.c9:focus { +.c9:hover:enabled, +.c9:focus:enabled { background: rgba(255,255,255,0.1); } @@ -402,10 +403,11 @@ exports[`connected settings true 1`] = ` .c9:disabled { color: rgba(255,255,255,0.3); + cursor: default; } -.c9:hover, -.c9:focus { +.c9:hover:enabled, +.c9:focus:enabled { background: rgba(255,255,255,0.1); } @@ -764,10 +766,11 @@ exports[`disconnected 1`] = ` .c9:disabled { color: rgba(255,255,255,0.3); + cursor: default; } -.c9:hover, -.c9:focus { +.c9:hover:enabled, +.c9:focus:enabled { background: rgba(255,255,255,0.1); } diff --git a/packages/teleterm/src/mainProcess/fixtures/mocks.ts b/packages/teleterm/src/mainProcess/fixtures/mocks.ts index 483f4e797..b92c6960e 100644 --- a/packages/teleterm/src/mainProcess/fixtures/mocks.ts +++ b/packages/teleterm/src/mainProcess/fixtures/mocks.ts @@ -34,6 +34,10 @@ export class MockMainProcessClient implements MainProcessClient { openTabContextMenu() {} + showFileSaveDialog() { + return Promise.resolve({ canceled: false, filePath: '' }); + } + configService = { get: () => ({ keyboardShortcuts: {}, diff --git a/packages/teleterm/src/mainProcess/mainProcess.ts b/packages/teleterm/src/mainProcess/mainProcess.ts index 80d7500c3..5c0d55a8f 100644 --- a/packages/teleterm/src/mainProcess/mainProcess.ts +++ b/packages/teleterm/src/mainProcess/mainProcess.ts @@ -7,6 +7,7 @@ import { app, ipcMain, shell, + dialog, Menu, MenuItemConstructorOptions, } from 'electron'; @@ -173,6 +174,12 @@ export default class MainProcess { } ); + ipcMain.handle('main-process-show-file-save-dialog', (_, filePath) => + dialog.showSaveDialog({ + defaultPath: path.basename(filePath), + }) + ); + subscribeToTerminalContextMenuEvent(); subscribeToTabContextMenuEvent(); subscribeToConfigServiceEvents(this.configService); diff --git a/packages/teleterm/src/mainProcess/mainProcessClient.ts b/packages/teleterm/src/mainProcess/mainProcessClient.ts index 9e8ff017a..17e029a07 100644 --- a/packages/teleterm/src/mainProcess/mainProcessClient.ts +++ b/packages/teleterm/src/mainProcess/mainProcessClient.ts @@ -18,6 +18,9 @@ export default function createMainProcessClient(): MainProcessClient { 'main-process-get-resolved-child-process-addresses' ); }, + showFileSaveDialog(filePath: string) { + return ipcRenderer.invoke('main-process-show-file-save-dialog', filePath); + }, openTerminalContextMenu, openTabContextMenu, configService: createConfigServiceClient(), diff --git a/packages/teleterm/src/mainProcess/types.ts b/packages/teleterm/src/mainProcess/types.ts index 0f13c0f0a..50431cc80 100644 --- a/packages/teleterm/src/mainProcess/types.ts +++ b/packages/teleterm/src/mainProcess/types.ts @@ -29,6 +29,9 @@ export type MainProcessClient = { getResolvedChildProcessAddresses(): Promise; openTerminalContextMenu(): void; openTabContextMenu(options: TabContextMenuOptions): void; + showFileSaveDialog( + filePath: string + ): Promise<{ canceled: boolean; filePath: string | undefined }>; configService: ConfigService; fileStorage: FileStorage; removeKubeConfig(options: { diff --git a/packages/teleterm/src/services/tshd/createClient.ts b/packages/teleterm/src/services/tshd/createClient.ts index d92c686fe..d02787db8 100644 --- a/packages/teleterm/src/services/tshd/createClient.ts +++ b/packages/teleterm/src/services/tshd/createClient.ts @@ -1,11 +1,12 @@ import { ChannelCredentials, ClientDuplexStream } from '@grpc/grpc-js'; -import { TerminalServiceClient } from 'teleterm/services/tshd/v1/service_grpc_pb'; -import * as api from 'teleterm/services/tshd/v1/service_pb'; -import * as types from 'teleterm/services/tshd/types'; import Logger from 'teleterm/logger'; +import * as api from './v1/service_pb'; +import { TerminalServiceClient } from './v1/service_grpc_pb'; +import { createFileTransferStream } from './createFileTransferStream'; import middleware, { withLogging } from './middleware'; +import * as types from './types'; import createAbortController from './createAbortController'; export default function createClient( @@ -413,8 +414,22 @@ export default function createClient( }); }); }, - }; + transferFile( + options: types.FileTransferRequest, + abortSignal: types.TshAbortSignal + ) { + const req = new api.FileTransferRequest() + .setLogin(options.login) + .setSource(options.source) + .setDestination(options.destination) + .setHostname(options.hostname) + .setClusterUri(options.clusterUri) + .setDirection(options.direction); + + return createFileTransferStream(tshd.transferFile(req), abortSignal); + }, + }; return client; } diff --git a/packages/teleterm/src/services/tshd/createFileTransferStream.ts b/packages/teleterm/src/services/tshd/createFileTransferStream.ts new file mode 100644 index 000000000..9b765263e --- /dev/null +++ b/packages/teleterm/src/services/tshd/createFileTransferStream.ts @@ -0,0 +1,37 @@ +import { ClientReadableStream } from '@grpc/grpc-js'; +import { FileTransferListeners } from 'shared/components/FileTransfer'; + +import { FileTransferProgress } from './v1/service_pb'; +import * as api from './v1/service_pb'; +import { TshAbortSignal } from './types'; + +export function createFileTransferStream( + stream: ClientReadableStream, + abortSignal?: TshAbortSignal +): FileTransferListeners { + abortSignal.addEventListener(() => stream.cancel()); + + return { + onProgress(callback: (progress: number) => void) { + stream.on('data', (data: api.FileTransferProgress) => + callback(data.getPercentage()) + ); + }, + onComplete(callback: () => void) { + stream.on('end', () => { + callback(); + // When stream ends, all listeners can be removed. + stream.removeAllListeners(); + }); + }, + onError(callback: (error: Error) => void) { + stream.on('error', err => { + callback(err); + // Due to a bug in grpc-js, the `error` event is also emitted after the `end` event. + // This behavior is not correct, only one of them should be emitted. + // To fix this, we remove all listeners after the stream ended with an error. + stream.removeAllListeners(); + }); + }, + }; +} diff --git a/packages/teleterm/src/services/tshd/fixtures/mocks.ts b/packages/teleterm/src/services/tshd/fixtures/mocks.ts index 2bc2281de..178bbf4a7 100644 --- a/packages/teleterm/src/services/tshd/fixtures/mocks.ts +++ b/packages/teleterm/src/services/tshd/fixtures/mocks.ts @@ -55,4 +55,5 @@ export class MockTshClient implements TshClient { abortSignal?: TshAbortSignal ) => Promise; logout: (clusterUri: string) => Promise; + transferFile: () => undefined; } diff --git a/packages/teleterm/src/services/tshd/types.ts b/packages/teleterm/src/services/tshd/types.ts index 90cbee954..567d36294 100644 --- a/packages/teleterm/src/services/tshd/types.ts +++ b/packages/teleterm/src/services/tshd/types.ts @@ -1,3 +1,5 @@ +import { FileTransferListeners } from 'shared/components/FileTransfer'; + import apiCluster from './v1/cluster_pb'; import apiDb from './v1/database_pb'; import apigateway from './v1/gateway_pb'; @@ -28,6 +30,8 @@ export type LoggedInUser = apiCluster.LoggedInUser.AsObject; export type AuthProvider = apiAuthSettings.AuthProvider.AsObject; export type AuthSettings = apiAuthSettings.AuthSettings.AsObject; +export type FileTransferRequest = apiService.FileTransferRequest.AsObject; + export type WebauthnCredentialInfo = apiService.CredentialInfo.AsObject; export type WebauthnLoginPrompt = | WebauthnLoginTapPrompt @@ -88,6 +92,10 @@ export type TshClient = { abortSignal?: TshAbortSignal ) => Promise; logout: (clusterUri: string) => Promise; + transferFile: ( + options: FileTransferRequest, + abortSignal?: TshAbortSignal + ) => FileTransferListeners; }; export type TshAbortController = { diff --git a/packages/teleterm/src/services/tshd/v1/service_grpc_pb.d.ts b/packages/teleterm/src/services/tshd/v1/service_grpc_pb.d.ts index 8d15635b2..6e8e9b14b 100644 --- a/packages/teleterm/src/services/tshd/v1/service_grpc_pb.d.ts +++ b/packages/teleterm/src/services/tshd/v1/service_grpc_pb.d.ts @@ -35,6 +35,7 @@ interface ITerminalServiceService extends grpc.ServiceDefinition { @@ -217,6 +218,15 @@ interface ITerminalServiceService_ILogout extends grpc.MethodDefinition; responseDeserialize: grpc.deserialize; } +interface ITerminalServiceService_ITransferFile extends grpc.MethodDefinition { + path: "/teleport.terminal.v1.TerminalService/TransferFile"; + requestStream: false; + responseStream: true; + requestSerialize: grpc.serialize; + requestDeserialize: grpc.deserialize; + responseSerialize: grpc.serialize; + responseDeserialize: grpc.deserialize; +} export const TerminalServiceService: ITerminalServiceService; @@ -241,6 +251,7 @@ export interface ITerminalServiceServer { login: grpc.handleUnaryCall; loginPasswordless: grpc.handleBidiStreamingCall; logout: grpc.handleUnaryCall; + transferFile: grpc.handleServerStreamingCall; } export interface ITerminalServiceClient { @@ -304,6 +315,8 @@ export interface ITerminalServiceClient { logout(request: v1_service_pb.LogoutRequest, callback: (error: grpc.ServiceError | null, response: v1_service_pb.EmptyResponse) => void): grpc.ClientUnaryCall; logout(request: v1_service_pb.LogoutRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: v1_service_pb.EmptyResponse) => void): grpc.ClientUnaryCall; logout(request: v1_service_pb.LogoutRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: v1_service_pb.EmptyResponse) => void): grpc.ClientUnaryCall; + transferFile(request: v1_service_pb.FileTransferRequest, options?: Partial): grpc.ClientReadableStream; + transferFile(request: v1_service_pb.FileTransferRequest, metadata?: grpc.Metadata, options?: Partial): grpc.ClientReadableStream; } export class TerminalServiceClient extends grpc.Client implements ITerminalServiceClient { @@ -367,4 +380,6 @@ export class TerminalServiceClient extends grpc.Client implements ITerminalServi public logout(request: v1_service_pb.LogoutRequest, callback: (error: grpc.ServiceError | null, response: v1_service_pb.EmptyResponse) => void): grpc.ClientUnaryCall; public logout(request: v1_service_pb.LogoutRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: v1_service_pb.EmptyResponse) => void): grpc.ClientUnaryCall; public logout(request: v1_service_pb.LogoutRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: v1_service_pb.EmptyResponse) => void): grpc.ClientUnaryCall; + public transferFile(request: v1_service_pb.FileTransferRequest, options?: Partial): grpc.ClientReadableStream; + public transferFile(request: v1_service_pb.FileTransferRequest, metadata?: grpc.Metadata, options?: Partial): grpc.ClientReadableStream; } diff --git a/packages/teleterm/src/services/tshd/v1/service_grpc_pb.js b/packages/teleterm/src/services/tshd/v1/service_grpc_pb.js index 5c2658121..dbd6044c9 100644 --- a/packages/teleterm/src/services/tshd/v1/service_grpc_pb.js +++ b/packages/teleterm/src/services/tshd/v1/service_grpc_pb.js @@ -81,6 +81,28 @@ function deserialize_teleport_terminal_v1_EmptyResponse(buffer_arg) { return v1_service_pb.EmptyResponse.deserializeBinary(new Uint8Array(buffer_arg)); } +function serialize_teleport_terminal_v1_FileTransferProgress(arg) { + if (!(arg instanceof v1_service_pb.FileTransferProgress)) { + throw new Error('Expected argument of type teleport.terminal.v1.FileTransferProgress'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_teleport_terminal_v1_FileTransferProgress(buffer_arg) { + return v1_service_pb.FileTransferProgress.deserializeBinary(new Uint8Array(buffer_arg)); +} + +function serialize_teleport_terminal_v1_FileTransferRequest(arg) { + if (!(arg instanceof v1_service_pb.FileTransferRequest)) { + throw new Error('Expected argument of type teleport.terminal.v1.FileTransferRequest'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_teleport_terminal_v1_FileTransferRequest(buffer_arg) { + return v1_service_pb.FileTransferRequest.deserializeBinary(new Uint8Array(buffer_arg)); +} + function serialize_teleport_terminal_v1_Gateway(arg) { if (!(arg instanceof v1_gateway_pb.Gateway)) { throw new Error('Expected argument of type teleport.terminal.v1.Gateway'); @@ -642,6 +664,18 @@ logout: { responseSerialize: serialize_teleport_terminal_v1_EmptyResponse, responseDeserialize: deserialize_teleport_terminal_v1_EmptyResponse, }, + // TransferFile downloads/uploads a file +transferFile: { + path: '/teleport.terminal.v1.TerminalService/TransferFile', + requestStream: false, + responseStream: true, + requestType: v1_service_pb.FileTransferRequest, + responseType: v1_service_pb.FileTransferProgress, + requestSerialize: serialize_teleport_terminal_v1_FileTransferRequest, + requestDeserialize: deserialize_teleport_terminal_v1_FileTransferRequest, + responseSerialize: serialize_teleport_terminal_v1_FileTransferProgress, + responseDeserialize: deserialize_teleport_terminal_v1_FileTransferProgress, + }, }; exports.TerminalServiceClient = grpc.makeGenericClientConstructor(TerminalServiceService); diff --git a/packages/teleterm/src/services/tshd/v1/service_pb.d.ts b/packages/teleterm/src/services/tshd/v1/service_pb.d.ts index 23f8e87e8..0138f2003 100644 --- a/packages/teleterm/src/services/tshd/v1/service_pb.d.ts +++ b/packages/teleterm/src/services/tshd/v1/service_pb.d.ts @@ -241,6 +241,68 @@ export namespace LoginPasswordlessRequest { } +export class FileTransferRequest extends jspb.Message { + getClusterUri(): string; + setClusterUri(value: string): FileTransferRequest; + + getLogin(): string; + setLogin(value: string): FileTransferRequest; + + getHostname(): string; + setHostname(value: string): FileTransferRequest; + + getSource(): string; + setSource(value: string): FileTransferRequest; + + getDestination(): string; + setDestination(value: string): FileTransferRequest; + + getDirection(): FileTransferDirection; + setDirection(value: FileTransferDirection): FileTransferRequest; + + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): FileTransferRequest.AsObject; + static toObject(includeInstance: boolean, msg: FileTransferRequest): FileTransferRequest.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: FileTransferRequest, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): FileTransferRequest; + static deserializeBinaryFromReader(message: FileTransferRequest, reader: jspb.BinaryReader): FileTransferRequest; +} + +export namespace FileTransferRequest { + export type AsObject = { + clusterUri: string, + login: string, + hostname: string, + source: string, + destination: string, + direction: FileTransferDirection, + } +} + +export class FileTransferProgress extends jspb.Message { + getPercentage(): number; + setPercentage(value: number): FileTransferProgress; + + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): FileTransferProgress.AsObject; + static toObject(includeInstance: boolean, msg: FileTransferProgress): FileTransferProgress.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: FileTransferProgress, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): FileTransferProgress; + static deserializeBinaryFromReader(message: FileTransferProgress, reader: jspb.BinaryReader): FileTransferProgress; +} + +export namespace FileTransferProgress { + export type AsObject = { + percentage: number, + } +} + export class LoginRequest extends jspb.Message { getClusterUri(): string; setClusterUri(value: string): LoginRequest; @@ -861,3 +923,9 @@ export enum PasswordlessPrompt { PASSWORDLESS_PROMPT_TAP = 2, PASSWORDLESS_PROMPT_CREDENTIAL = 3, } + +export enum FileTransferDirection { + FILE_TRANSFER_DIRECTION_UNSPECIFIED = 0, + FILE_TRANSFER_DIRECTION_DOWNLOAD = 1, + FILE_TRANSFER_DIRECTION_UPLOAD = 2, +} diff --git a/packages/teleterm/src/services/tshd/v1/service_pb.js b/packages/teleterm/src/services/tshd/v1/service_pb.js index 6a8d8a65f..f76c00a0c 100644 --- a/packages/teleterm/src/services/tshd/v1/service_pb.js +++ b/packages/teleterm/src/services/tshd/v1/service_pb.js @@ -33,6 +33,9 @@ goog.exportSymbol('proto.teleport.terminal.v1.AddClusterRequest', null, global); goog.exportSymbol('proto.teleport.terminal.v1.CreateGatewayRequest', null, global); goog.exportSymbol('proto.teleport.terminal.v1.CredentialInfo', null, global); goog.exportSymbol('proto.teleport.terminal.v1.EmptyResponse', null, global); +goog.exportSymbol('proto.teleport.terminal.v1.FileTransferDirection', null, global); +goog.exportSymbol('proto.teleport.terminal.v1.FileTransferProgress', null, global); +goog.exportSymbol('proto.teleport.terminal.v1.FileTransferRequest', null, global); goog.exportSymbol('proto.teleport.terminal.v1.GetAuthSettingsRequest', null, global); goog.exportSymbol('proto.teleport.terminal.v1.GetClusterRequest', null, global); goog.exportSymbol('proto.teleport.terminal.v1.ListAppsRequest', null, global); @@ -256,6 +259,48 @@ if (goog.DEBUG && !COMPILED) { */ proto.teleport.terminal.v1.LoginPasswordlessRequest.LoginPasswordlessCredentialResponse.displayName = 'proto.teleport.terminal.v1.LoginPasswordlessRequest.LoginPasswordlessCredentialResponse'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.teleport.terminal.v1.FileTransferRequest = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.teleport.terminal.v1.FileTransferRequest, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.teleport.terminal.v1.FileTransferRequest.displayName = 'proto.teleport.terminal.v1.FileTransferRequest'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.teleport.terminal.v1.FileTransferProgress = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.teleport.terminal.v1.FileTransferProgress, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.teleport.terminal.v1.FileTransferProgress.displayName = 'proto.teleport.terminal.v1.FileTransferProgress'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -2183,6 +2228,416 @@ proto.teleport.terminal.v1.LoginPasswordlessRequest.prototype.hasCredential = fu + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.toObject = function(opt_includeInstance) { + return proto.teleport.terminal.v1.FileTransferRequest.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.teleport.terminal.v1.FileTransferRequest} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.teleport.terminal.v1.FileTransferRequest.toObject = function(includeInstance, msg) { + var f, obj = { + clusterUri: jspb.Message.getFieldWithDefault(msg, 1, ""), + login: jspb.Message.getFieldWithDefault(msg, 2, ""), + hostname: jspb.Message.getFieldWithDefault(msg, 3, ""), + source: jspb.Message.getFieldWithDefault(msg, 4, ""), + destination: jspb.Message.getFieldWithDefault(msg, 5, ""), + direction: jspb.Message.getFieldWithDefault(msg, 6, 0) + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.teleport.terminal.v1.FileTransferRequest} + */ +proto.teleport.terminal.v1.FileTransferRequest.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.teleport.terminal.v1.FileTransferRequest; + return proto.teleport.terminal.v1.FileTransferRequest.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.teleport.terminal.v1.FileTransferRequest} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.teleport.terminal.v1.FileTransferRequest} + */ +proto.teleport.terminal.v1.FileTransferRequest.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setClusterUri(value); + break; + case 2: + var value = /** @type {string} */ (reader.readString()); + msg.setLogin(value); + break; + case 3: + var value = /** @type {string} */ (reader.readString()); + msg.setHostname(value); + break; + case 4: + var value = /** @type {string} */ (reader.readString()); + msg.setSource(value); + break; + case 5: + var value = /** @type {string} */ (reader.readString()); + msg.setDestination(value); + break; + case 6: + var value = /** @type {!proto.teleport.terminal.v1.FileTransferDirection} */ (reader.readEnum()); + msg.setDirection(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.teleport.terminal.v1.FileTransferRequest.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.teleport.terminal.v1.FileTransferRequest} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.teleport.terminal.v1.FileTransferRequest.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getClusterUri(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } + f = message.getLogin(); + if (f.length > 0) { + writer.writeString( + 2, + f + ); + } + f = message.getHostname(); + if (f.length > 0) { + writer.writeString( + 3, + f + ); + } + f = message.getSource(); + if (f.length > 0) { + writer.writeString( + 4, + f + ); + } + f = message.getDestination(); + if (f.length > 0) { + writer.writeString( + 5, + f + ); + } + f = message.getDirection(); + if (f !== 0.0) { + writer.writeEnum( + 6, + f + ); + } +}; + + +/** + * optional string cluster_uri = 1; + * @return {string} + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.getClusterUri = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.teleport.terminal.v1.FileTransferRequest} returns this + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.setClusterUri = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + +/** + * optional string login = 2; + * @return {string} + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.getLogin = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, "")); +}; + + +/** + * @param {string} value + * @return {!proto.teleport.terminal.v1.FileTransferRequest} returns this + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.setLogin = function(value) { + return jspb.Message.setProto3StringField(this, 2, value); +}; + + +/** + * optional string hostname = 3; + * @return {string} + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.getHostname = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 3, "")); +}; + + +/** + * @param {string} value + * @return {!proto.teleport.terminal.v1.FileTransferRequest} returns this + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.setHostname = function(value) { + return jspb.Message.setProto3StringField(this, 3, value); +}; + + +/** + * optional string source = 4; + * @return {string} + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.getSource = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 4, "")); +}; + + +/** + * @param {string} value + * @return {!proto.teleport.terminal.v1.FileTransferRequest} returns this + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.setSource = function(value) { + return jspb.Message.setProto3StringField(this, 4, value); +}; + + +/** + * optional string destination = 5; + * @return {string} + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.getDestination = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 5, "")); +}; + + +/** + * @param {string} value + * @return {!proto.teleport.terminal.v1.FileTransferRequest} returns this + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.setDestination = function(value) { + return jspb.Message.setProto3StringField(this, 5, value); +}; + + +/** + * optional FileTransferDirection direction = 6; + * @return {!proto.teleport.terminal.v1.FileTransferDirection} + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.getDirection = function() { + return /** @type {!proto.teleport.terminal.v1.FileTransferDirection} */ (jspb.Message.getFieldWithDefault(this, 6, 0)); +}; + + +/** + * @param {!proto.teleport.terminal.v1.FileTransferDirection} value + * @return {!proto.teleport.terminal.v1.FileTransferRequest} returns this + */ +proto.teleport.terminal.v1.FileTransferRequest.prototype.setDirection = function(value) { + return jspb.Message.setProto3EnumField(this, 6, value); +}; + + + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.teleport.terminal.v1.FileTransferProgress.prototype.toObject = function(opt_includeInstance) { + return proto.teleport.terminal.v1.FileTransferProgress.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.teleport.terminal.v1.FileTransferProgress} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.teleport.terminal.v1.FileTransferProgress.toObject = function(includeInstance, msg) { + var f, obj = { + percentage: jspb.Message.getFieldWithDefault(msg, 1, 0) + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.teleport.terminal.v1.FileTransferProgress} + */ +proto.teleport.terminal.v1.FileTransferProgress.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.teleport.terminal.v1.FileTransferProgress; + return proto.teleport.terminal.v1.FileTransferProgress.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.teleport.terminal.v1.FileTransferProgress} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.teleport.terminal.v1.FileTransferProgress} + */ +proto.teleport.terminal.v1.FileTransferProgress.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {number} */ (reader.readUint32()); + msg.setPercentage(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.teleport.terminal.v1.FileTransferProgress.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.teleport.terminal.v1.FileTransferProgress.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.teleport.terminal.v1.FileTransferProgress} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.teleport.terminal.v1.FileTransferProgress.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getPercentage(); + if (f !== 0) { + writer.writeUint32( + 1, + f + ); + } +}; + + +/** + * optional uint32 percentage = 1; + * @return {number} + */ +proto.teleport.terminal.v1.FileTransferProgress.prototype.getPercentage = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 1, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.teleport.terminal.v1.FileTransferProgress} returns this + */ +proto.teleport.terminal.v1.FileTransferProgress.prototype.setPercentage = function(value) { + return jspb.Message.setProto3IntField(this, 1, value); +}; + + + /** * Oneof group definitions for this message. Each group defines the field * numbers belonging to that group. When of these fields' value is set, all @@ -6114,4 +6569,13 @@ proto.teleport.terminal.v1.PasswordlessPrompt = { PASSWORDLESS_PROMPT_CREDENTIAL: 3 }; +/** + * @enum {number} + */ +proto.teleport.terminal.v1.FileTransferDirection = { + FILE_TRANSFER_DIRECTION_UNSPECIFIED: 0, + FILE_TRANSFER_DIRECTION_DOWNLOAD: 1, + FILE_TRANSFER_DIRECTION_UPLOAD: 2 +}; + goog.object.extend(exports, proto.teleport.terminal.v1); diff --git a/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx b/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx index 16482fe43..7d9a35a84 100644 --- a/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx +++ b/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx @@ -16,11 +16,19 @@ limitations under the License. import React from 'react'; +import { + FileTransferActionBar, + FileTransfer, + FileTransferContextProvider, +} from 'shared/components/FileTransfer'; + import Document from 'teleterm/ui/Document'; +import { useAppContext } from 'teleterm/ui/appContextProvider'; -import useDocTerminal, { Props } from './useDocumentTerminal'; import Terminal from './Terminal'; import DocumentReconnect from './DocumentReconnect'; +import useDocTerminal, { Props } from './useDocumentTerminal'; +import { useTshFileTransferHandlers } from './useTshFileTransferHandlers'; export default function DocumentTerminalContainer({ doc, visible }: Props) { if (doc.kind === 'doc.terminal_tsh_node' && doc.status === 'disconnected') { @@ -31,9 +39,13 @@ export default function DocumentTerminalContainer({ doc, visible }: Props) { } export function DocumentTerminal(props: Props & { visible: boolean }) { + const ctx = useAppContext(); const { visible, doc } = props; const state = useDocTerminal(doc); const ptyProcess = state.data?.ptyProcess; + const { upload, download } = useTshFileTransferHandlers({ + originatingDocumentUri: doc.uri, + }); return ( {ptyProcess && ( - + <> + {doc.kind === 'doc.terminal_tsh_node' && ( + <> + + + + // TODO (gzdunek): replace with a native dialog + window.confirm( + 'Are you sure you want to cancel file transfers?' + ) + } + transferHandlers={{ + getDownloader: async (sourcePath, abortController) => { + const fileDialog = + await ctx.mainProcessClient.showFileSaveDialog( + sourcePath + ); + if (fileDialog.canceled) { + return; + } + return download( + { + serverUri: doc.serverUri, + login: doc.login, + source: sourcePath, + destination: fileDialog.filePath, + }, + abortController + ); + }, + getUploader: async ( + destinationPath, + file, + abortController + ) => + upload( + { + serverUri: doc.serverUri, + login: doc.login, + source: file.path, + destination: destinationPath, + }, + abortController + ), + }} + /> + + + )} + + )} ); diff --git a/packages/teleterm/src/ui/DocumentTerminal/useTshFileTransferHandlers.ts b/packages/teleterm/src/ui/DocumentTerminal/useTshFileTransferHandlers.ts new file mode 100644 index 000000000..7cd120be9 --- /dev/null +++ b/packages/teleterm/src/ui/DocumentTerminal/useTshFileTransferHandlers.ts @@ -0,0 +1,96 @@ +import { + FileTransferListeners, + createFileTransferEventsEmitter, +} from 'shared/components/FileTransfer'; + +import { routing } from 'teleterm/ui/uri'; +import { FileTransferDirection } from 'teleterm/services/tshd/v1/service_pb'; +import { retryWithRelogin } from 'teleterm/ui/utils'; +import { useAppContext } from 'teleterm/ui/appContextProvider'; +import { IAppContext } from 'teleterm/ui/types'; + +export function useTshFileTransferHandlers(options: { + originatingDocumentUri: string; +}) { + const appContext = useAppContext(); + + return { + upload( + file: FileTransferRequestObject, + abortController: AbortController + ): FileTransferListeners { + return transferFile( + appContext, + options.originatingDocumentUri, + file, + abortController, + FileTransferDirection.FILE_TRANSFER_DIRECTION_UPLOAD + ); + }, + download( + file: FileTransferRequestObject, + abortController: AbortController + ): FileTransferListeners { + return transferFile( + appContext, + options.originatingDocumentUri, + file, + abortController, + FileTransferDirection.FILE_TRANSFER_DIRECTION_DOWNLOAD + ); + }, + }; +} + +function transferFile( + appContext: IAppContext, + originatingDocumentUri: string, + file: FileTransferRequestObject, + abortController: AbortController, + direction: FileTransferDirection +): FileTransferListeners { + const server = appContext.clustersService.getServer(file.serverUri); + const eventsEmitter = createFileTransferEventsEmitter(); + const getFileTransferActionAsPromise = () => + new Promise((resolve, reject) => { + const callbacks = appContext.fileTransferService.transferFile( + { + source: file.source, + destination: file.destination, + login: file.login, + clusterUri: routing.ensureClusterUri(file.serverUri), + hostname: server.hostname, + direction, + }, + abortController + ); + + callbacks.onProgress((percentage: number) => { + eventsEmitter.emitProgress(percentage); + }); + callbacks.onError((error: Error) => { + reject(error); + }); + callbacks.onComplete(() => { + resolve(undefined); + }); + }); + + retryWithRelogin( + appContext, + originatingDocumentUri, + file.serverUri, + getFileTransferActionAsPromise + ) + .then(eventsEmitter.emitComplete) + .catch(eventsEmitter.emitError); + + return eventsEmitter; +} + +type FileTransferRequestObject = { + source: string; + destination: string; + login: string; + serverUri: string; +}; diff --git a/packages/teleterm/src/ui/appContext.ts b/packages/teleterm/src/ui/appContext.ts index 27f2673e1..139d5736d 100644 --- a/packages/teleterm/src/ui/appContext.ts +++ b/packages/teleterm/src/ui/appContext.ts @@ -22,9 +22,9 @@ import { ConnectionTrackerService } from 'teleterm/ui/services/connectionTracker import { QuickInputService } from 'teleterm/ui/services/quickInput'; import { StatePersistenceService } from 'teleterm/ui/services/statePersistence'; import { KeyboardShortcutsService } from 'teleterm/ui/services/keyboardShortcuts'; - import { WorkspacesService } from 'teleterm/ui/services/workspacesService/workspacesService'; import { NotificationsService } from 'teleterm/ui/services/notifications'; +import { FileTransferService } from 'teleterm/ui/services/fileTransferClient'; import { CommandLauncher } from './commandLauncher'; import { IAppContext } from './types'; @@ -41,10 +41,12 @@ export default class AppContext implements IAppContext { mainProcessClient: MainProcessClient; commandLauncher: CommandLauncher; connectionTracker: ConnectionTrackerService; + fileTransferService: FileTransferService; constructor(config: ElectronGlobals) { const { tshClient, ptyServiceClient, mainProcessClient } = config; this.mainProcessClient = mainProcessClient; + this.fileTransferService = new FileTransferService(tshClient); this.statePersistenceService = new StatePersistenceService( this.mainProcessClient.fileStorage ); diff --git a/packages/teleterm/src/ui/services/fileTransferClient/fileTransferService.ts b/packages/teleterm/src/ui/services/fileTransferClient/fileTransferService.ts new file mode 100644 index 000000000..23c92f371 --- /dev/null +++ b/packages/teleterm/src/ui/services/fileTransferClient/fileTransferService.ts @@ -0,0 +1,22 @@ +import { FileTransferListeners } from 'shared/components/FileTransfer'; + +import { TshClient, FileTransferRequest } from 'teleterm/services/tshd/types'; + +export class FileTransferService { + constructor(private tshClient: TshClient) {} + + transferFile( + options: FileTransferRequest, + abortController: AbortController + ): FileTransferListeners { + const abortSignal = { + addEventListener: (cb: (...args: any[]) => void) => { + abortController.signal.addEventListener('abort', cb); + }, + removeEventListener: (cb: (...args: any[]) => void) => { + abortController.signal.removeEventListener('abort', cb); + }, + }; + return this.tshClient.transferFile(options, abortSignal); + } +} diff --git a/packages/teleterm/src/ui/services/fileTransferClient/index.ts b/packages/teleterm/src/ui/services/fileTransferClient/index.ts new file mode 100644 index 000000000..e16ad26f4 --- /dev/null +++ b/packages/teleterm/src/ui/services/fileTransferClient/index.ts @@ -0,0 +1 @@ +export * from './fileTransferService'; diff --git a/packages/teleterm/src/ui/types.ts b/packages/teleterm/src/ui/types.ts index f23206e5c..30f25cea1 100644 --- a/packages/teleterm/src/ui/types.ts +++ b/packages/teleterm/src/ui/types.ts @@ -25,6 +25,7 @@ import { KeyboardShortcutsService } from 'teleterm/ui/services/keyboardShortcuts import { WorkspacesService } from 'teleterm/ui/services/workspacesService'; import { NotificationsService } from 'teleterm/ui/services/notifications'; import { ConnectionTrackerService } from 'teleterm/ui/services/connectionTracker'; +import { FileTransferService } from 'teleterm/ui/services/fileTransferClient'; export interface IAppContext { clustersService: ClustersService; @@ -38,6 +39,7 @@ export interface IAppContext { mainProcessClient: MainProcessClient; commandLauncher: CommandLauncher; connectionTracker: ConnectionTrackerService; + fileTransferService: FileTransferService; init(): Promise; }