From 48e90d256b6e1b4a957f4589195242884e42062b Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Fri, 23 Feb 2024 09:53:16 -0500 Subject: [PATCH] feat: renterd multipart remote uploads --- .changeset/ninety-toes-cheer.md | 5 + .changeset/short-ducks-press.md | 5 + .changeset/tame-wolves-nail.md | 5 + .../components/Files/FilesCmd/index.tsx | 4 +- .../Files/FilesExplorerModeButton.tsx | 19 +- .../Files/FilesExplorerModeContextMenu.tsx | 64 ++++++ .../Files/FilesSearchMenu/index.tsx | 4 +- .../EmptyState/StateNoneYet.tsx | 2 +- .../FilesFlat/EmptyState/StateNoneYet.tsx | 2 +- ...mbMenu.tsx => FilesFlatBreadcrumbMenu.tsx} | 13 +- apps/renterd/components/FilesFlat/index.tsx | 4 +- apps/renterd/components/Home/index.tsx | 2 +- apps/renterd/components/RenterdSidenav.tsx | 2 +- apps/renterd/components/TransferProgress.tsx | 35 ++++ apps/renterd/components/TransfersBar.tsx | 190 ++++++------------ apps/renterd/components/TransfersBarItem.tsx | 40 ++++ .../Uploads/EmptyState/StateError.tsx | 15 ++ .../Uploads/EmptyState/StateNoneMatching.tsx | 29 +++ .../Uploads/EmptyState/StateNoneYet.tsx | 29 +++ .../components/Uploads/EmptyState/index.tsx | 22 ++ .../components/Uploads/UploadContextMenu.tsx | 33 +++ .../components/Uploads/UploadsActionsMenu.tsx | 9 + .../Uploads/UploadsBreadcrumbMenu.tsx | 51 +++++ .../Uploads/UploadsStatsMenu/index.tsx | 18 ++ .../components/Uploads/UploadsTable.tsx | 31 +++ .../Uploads/UploadsViewDropdownMenu.tsx | 58 ++++++ apps/renterd/components/Uploads/index.tsx | 29 +++ apps/renterd/config/providers.tsx | 25 ++- apps/renterd/config/routes.ts | 6 +- .../contexts/filesManager/index.spec.tsx | 13 +- apps/renterd/contexts/filesManager/index.tsx | 67 +++--- apps/renterd/contexts/filesManager/types.ts | 8 +- .../renterd/contexts/filesManager/uploads.tsx | 3 +- apps/renterd/contexts/uploads/columns.tsx | 83 ++++++++ apps/renterd/contexts/uploads/index.tsx | 178 ++++++++++++++++ apps/renterd/contexts/uploads/types.ts | 30 +++ apps/renterd/mock/mock.tsx | 13 +- .../buckets/[bucket]/files/[[...path]].tsx | 5 + .../pages/buckets/[bucket]/uploads/index.tsx | 5 + .../[[...path]].tsx => buckets/index.tsx} | 0 .../src/components/PaginatorMarker.tsx | 1 + libs/react-renterd/src/bus.ts | 5 +- 42 files changed, 957 insertions(+), 205 deletions(-) create mode 100644 .changeset/ninety-toes-cheer.md create mode 100644 .changeset/short-ducks-press.md create mode 100644 .changeset/tame-wolves-nail.md create mode 100644 apps/renterd/components/Files/FilesExplorerModeContextMenu.tsx rename apps/renterd/components/FilesFlat/{FilesBreadcrumbMenu.tsx => FilesFlatBreadcrumbMenu.tsx} (76%) create mode 100644 apps/renterd/components/TransferProgress.tsx create mode 100644 apps/renterd/components/TransfersBarItem.tsx create mode 100644 apps/renterd/components/Uploads/EmptyState/StateError.tsx create mode 100644 apps/renterd/components/Uploads/EmptyState/StateNoneMatching.tsx create mode 100644 apps/renterd/components/Uploads/EmptyState/StateNoneYet.tsx create mode 100644 apps/renterd/components/Uploads/EmptyState/index.tsx create mode 100644 apps/renterd/components/Uploads/UploadContextMenu.tsx create mode 100644 apps/renterd/components/Uploads/UploadsActionsMenu.tsx create mode 100644 apps/renterd/components/Uploads/UploadsBreadcrumbMenu.tsx create mode 100644 apps/renterd/components/Uploads/UploadsStatsMenu/index.tsx create mode 100644 apps/renterd/components/Uploads/UploadsTable.tsx create mode 100644 apps/renterd/components/Uploads/UploadsViewDropdownMenu.tsx create mode 100644 apps/renterd/components/Uploads/index.tsx create mode 100644 apps/renterd/contexts/uploads/columns.tsx create mode 100644 apps/renterd/contexts/uploads/index.tsx create mode 100644 apps/renterd/contexts/uploads/types.ts create mode 100644 apps/renterd/pages/buckets/[bucket]/files/[[...path]].tsx create mode 100644 apps/renterd/pages/buckets/[bucket]/uploads/index.tsx rename apps/renterd/pages/{files/[[...path]].tsx => buckets/index.tsx} (100%) diff --git a/.changeset/ninety-toes-cheer.md b/.changeset/ninety-toes-cheer.md new file mode 100644 index 000000000..2ff4fae22 --- /dev/null +++ b/.changeset/ninety-toes-cheer.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Buckets now have a third view for viewing all active uploads, both local and from other sessions. diff --git a/.changeset/short-ducks-press.md b/.changeset/short-ducks-press.md new file mode 100644 index 000000000..86d865e2b --- /dev/null +++ b/.changeset/short-ducks-press.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Remote file uploads can now be aborted from the uploads explorer. diff --git a/.changeset/tame-wolves-nail.md b/.changeset/tame-wolves-nail.md new file mode 100644 index 000000000..462ae5f07 --- /dev/null +++ b/.changeset/tame-wolves-nail.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The transfers bar now only lists downloads and shows two buttons with one navigating to the new uploads list. diff --git a/apps/renterd/components/Files/FilesCmd/index.tsx b/apps/renterd/components/Files/FilesCmd/index.tsx index 131d89e95..353c70521 100644 --- a/apps/renterd/components/Files/FilesCmd/index.tsx +++ b/apps/renterd/components/Files/FilesCmd/index.tsx @@ -50,8 +50,8 @@ export function FilesCmd({ currentPage={currentPage} commandPage={commandPage} onSelect={() => { - if (!router.pathname.startsWith(routes.files.index)) { - router.push(routes.files.index) + if (!router.pathname.startsWith(routes.buckets.index)) { + router.push(routes.buckets.index) } closeDialog() afterSelect() diff --git a/apps/renterd/components/Files/FilesExplorerModeButton.tsx b/apps/renterd/components/Files/FilesExplorerModeButton.tsx index 7030c89d0..7c2f6fe3f 100644 --- a/apps/renterd/components/Files/FilesExplorerModeButton.tsx +++ b/apps/renterd/components/Files/FilesExplorerModeButton.tsx @@ -1,10 +1,10 @@ import { Button, Tooltip } from '@siafoundation/design-system' -import { BucketIcon, Earth16, Folder16 } from '@siafoundation/react-icons' +import { BucketIcon } from '@siafoundation/react-icons' import { useFilesManager } from '../../contexts/filesManager' +import { FilesExplorerModeContextMenu } from './FilesExplorerModeContextMenu' export function FilesExplorerModeButton() { - const { isViewingBuckets, toggleExplorerMode, activeExplorerMode } = - useFilesManager() + const { isViewingBuckets } = useFilesManager() if (isViewingBuckets) { return ( @@ -18,16 +18,5 @@ export function FilesExplorerModeButton() { ) } - return ( - - ) + return } diff --git a/apps/renterd/components/Files/FilesExplorerModeContextMenu.tsx b/apps/renterd/components/Files/FilesExplorerModeContextMenu.tsx new file mode 100644 index 000000000..8be7e40c1 --- /dev/null +++ b/apps/renterd/components/Files/FilesExplorerModeContextMenu.tsx @@ -0,0 +1,64 @@ +import { + Button, + DropdownMenu, + DropdownMenuItem, + DropdownMenuLeftSlot, +} from '@siafoundation/design-system' +import { CloudUpload16, Earth16, Folder16 } from '@siafoundation/react-icons' +import { useFilesManager } from '../../contexts/filesManager' +import { useUploads } from '../../contexts/uploads' + +export function FilesExplorerModeContextMenu() { + const { activeExplorerMode, setExplorerModeDirectory, setExplorerModeFlat } = + useFilesManager() + const { isViewingUploads, navigateToUploads } = useUploads() + + return ( + + {isViewingUploads ? ( + + ) : activeExplorerMode === 'directory' ? ( + + ) : ( + + )} + + } + contentProps={{ + align: 'start', + side: 'bottom', + className: 'max-w-[300px]', + }} + > + + + + + Directory + + + + + + All files + + + + + + Uploads + + + ) +} diff --git a/apps/renterd/components/Files/FilesSearchMenu/index.tsx b/apps/renterd/components/Files/FilesSearchMenu/index.tsx index b0936f11a..142c1de8c 100644 --- a/apps/renterd/components/Files/FilesSearchMenu/index.tsx +++ b/apps/renterd/components/Files/FilesSearchMenu/index.tsx @@ -62,8 +62,8 @@ export function FilesSearchMenu({ panel }: Props) { beforeSelect() }} afterSelect={() => { - if (!pathname.startsWith(routes.files.index)) { - router.push(routes.files.index) + if (!pathname.startsWith(routes.buckets.index)) { + router.push(routes.buckets.index) } }} /> diff --git a/apps/renterd/components/FilesDirectory/EmptyState/StateNoneYet.tsx b/apps/renterd/components/FilesDirectory/EmptyState/StateNoneYet.tsx index 4cab099c4..db903c43e 100644 --- a/apps/renterd/components/FilesDirectory/EmptyState/StateNoneYet.tsx +++ b/apps/renterd/components/FilesDirectory/EmptyState/StateNoneYet.tsx @@ -16,7 +16,7 @@ export function StateNoneYet() { drag and drop files or click here to start uploading. { e.stopPropagation() }} diff --git a/apps/renterd/components/FilesFlat/EmptyState/StateNoneYet.tsx b/apps/renterd/components/FilesFlat/EmptyState/StateNoneYet.tsx index 5b4cc504e..365ede3e3 100644 --- a/apps/renterd/components/FilesFlat/EmptyState/StateNoneYet.tsx +++ b/apps/renterd/components/FilesFlat/EmptyState/StateNoneYet.tsx @@ -15,7 +15,7 @@ export function StateNoneYet() { The {activeBucket} bucket does not contain any files. { e.stopPropagation() }} diff --git a/apps/renterd/components/FilesFlat/FilesBreadcrumbMenu.tsx b/apps/renterd/components/FilesFlat/FilesFlatBreadcrumbMenu.tsx similarity index 76% rename from apps/renterd/components/FilesFlat/FilesBreadcrumbMenu.tsx rename to apps/renterd/components/FilesFlat/FilesFlatBreadcrumbMenu.tsx index 3185b3687..12b58a636 100644 --- a/apps/renterd/components/FilesFlat/FilesBreadcrumbMenu.tsx +++ b/apps/renterd/components/FilesFlat/FilesFlatBreadcrumbMenu.tsx @@ -3,7 +3,7 @@ import { ChevronRight16 } from '@siafoundation/react-icons' import { useFilesManager } from '../../contexts/filesManager' import { FilesExplorerModeButton } from '../Files/FilesExplorerModeButton' -export function FilesBreadcrumbMenu() { +export function FilesFlatBreadcrumbMenu() { const { activeBucketName: activeBucket, setActiveDirectory } = useFilesManager() @@ -33,6 +33,17 @@ export function FilesBreadcrumbMenu() { > {activeBucket} + + + + + All files + diff --git a/apps/renterd/components/FilesFlat/index.tsx b/apps/renterd/components/FilesFlat/index.tsx index 6bcbfba48..9b6c50f36 100644 --- a/apps/renterd/components/FilesFlat/index.tsx +++ b/apps/renterd/components/FilesFlat/index.tsx @@ -1,7 +1,7 @@ import { RenterdSidenav } from '../RenterdSidenav' import { routes } from '../../config/routes' import { useDialog } from '../../contexts/dialog' -import { FilesBreadcrumbMenu } from './FilesBreadcrumbMenu' +import { FilesFlatBreadcrumbMenu } from './FilesFlatBreadcrumbMenu' import { RenterdAuthedLayout } from '../RenterdAuthedLayout' import { FilesActionsMenu } from './FilesActionsMenu' import { FilesStatsMenu } from './FilesStatsMenu' @@ -16,7 +16,7 @@ export function FilesFlat() { navTitle={null} routes={routes} sidenav={} - nav={} + nav={} stats={} actions={} openSettings={() => openDialog('settings')} diff --git a/apps/renterd/components/Home/index.tsx b/apps/renterd/components/Home/index.tsx index a70a16a34..66876bab0 100644 --- a/apps/renterd/components/Home/index.tsx +++ b/apps/renterd/components/Home/index.tsx @@ -10,7 +10,7 @@ export function Home() { const { openDialog } = useDialog() useEffect(() => { - router.replace(routes.files.index) + router.replace(routes.buckets.index) }, [router]) return ( diff --git a/apps/renterd/components/RenterdSidenav.tsx b/apps/renterd/components/RenterdSidenav.tsx index 50a293884..5f0349be1 100644 --- a/apps/renterd/components/RenterdSidenav.tsx +++ b/apps/renterd/components/RenterdSidenav.tsx @@ -23,7 +23,7 @@ export function RenterdSidenav() { {/* */} - + diff --git a/apps/renterd/components/TransferProgress.tsx b/apps/renterd/components/TransferProgress.tsx new file mode 100644 index 000000000..1a39a72ea --- /dev/null +++ b/apps/renterd/components/TransferProgress.tsx @@ -0,0 +1,35 @@ +import { ProgressBar, Text } from '@siafoundation/design-system' +import { useMemo } from 'react' +import { upperFirst } from '@technically/lodash' + +function getProgress(transfer: { loaded?: number; size?: number }) { + return transfer.loaded !== undefined ? transfer.loaded / transfer.size : 1 +} + +type Props = { + loaded: number + size: number + status: string +} + +export function TransferProgress({ loaded, size, status }: Props) { + const progress = useMemo(() => getProgress({ loaded, size }), [loaded, size]) + return ( +
+ +
+ + {upperFirst(status)} + + + {(progress * 100).toFixed(0)}% + +
+
+ ) +} diff --git a/apps/renterd/components/TransfersBar.tsx b/apps/renterd/components/TransfersBar.tsx index 911f52dc8..f90e382c6 100644 --- a/apps/renterd/components/TransfersBar.tsx +++ b/apps/renterd/components/TransfersBar.tsx @@ -1,169 +1,99 @@ -import { - Button, - LoadingDots, - Panel, - ProgressBar, - ScrollArea, - Text, -} from '@siafoundation/design-system' -import { - Close16, - Download16, - Subtract24, - Upload16, -} from '@siafoundation/react-icons' +import { Button, Panel, ScrollArea, Text } from '@siafoundation/design-system' +import { Download16, Subtract24, Upload16 } from '@siafoundation/react-icons' import { useState } from 'react' import { useFilesManager } from '../contexts/filesManager' import { useAppSettings } from '@siafoundation/react-core' -import { upperFirst } from '@technically/lodash' - -function getProgress(transfer: { loaded?: number; size?: number }) { - return transfer.loaded !== undefined ? transfer.loaded / transfer.size : 1 -} +import { TransfersBarItem } from './TransfersBarItem' +import { useUploads } from '../contexts/uploads' export function TransfersBar() { const { isUnlockedAndAuthedRoute } = useAppSettings() - const { uploadsList, downloadsList, downloadCancel } = useFilesManager() + const { downloadsList, downloadCancel } = useFilesManager() + const { + pageCount: uploadsPageCount, + navigateToUploads, + isViewingUploads, + } = useUploads() const [maximized, setMaximized] = useState(true) - const uploadCount = uploadsList.length + const isActiveUploads = !!uploadsPageCount const downloadCount = downloadsList.length + const isActiveDownloads = !!downloadCount if (!isUnlockedAndAuthedRoute) { return null } - if (uploadCount === 0 && downloadCount === 0) { + if (!isActiveUploads && !isActiveDownloads) { return null } - if (maximized) { + const controls = ( +
+ {isActiveUploads && !isViewingUploads ? ( + + ) : null} + {isActiveDownloads ? ( + + ) : null} +
+ ) + + if (isActiveDownloads && maximized) { return ( -
+
- {uploadCount > 0 ? ( + {isActiveDownloads ? ( <>
- Active uploads ({uploadCount}) + Active downloads ({downloadCount})
- {uploadsList.map((upload) => { - const progress = getProgress(upload) - return ( -
-
- - {upload.path} - - -
- -
- - {upperFirst(upload.uploadStatus)} - - - {(progress * 100).toFixed(0)}% - -
-
- ) - })} - - ) : null} - {downloadCount > 0 ? ( - <> -
- - Active downloads ({downloadCount}) - - {uploadCount === 0 ? ( - - ) : null} -
- {downloadsList.map((download) => { - const progress = getProgress(download) - return ( -
-
- - {download.path} - - -
- -
- - {progress === 1 ? 'Processing' : 'Downloading'} - - - {(progress * 100).toFixed(0)}% - -
-
- ) - })} + {downloadsList.map((download) => ( + downloadCancel(download)} + abortTip="Cancel download" + /> + ))} ) : null}
+ {controls}
) } return ( -
- +
+ {controls}
) } diff --git a/apps/renterd/components/TransfersBarItem.tsx b/apps/renterd/components/TransfersBarItem.tsx new file mode 100644 index 000000000..1adaca72d --- /dev/null +++ b/apps/renterd/components/TransfersBarItem.tsx @@ -0,0 +1,40 @@ +import { Button, Text } from '@siafoundation/design-system' +import { Close16 } from '@siafoundation/react-icons' +import { TransferProgress } from './TransferProgress' + +type Props = { + loaded: number + size: number + path: string + abortTip: string + abort?: () => void + status: string +} + +export function TransfersBarItem({ + loaded, + size, + path, + abortTip, + abort, + status, +}: Props) { + return ( +
+
+ + {path} + + +
+ +
+ ) +} diff --git a/apps/renterd/components/Uploads/EmptyState/StateError.tsx b/apps/renterd/components/Uploads/EmptyState/StateError.tsx new file mode 100644 index 000000000..00ae696e6 --- /dev/null +++ b/apps/renterd/components/Uploads/EmptyState/StateError.tsx @@ -0,0 +1,15 @@ +import { Text } from '@siafoundation/design-system' +import { MisuseOutline32 } from '@siafoundation/react-icons' + +export function StateError() { + return ( +
+ + + + + Error fetching uploads. + +
+ ) +} diff --git a/apps/renterd/components/Uploads/EmptyState/StateNoneMatching.tsx b/apps/renterd/components/Uploads/EmptyState/StateNoneMatching.tsx new file mode 100644 index 000000000..8f33f359b --- /dev/null +++ b/apps/renterd/components/Uploads/EmptyState/StateNoneMatching.tsx @@ -0,0 +1,29 @@ +import { Button, Text } from '@siafoundation/design-system' +import { Filter32 } from '@siafoundation/react-icons' +import { useFilesManager } from '../../../contexts/filesManager' + +export function StateNoneMatching() { + const { filters, resetFilters } = useFilesManager() + return ( +
+ + + +
+ + No uploads matching filters. + + {!!filters.length && ( + + )} +
+
+ ) +} diff --git a/apps/renterd/components/Uploads/EmptyState/StateNoneYet.tsx b/apps/renterd/components/Uploads/EmptyState/StateNoneYet.tsx new file mode 100644 index 000000000..4bdc3b43c --- /dev/null +++ b/apps/renterd/components/Uploads/EmptyState/StateNoneYet.tsx @@ -0,0 +1,29 @@ +import { Code, LinkButton, Text } from '@siafoundation/design-system' +import { CloudUpload32 } from '@siafoundation/react-icons' +import { routes } from '../../../config/routes' +import { useFilesManager } from '../../../contexts/filesManager' + +export function StateNoneYet() { + const { activeBucketName: activeBucket } = useFilesManager() + return ( +
+ + + +
+ + The {activeBucket} bucket does not have any active + uploads. + + { + e.stopPropagation() + }} + > + View buckets list + +
+
+ ) +} diff --git a/apps/renterd/components/Uploads/EmptyState/index.tsx b/apps/renterd/components/Uploads/EmptyState/index.tsx new file mode 100644 index 000000000..a6ccb146e --- /dev/null +++ b/apps/renterd/components/Uploads/EmptyState/index.tsx @@ -0,0 +1,22 @@ +import { StateError } from './StateError' +import { StateNoneMatching } from './StateNoneMatching' +import { StateNoneYet } from './StateNoneYet' +import { useUploads } from '../../../contexts/uploads' + +export function EmptyState() { + const { dataState } = useUploads() + + if (dataState === 'noneMatchingFilters') { + return + } + + if (dataState === 'error') { + return + } + + if (dataState === 'noneYet') { + return + } + + return null +} diff --git a/apps/renterd/components/Uploads/UploadContextMenu.tsx b/apps/renterd/components/Uploads/UploadContextMenu.tsx new file mode 100644 index 000000000..d6baa15df --- /dev/null +++ b/apps/renterd/components/Uploads/UploadContextMenu.tsx @@ -0,0 +1,33 @@ +import { + DropdownMenu, + DropdownMenuItem, + Button, + DropdownMenuLeftSlot, + DropdownMenuLabel, +} from '@siafoundation/design-system' +import { CloudUpload16, Error16 } from '@siafoundation/react-icons' + +type Props = { + abort?: () => void +} + +export function UploadContextMenu({ abort }: Props) { + return ( + + + + } + contentProps={{ align: 'start' }} + > + Actions + + + + + Cancel upload + + + ) +} diff --git a/apps/renterd/components/Uploads/UploadsActionsMenu.tsx b/apps/renterd/components/Uploads/UploadsActionsMenu.tsx new file mode 100644 index 000000000..6c6803583 --- /dev/null +++ b/apps/renterd/components/Uploads/UploadsActionsMenu.tsx @@ -0,0 +1,9 @@ +import { UploadsViewDropdownMenu } from './UploadsViewDropdownMenu' + +export function UploadsActionsMenu() { + return ( +
+ +
+ ) +} diff --git a/apps/renterd/components/Uploads/UploadsBreadcrumbMenu.tsx b/apps/renterd/components/Uploads/UploadsBreadcrumbMenu.tsx new file mode 100644 index 000000000..2317aa120 --- /dev/null +++ b/apps/renterd/components/Uploads/UploadsBreadcrumbMenu.tsx @@ -0,0 +1,51 @@ +import { Text, ScrollArea } from '@siafoundation/design-system' +import { ChevronRight16 } from '@siafoundation/react-icons' +import { useFilesManager } from '../../contexts/filesManager' +import { FilesExplorerModeButton } from '../Files/FilesExplorerModeButton' + +export function UploadsBreadcrumbMenu() { + const { activeBucketName: activeBucket, setActiveDirectory } = + useFilesManager() + + return ( +
+ + +
+ setActiveDirectory(() => [])} + size="18" + weight="semibold" + className="flex items-center cursor-pointer" + noWrap + > + Buckets + + + + + setActiveDirectory(() => [activeBucket])} + size="18" + weight="semibold" + className="flex items-center cursor-pointer" + noWrap + > + {activeBucket} + + + + + + Uploads + +
+
+
+ ) +} diff --git a/apps/renterd/components/Uploads/UploadsStatsMenu/index.tsx b/apps/renterd/components/Uploads/UploadsStatsMenu/index.tsx new file mode 100644 index 000000000..50d7c560d --- /dev/null +++ b/apps/renterd/components/Uploads/UploadsStatsMenu/index.tsx @@ -0,0 +1,18 @@ +import { PaginatorMarker } from '@siafoundation/design-system' +import { useUploads } from '../../../contexts/uploads' + +export function UploadsStatsMenu() { + const { limit, pageCount, dataState, nextMarker, hasMore } = useUploads() + return ( +
+
+ +
+ ) +} diff --git a/apps/renterd/components/Uploads/UploadsTable.tsx b/apps/renterd/components/Uploads/UploadsTable.tsx new file mode 100644 index 000000000..65365434c --- /dev/null +++ b/apps/renterd/components/Uploads/UploadsTable.tsx @@ -0,0 +1,31 @@ +import { Table } from '@siafoundation/design-system' +import { EmptyState } from './EmptyState' +import { columns } from '../../contexts/uploads/columns' +import { useUploads } from '../../contexts/uploads' + +export function UploadsTable() { + const { + sortableColumns, + toggleSort, + datasetPage, + dataState, + sortField, + sortDirection, + } = useUploads() + return ( +
+ } + pageSize={10} + data={datasetPage} + columns={columns} + sortableColumns={sortableColumns} + sortField={sortField} + sortDirection={sortDirection} + toggleSort={toggleSort} + rowSize="dense" + /> + + ) +} diff --git a/apps/renterd/components/Uploads/UploadsViewDropdownMenu.tsx b/apps/renterd/components/Uploads/UploadsViewDropdownMenu.tsx new file mode 100644 index 000000000..8d7518d1f --- /dev/null +++ b/apps/renterd/components/Uploads/UploadsViewDropdownMenu.tsx @@ -0,0 +1,58 @@ +import { + Button, + PoolCombo, + Label, + Popover, + MenuItemRightSlot, + BaseMenuItem, +} from '@siafoundation/design-system' +import { CaretDown16, SettingsAdjust16 } from '@siafoundation/react-icons' +import { useUploads } from '../../contexts/uploads' + +export function UploadsViewDropdownMenu() { + const { + configurableColumns, + toggleColumnVisibility, + resetDefaultColumnVisibility, + enabledColumns, + } = useUploads() + return ( + + + View + + + } + contentProps={{ + align: 'end', + className: 'max-w-[300px]', + }} + > + + + + + + + + ({ + label: column.label, + value: column.id, + }))} + values={enabledColumns} + onChange={(value) => toggleColumnVisibility(value)} + /> + + + ) +} diff --git a/apps/renterd/components/Uploads/index.tsx b/apps/renterd/components/Uploads/index.tsx new file mode 100644 index 000000000..db4d7b226 --- /dev/null +++ b/apps/renterd/components/Uploads/index.tsx @@ -0,0 +1,29 @@ +import { RenterdSidenav } from '../RenterdSidenav' +import { routes } from '../../config/routes' +import { useDialog } from '../../contexts/dialog' +import { RenterdAuthedLayout } from '../RenterdAuthedLayout' +import { UploadsActionsMenu } from './UploadsActionsMenu' +import { UploadsStatsMenu } from './UploadsStatsMenu' +import { UploadsTable } from './UploadsTable' +import { UploadsBreadcrumbMenu } from './UploadsBreadcrumbMenu' + +export function Uploads() { + const { openDialog } = useDialog() + + return ( + } + nav={} + stats={} + actions={} + openSettings={() => openDialog('settings')} + > +
+ +
+
+ ) +} diff --git a/apps/renterd/config/providers.tsx b/apps/renterd/config/providers.tsx index 0747e8da5..7f0e4b633 100644 --- a/apps/renterd/config/providers.tsx +++ b/apps/renterd/config/providers.tsx @@ -11,6 +11,7 @@ import { KeysProvider } from '../contexts/keys' import { FilesFlatProvider } from '../contexts/filesFlat' import { FilesManagerProvider } from '../contexts/filesManager' import { FilesDirectoryProvider } from '../contexts/filesDirectory' +import { UploadsProvider } from '../contexts/uploads' type Props = { children: React.ReactNode @@ -25,18 +26,20 @@ export function Providers({ children }: Props) { - - - - {/* this is here so that dialogs can use all the other providers, + + + + + {/* this is here so that dialogs can use all the other providers, and the other providers can trigger dialogs */} - - - - {children} - - - + + + + {children} + + + + diff --git a/apps/renterd/config/routes.ts b/apps/renterd/config/routes.ts index cf3c32d52..f2071d328 100644 --- a/apps/renterd/config/routes.ts +++ b/apps/renterd/config/routes.ts @@ -2,8 +2,10 @@ import { busStateKey } from '@siafoundation/react-renterd' export const routes = { home: '/', - files: { - index: '/files', + buckets: { + index: '/buckets', + files: '/buckets/[bucket]/files/[path]', + uploads: '/buckets/[bucket]/uploads', }, config: { index: '/config', diff --git a/apps/renterd/contexts/filesManager/index.spec.tsx b/apps/renterd/contexts/filesManager/index.spec.tsx index 0da4d27d3..47fad187f 100644 --- a/apps/renterd/contexts/filesManager/index.spec.tsx +++ b/apps/renterd/contexts/filesManager/index.spec.tsx @@ -3,7 +3,11 @@ import { setupServer } from 'msw/node' import { NextAppCsr } from '@siafoundation/design-system' import { FilesManagerProvider, useFilesManager, FilesManagerState } from '.' import { useEffect } from 'react' -import { mockApiBusBuckets, mockMatchMedia } from '../../mock/mock' +import { + mockApiBusBuckets, + mockApiBusSettingRedundancy, + mockMatchMedia, +} from '../../mock/mock' // eslint-disable-next-line @typescript-eslint/no-var-requires const { usePathname, useAppRouter } = require('@siafoundation/next') @@ -25,6 +29,7 @@ const server = setupServer() beforeAll(() => { mockMatchMedia() mockApiBusBuckets(server) + mockApiBusSettingRedundancy(server) server.listen() }) beforeEach(() => { @@ -59,7 +64,7 @@ describe('filesManager', () => { const context = mountProvider() expect(context.state.activeExplorerMode).toBe('directory') act(() => { - context.state.toggleExplorerMode() + context.state.setExplorerModeFlat() }) waitFor(() => { expect(context.state.activeExplorerMode).toBe('flat') @@ -73,14 +78,14 @@ describe('filesManager', () => { usePathname.mockReturnValue('/files/foo/bar/baz') const context = mountProvider() act(() => { - context.state.toggleExplorerMode() + context.state.setExplorerModeFlat() }) waitFor(() => { expect(context.state.activeExplorerMode).toBe('flat') expect(context.state.fileNamePrefixFilter).toBe('/foo/bar/baz') }) act(() => { - context.state.toggleExplorerMode() + context.state.setExplorerModeDirectory() }) waitFor(() => { expect(context.state.activeExplorerMode).toBe('directory') diff --git a/apps/renterd/contexts/filesManager/index.tsx b/apps/renterd/contexts/filesManager/index.tsx index 0085578e9..fd04e80a0 100644 --- a/apps/renterd/contexts/filesManager/index.tsx +++ b/apps/renterd/contexts/filesManager/index.tsx @@ -45,7 +45,8 @@ function useFilesManagerMain() { defaultSortField, }) const router = useAppRouter() - const params = useParams<{ path: FullPathSegments }>() + const params = useParams<{ bucket?: string; path?: FullPathSegments }>() + const activeBucketName = params?.bucket const { filters, setFilter, removeFilter, removeLastFilter, resetFilters } = useServerFilters() const fileNamePrefixFilter = useMemo(() => { @@ -54,15 +55,13 @@ function useFilesManagerMain() { }, [filters]) // [bucket, key, directory] - const activeDirectory = useMemo( - () => (params?.path || []).map(decodeURIComponent), - [params?.path] - ) + const activeDirectory = useMemo(() => { + if (!activeBucketName) return [] + const path = (params?.path || []).map(decodeURIComponent) + return [activeBucketName, ...path] + }, [activeBucketName, params?.path]) // bucket - const activeBucketName = useMemo(() => { - return activeDirectory[0] - }, [activeDirectory]) const buckets = useBuckets() const activeBucket = buckets.data?.find((b) => b.name === activeBucketName) @@ -79,15 +78,22 @@ function useFilesManagerMain() { const setActiveDirectory = useCallback( (fn: (activeDirectory: FullPathSegments) => FullPathSegments) => { const nextActiveDirectory = fn(activeDirectory) - const route = `${routes.files.index}/${nextActiveDirectory - .map(encodeURIComponent) - .join('/')}` + if (nextActiveDirectory.length === 0) { + router.push(routes.buckets.index) + return + } + const route = routes.buckets.files + .replace('[bucket]', nextActiveDirectory[0]) + .replace( + '[path]', + nextActiveDirectory.slice(1).map(encodeURIComponent).join('/') + ) router.push(route) }, [router, activeDirectory] ) - const { uploadFiles, uploadsList } = useUploads({ + const { uploadFiles, uploadsMap, uploadsList } = useUploads({ activeDirectoryPath, }) const { downloadFiles, downloadsList, getFileUrl, downloadCancel } = @@ -141,21 +147,32 @@ function useFilesManagerMain() { ] ) - const toggleExplorerMode = useCallback(async () => { - const nextMode = activeExplorerMode === 'directory' ? 'flat' : 'directory' - if (nextMode === 'flat') { - setActiveDirectoryAndFileNamePrefix( - [activeBucketName], - getKeyFromPath(activeDirectoryPath).slice(1) - ) - } else { - setActiveDirectoryAndFileNamePrefix([activeBucketName], '') + const setExplorerModeDirectory = useCallback(async () => { + if (activeExplorerMode === 'directory') { + return } - setActiveExplorerMode(nextMode) + setActiveDirectoryAndFileNamePrefix([activeBucketName], '') + setActiveExplorerMode('directory') }, [ + activeExplorerMode, activeBucketName, - activeDirectoryPath, + setActiveExplorerMode, + setActiveDirectoryAndFileNamePrefix, + ]) + + const setExplorerModeFlat = useCallback(async () => { + if (activeExplorerMode === 'flat') { + return + } + setActiveDirectoryAndFileNamePrefix( + [activeBucketName], + getKeyFromPath(activeDirectoryPath).slice(1) + ) + setActiveExplorerMode('flat') + }, [ activeExplorerMode, + activeBucketName, + activeDirectoryPath, setActiveExplorerMode, setActiveDirectoryAndFileNamePrefix, ]) @@ -173,6 +190,7 @@ function useFilesManagerMain() { activeDirectoryPath, navigateToModeSpecificFiltering, uploadFiles, + uploadsMap, uploadsList, downloadFiles, downloadsList, @@ -198,7 +216,8 @@ function useFilesManagerMain() { resetDefaultColumnVisibility, getFileUrl, activeExplorerMode, - toggleExplorerMode, + setExplorerModeDirectory, + setExplorerModeFlat, } } diff --git a/apps/renterd/contexts/filesManager/types.ts b/apps/renterd/contexts/filesManager/types.ts index 33b45f185..360d3030e 100644 --- a/apps/renterd/contexts/filesManager/types.ts +++ b/apps/renterd/contexts/filesManager/types.ts @@ -83,14 +83,12 @@ export type ExplorerMode = 'directory' | 'flat' export type UploadStatus = 'queued' | 'uploading' | 'processing' export type ObjectUploadData = ObjectData & { - upload: MultipartUpload + upload?: MultipartUpload uploadStatus: UploadStatus uploadAbort?: () => Promise uploadFile?: File -} - -export type ObjectUploadRemoteData = ObjectData & { - remote: true + remote?: boolean + createdAt: string } export type UploadsMap = Record diff --git a/apps/renterd/contexts/filesManager/uploads.tsx b/apps/renterd/contexts/filesManager/uploads.tsx index 95a0454c5..dd88bc2b0 100644 --- a/apps/renterd/contexts/filesManager/uploads.tsx +++ b/apps/renterd/contexts/filesManager/uploads.tsx @@ -141,7 +141,7 @@ export function useUploads({ activeDirectoryPath }: Props) { loaded: progress.sent, size: progress.total, }) - }, 200) + }, 1000) ) multipartUpload.setOnComplete(async () => { await mutate((key) => key.startsWith('/bus/objects')) @@ -197,6 +197,7 @@ export function useUploads({ activeDirectoryPath }: Props) { upload: multipartUpload, uploadStatus: 'queued', uploadFile: uploadFile, + createdAt: new Date().toISOString(), uploadAbort: async () => { await multipartUpload.abort() removeUpload(uploadId) diff --git a/apps/renterd/contexts/uploads/columns.tsx b/apps/renterd/contexts/uploads/columns.tsx new file mode 100644 index 000000000..690c9b7b2 --- /dev/null +++ b/apps/renterd/contexts/uploads/columns.tsx @@ -0,0 +1,83 @@ +import { Text, ValueCopyable } from '@siafoundation/design-system' +import { UploadsTableColumn } from './types' +import { getKeyFromPath } from '../../lib/paths' +import { TransferProgress } from '../../components/TransferProgress' +import { humanBytes } from '@siafoundation/units' +import { formatRelative } from 'date-fns' +import { UploadContextMenu } from '../../components/Uploads/UploadContextMenu' + +export const columns: UploadsTableColumn[] = [ + { + id: 'actions', + label: '', + fixed: true, + cellClassName: 'w-[50px] !pl-2 !pr-2 [&+*]:!pl-0', + render: function ActionsColumn({ data: { uploadAbort } }) { + return + }, + }, + { + id: 'path', + label: 'path', + contentClassName: 'max-w-[600px]', + category: 'general', + render: function PathColumn({ data: { path, id } }) { + const key = getKeyFromPath(path).slice(1) + return ( +
+ + {key} + + +
+ ) + }, + }, + { + id: 'status', + label: 'status', + category: 'general', + contentClassName: 'w-[200px]', + render: function StatusColumn({ + data: { loaded, size, uploadStatus, remote }, + }) { + if (remote) { + return ( + + Uploading from a different session + + ) + } + return ( + + ) + }, + }, + { + id: 'size', + label: 'size', + category: 'general', + render: function SizeColumn({ data: { remote, size } }) { + if (remote) { + return null + } + return ( + + {humanBytes(size)} + + ) + }, + }, + { + id: 'createdAt', + label: 'started at', + category: 'general', + render: function SizeColumn({ data: { createdAt } }) { + return ( + + {formatRelative(new Date(createdAt).getTime(), new Date())} + + ) + }, + }, +] diff --git a/apps/renterd/contexts/uploads/index.tsx b/apps/renterd/contexts/uploads/index.tsx new file mode 100644 index 000000000..0b0ec0df6 --- /dev/null +++ b/apps/renterd/contexts/uploads/index.tsx @@ -0,0 +1,178 @@ +import { + useTableState, + useDatasetEmptyState, + useServerFilters, +} from '@siafoundation/design-system' +import { useAppRouter, usePathname, useSearchParams } from '@siafoundation/next' +import { + useMultipartUploadAbort, + useMultipartUploadListUploads, +} from '@siafoundation/react-renterd' +import { createContext, useCallback, useContext, useMemo } from 'react' +import { columnsDefaultVisible, defaultSortField, sortOptions } from './types' +import { columns } from './columns' +import { join, getFilename } from '../../lib/paths' +import { useFilesManager } from '../filesManager' +import { ObjectUploadData } from '../filesManager/types' +import { routes } from '../../config/routes' + +const defaultLimit = 50 + +function useUploadsMain() { + const { uploadsMap, activeBucket } = useFilesManager() + const router = useAppRouter() + const params = useSearchParams() + const limit = Number(params.get('limit') || defaultLimit) + const marker = params.get('marker') + + const { filters, setFilter, removeFilter, removeLastFilter, resetFilters } = + useServerFilters() + + const apiBusUploadAbort = useMultipartUploadAbort() + const response = useMultipartUploadListUploads({ + disabled: !activeBucket, + payload: { + bucket: activeBucket?.name, + uploadIDMarker: marker, + limit, + }, + }) + + const dataset: ObjectUploadData[] = useMemo(() => { + return ( + response.data?.uploads?.map((upload) => { + const id = upload.uploadID + const name = getFilename(upload.path) + const fullPath = join(activeBucket?.name, upload.path) + const localUpload = uploadsMap[id] + if (localUpload) { + { + return localUpload + } + } + return { + id, + path: fullPath, + bucket: activeBucket, + name, + size: 1, + loaded: 1, + isUploading: true, + uploadStatus: 'uploading', + createdAt: upload.createdAt, + remote: true, + type: 'file', + uploadAbort: async () => { + await apiBusUploadAbort.post({ + payload: { + bucket: activeBucket?.name, + path: upload.path, + uploadID: upload.uploadID, + }, + }) + }, + } + }) || [] + ) + }, [uploadsMap, activeBucket, response.data, apiBusUploadAbort]) + + console.log(dataset) + + const { + configurableColumns, + enabledColumns, + sortableColumns, + toggleColumnVisibility, + setColumnsVisible, + setColumnsHidden, + toggleSort, + setSortDirection, + setSortField, + sortField, + sortDirection, + resetDefaultColumnVisibility, + } = useTableState('renterd/v0/uploads', { + columns, + columnsDefaultVisible, + sortOptions, + defaultSortField, + }) + + const filteredTableColumns = useMemo( + () => + columns.filter( + (column) => column.fixed || enabledColumns.includes(column.id) + ), + [enabledColumns] + ) + + const dataState = useDatasetEmptyState( + dataset, + response.isValidating, + response.error, + filters + ) + + const uploadsRoute = routes.buckets.uploads.replace( + '[bucket]', + activeBucket?.name + ) + + const pathname = usePathname() + const isViewingUploads = activeBucket && pathname.startsWith(uploadsRoute) + + const navigateToUploads = useCallback(() => { + if (!activeBucket) { + return + } + router.push(uploadsRoute) + }, [activeBucket, uploadsRoute, router]) + + return { + navigateToUploads, + isViewingUploads, + dataState, + limit, + marker, + nextMarker: response.data?.nextUploadIDMarker, + hasMore: response.data?.hasMore, + isLoading: response.isLoading, + error: response.error, + pageCount: dataset?.length || 0, + columns: filteredTableColumns, + datasetPage: dataset, + configurableColumns, + enabledColumns, + sortableColumns, + toggleColumnVisibility, + setColumnsVisible, + setColumnsHidden, + toggleSort, + setSortDirection, + setSortField, + sortField, + filters, + setFilter, + removeFilter, + removeLastFilter, + resetFilters, + sortDirection, + resetDefaultColumnVisibility, + } +} + +type State = ReturnType + +const UploadsContext = createContext({} as State) +export const useUploads = () => useContext(UploadsContext) + +type Props = { + children: React.ReactNode +} + +export function UploadsProvider({ children }: Props) { + const state = useUploadsMain() + return ( + {children} + ) +} diff --git a/apps/renterd/contexts/uploads/types.ts b/apps/renterd/contexts/uploads/types.ts new file mode 100644 index 000000000..0c9593906 --- /dev/null +++ b/apps/renterd/contexts/uploads/types.ts @@ -0,0 +1,30 @@ +import { TableColumn } from '@siafoundation/design-system' +import { ObjectUploadData } from '../filesManager/types' + +export type TableColumnId = 'actions' | 'path' | 'status' | 'size' | 'createdAt' + +export type UploadsTableColumn = TableColumn< + TableColumnId, + ObjectUploadData, + never +> & { + fixed?: boolean + category?: string +} + +export const columnsDefaultVisible: TableColumnId[] = [ + 'path', + 'status', + 'size', + 'createdAt', +] + +export type SortField = 'path' + +export const defaultSortField: SortField = 'path' + +export const sortOptions: { + id: SortField + label: string + category: string +}[] = [] diff --git a/apps/renterd/mock/mock.tsx b/apps/renterd/mock/mock.tsx index 90bef6e72..c25334ded 100644 --- a/apps/renterd/mock/mock.tsx +++ b/apps/renterd/mock/mock.tsx @@ -1,6 +1,6 @@ import { SetupServer } from 'msw/node' import { HttpResponse, http } from 'msw' -import { Bucket } from '@siafoundation/react-renterd' +import { Bucket, RedundancySettings } from '@siafoundation/react-renterd' export function mockApiBusBuckets(server: SetupServer) { server.use( @@ -17,6 +17,17 @@ export function mockApiBusBuckets(server: SetupServer) { ) } +export function mockApiBusSettingRedundancy(server: SetupServer) { + server.use( + http.get('/api/bus/setting/redundancy', () => { + return HttpResponse.json({ + minShards: 10, + totalShards: 30, + } as RedundancySettings) + }) + ) +} + export function mockMatchMedia() { window.matchMedia = jest.fn().mockImplementation((query) => ({ matches: false, diff --git a/apps/renterd/pages/buckets/[bucket]/files/[[...path]].tsx b/apps/renterd/pages/buckets/[bucket]/files/[[...path]].tsx new file mode 100644 index 000000000..627904628 --- /dev/null +++ b/apps/renterd/pages/buckets/[bucket]/files/[[...path]].tsx @@ -0,0 +1,5 @@ +import { Files } from '../../../../components/Files' + +export default function FilesPage() { + return +} diff --git a/apps/renterd/pages/buckets/[bucket]/uploads/index.tsx b/apps/renterd/pages/buckets/[bucket]/uploads/index.tsx new file mode 100644 index 000000000..50d1a997f --- /dev/null +++ b/apps/renterd/pages/buckets/[bucket]/uploads/index.tsx @@ -0,0 +1,5 @@ +import { Uploads } from '../../../../components/Uploads' + +export default function UploadsPage() { + return +} diff --git a/apps/renterd/pages/files/[[...path]].tsx b/apps/renterd/pages/buckets/index.tsx similarity index 100% rename from apps/renterd/pages/files/[[...path]].tsx rename to apps/renterd/pages/buckets/index.tsx diff --git a/libs/design-system/src/components/PaginatorMarker.tsx b/libs/design-system/src/components/PaginatorMarker.tsx index cd26409c4..63fb1b6b3 100644 --- a/libs/design-system/src/components/PaginatorMarker.tsx +++ b/libs/design-system/src/components/PaginatorMarker.tsx @@ -28,6 +28,7 @@ export function PaginatorMarker({ size="small" variant="gray" className="rounded-r-none" + disabled={!marker} onClick={() => router.push({ query: { diff --git a/libs/react-renterd/src/bus.ts b/libs/react-renterd/src/bus.ts index 87db25b06..2e0046fbc 100644 --- a/libs/react-renterd/src/bus.ts +++ b/libs/react-renterd/src/bus.ts @@ -952,7 +952,10 @@ export type MultipartUploadListUploadsPayload = { } export type MultipartUploadListUploadsResponse = { - uploads: { + hasMore: boolean + nextMarker: string + nextUploadIDMarker: string + uploads?: { path: string uploadID: string createdAt: string