diff --git a/.changeset/calm-oranges-think.md b/.changeset/calm-oranges-think.md new file mode 100644 index 000000000..fc322f9ef --- /dev/null +++ b/.changeset/calm-oranges-think.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/react-renterd': minor +--- + +Added useBuckets, useBucketCreate, useBucketDelete, and bucket support to all object hooks. diff --git a/.changeset/fuzzy-ears-pay.md b/.changeset/fuzzy-ears-pay.md new file mode 100644 index 000000000..28ad22cca --- /dev/null +++ b/.changeset/fuzzy-ears-pay.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The not enough active contracts warning no longer flickers. diff --git a/.changeset/khaki-pianos-cry.md b/.changeset/khaki-pianos-cry.md new file mode 100644 index 000000000..67719a040 --- /dev/null +++ b/.changeset/khaki-pianos-cry.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The files feature now supports buckets. diff --git a/apps/explorer/components/Home/index.tsx b/apps/explorer/components/Home/index.tsx index ce080a3ac..e181fdb6c 100644 --- a/apps/explorer/components/Home/index.tsx +++ b/apps/explorer/components/Home/index.tsx @@ -35,38 +35,17 @@ export function Home({ hosts: SiaCentralHost[] rates: SiaCentralExchangeRates }) { - const exchange = useExchangeRate() + const exchange = useExchangeRate(rates) const values = useMemo(() => { const list = [ { label: 'Blockchain height', value: ( -
- {humanNumber(blockHeight)} - {/* {status.data && - status.data.consensusblock !== status.data.lastblock && ( - <> - - / - - - - {humanNumber(status.data?.consensusblock)} - - - - )} */} -
+ + + {humanNumber(blockHeight)} + + ), }, { @@ -133,16 +112,23 @@ export function Home({ label: 'Average storage price', value: ( - - {getStorageCost({ - price: metrics?.average.settings.storage_price, - exchange, - })} - +
+ + {getStorageCost({ + price: metrics?.average.settings.storage_price, + exchange, + })} + + + {getStorageCost({ + price: metrics?.average.settings.storage_price, + })} + +
), }, @@ -150,16 +136,23 @@ export function Home({ label: 'Average download price', value: ( - - {getDownloadCost({ - price: metrics?.average.settings.download_price, - exchange, - })} - +
+ + {getDownloadCost({ + price: metrics?.average.settings.download_price, + exchange, + })} + + + {getDownloadCost({ + price: metrics?.average.settings.download_price, + })} + +
), }, @@ -167,16 +160,23 @@ export function Home({ label: 'Average upload price', value: ( - - {getUploadCost({ - price: metrics?.average.settings.upload_price, - exchange, - })} - +
+ + {getUploadCost({ + price: metrics?.average.settings.upload_price, + exchange, + })} + + + {getUploadCost({ + price: metrics?.average.settings.upload_price, + })} + +
), }, @@ -187,7 +187,7 @@ export function Home({ return ( +
{values.map(({ label, value }) => (
{label} - - {value} - + {value}
))}
diff --git a/apps/explorer/components/HomeSkeleton/index.tsx b/apps/explorer/components/HomeSkeleton/index.tsx index a333ec4e0..c989db229 100644 --- a/apps/explorer/components/HomeSkeleton/index.tsx +++ b/apps/explorer/components/HomeSkeleton/index.tsx @@ -5,13 +5,13 @@ export function HomeSkeleton() { return ( - - - - - - +
+ + + + + +
} > diff --git a/apps/explorer/lib/host.ts b/apps/explorer/lib/host.ts index 97ff9f6aa..c23c282ce 100644 --- a/apps/explorer/lib/host.ts +++ b/apps/explorer/lib/host.ts @@ -19,7 +19,7 @@ export function getStorageCost({ price, exchange }: Props) { .times(monthsToBlocks(1)) .div(1e24) .times(exchange.rate || 1) - .toFixed(2)}/TB` + .toFormat(2)}/TB` : `${humanSiacoin( new BigNumber(price).times(TBToBytes(1)).times(monthsToBlocks(1)), { fixed: 3 } @@ -32,7 +32,7 @@ export function getDownloadCost({ price, exchange }: Props) { .times(TBToBytes(1)) .div(1e24) .times(exchange.rate || 1) - .toFixed(2)}/TB` + .toFormat(2)}/TB` : `${humanSiacoin(new BigNumber(price).times(TBToBytes(1)), { fixed: 3, })}/TB` @@ -44,7 +44,7 @@ export function getUploadCost({ price, exchange }: Props) { .times(TBToBytes(1)) .div(1e24) .times(exchange.rate || 1) - .toFixed(2)}/TB` + .toFormat(2)}/TB` : `${humanSiacoin(new BigNumber(price).times(TBToBytes(1)), { fixed: 3, })}/TB` diff --git a/apps/renterd/components/Files/BucketContextMenu.tsx b/apps/renterd/components/Files/BucketContextMenu.tsx new file mode 100644 index 000000000..6c7998d61 --- /dev/null +++ b/apps/renterd/components/Files/BucketContextMenu.tsx @@ -0,0 +1,41 @@ +import { + DropdownMenu, + DropdownMenuItem, + Button, + DropdownMenuLeftSlot, + Delete16, + DropdownMenuLabel, + BucketIcon, +} from '@siafoundation/design-system' +import { useDialog } from '../../contexts/dialog' + +type Props = { + name: string +} + +export function BucketContextMenu({ name }: Props) { + const { openDialog } = useDialog() + return ( + + + + } + contentProps={{ align: 'start' }} + > + Actions + { + openDialog('filesDeleteBucket', name) + }} + > + + + + Delete bucket + + + ) +} diff --git a/apps/renterd/components/Files/Columns/FilesHealthColumn/FilesHealthColumnContents.tsx b/apps/renterd/components/Files/Columns/FilesHealthColumn/FilesHealthColumnContents.tsx index 48bc84a46..55a7e4e19 100644 --- a/apps/renterd/components/Files/Columns/FilesHealthColumn/FilesHealthColumnContents.tsx +++ b/apps/renterd/components/Files/Columns/FilesHealthColumn/FilesHealthColumnContents.tsx @@ -10,19 +10,19 @@ import { sortBy } from 'lodash' import { computeSlabContractSetShards } from '../../../../contexts/files/health' import { ObjectData } from '../../../../contexts/files/types' import { useHealthLabel } from '../../../../hooks/useHealthLabel' +import { bucketAndKeyParamsFromPath } from '../../../../contexts/files/paths' export function FilesHealthColumnContents({ path, isUploading, - isDirectory, + type, health: _health, size, }: ObjectData) { + const isDirectory = type === 'directory' const obj = useObject({ disabled: isUploading || isDirectory, - params: { - key: path.slice(1), - }, + params: bucketAndKeyParamsFromPath(path), config: { swr: { dedupingInterval: 5000, diff --git a/apps/renterd/components/Files/Columns/FilesHealthColumn/index.tsx b/apps/renterd/components/Files/Columns/FilesHealthColumn/index.tsx index 4bcaf5e74..3e5cc19b5 100644 --- a/apps/renterd/components/Files/Columns/FilesHealthColumn/index.tsx +++ b/apps/renterd/components/Files/Columns/FilesHealthColumn/index.tsx @@ -4,7 +4,8 @@ import { useHealthLabel } from '../../../../hooks/useHealthLabel' import { FilesHealthColumnContents } from './FilesHealthColumnContents' export function FilesHealthColumn(props: ObjectData) { - const { name, isUploading, isDirectory, health: _health, size } = props + const { name, isUploading, type, health: _health, size } = props + const isDirectory = type === 'directory' const { displayHealth, label, color, icon } = useHealthLabel({ health: _health, size, diff --git a/apps/renterd/components/Files/StateError.tsx b/apps/renterd/components/Files/EmptyState/StateError.tsx similarity index 100% rename from apps/renterd/components/Files/StateError.tsx rename to apps/renterd/components/Files/EmptyState/StateError.tsx diff --git a/apps/renterd/components/Files/StateNoneMatching.tsx b/apps/renterd/components/Files/EmptyState/StateNoneMatching.tsx similarity index 100% rename from apps/renterd/components/Files/StateNoneMatching.tsx rename to apps/renterd/components/Files/EmptyState/StateNoneMatching.tsx diff --git a/apps/renterd/components/Files/StateNoneYet.tsx b/apps/renterd/components/Files/EmptyState/StateNoneYet.tsx similarity index 100% rename from apps/renterd/components/Files/StateNoneYet.tsx rename to apps/renterd/components/Files/EmptyState/StateNoneYet.tsx diff --git a/apps/renterd/components/Files/EmptyState.tsx b/apps/renterd/components/Files/EmptyState/index.tsx similarity index 86% rename from apps/renterd/components/Files/EmptyState.tsx rename to apps/renterd/components/Files/EmptyState/index.tsx index 18175ca67..4203ce8f4 100644 --- a/apps/renterd/components/Files/EmptyState.tsx +++ b/apps/renterd/components/Files/EmptyState/index.tsx @@ -1,14 +1,14 @@ import { CloudUpload32, LinkButton, Text } from '@siafoundation/design-system' -import { routes } from '../../config/routes' -import { useFiles } from '../../contexts/files' -import { useAutopilotNotConfigured } from './checks/useAutopilotNotConfigured' -import { useNotEnoughContracts } from './checks/useNotEnoughContracts' +import { routes } from '../../../config/routes' +import { useFiles } from '../../../contexts/files' +import { useAutopilotNotConfigured } from '../checks/useAutopilotNotConfigured' +import { useNotEnoughContracts } from '../checks/useNotEnoughContracts' import { StateError } from './StateError' import { StateNoneMatching } from './StateNoneMatching' import { StateNoneYet } from './StateNoneYet' export function EmptyState() { - const { dataState, activeDirectoryPath } = useFiles() + const { dataState, isViewingRootOfABucket } = useFiles() const autopilotNotConfigured = useAutopilotNotConfigured() const notEnoughContracts = useNotEnoughContracts() @@ -23,7 +23,7 @@ export function EmptyState() { // only show on root directory and when there are no files if ( - activeDirectoryPath === '/' && + isViewingRootOfABucket && dataState === 'noneYet' && autopilotNotConfigured.active ) { @@ -48,7 +48,7 @@ export function EmptyState() { // only show on root directory and when there are no files if ( - activeDirectoryPath === '/' && + isViewingRootOfABucket && dataState === 'noneYet' && notEnoughContracts.active ) { diff --git a/apps/renterd/components/Files/FileContextMenu/CopyMetadataMenuItem.tsx b/apps/renterd/components/Files/FileContextMenu/CopyMetadataMenuItem.tsx index 8179981a1..10bd8c46e 100644 --- a/apps/renterd/components/Files/FileContextMenu/CopyMetadataMenuItem.tsx +++ b/apps/renterd/components/Files/FileContextMenu/CopyMetadataMenuItem.tsx @@ -5,6 +5,7 @@ import { Copy16, } from '@siafoundation/design-system' import { useObject } from '@siafoundation/react-renterd' +import { bucketAndKeyParamsFromPath } from '../../../contexts/files/paths' type Props = { path: string @@ -14,9 +15,7 @@ type Props = { // specific one when the user triggers the context menu. export function CopyMetadataMenuItem({ path }: Props) { const obj = useObject({ - params: { - key: path.slice(1), - }, + params: bucketAndKeyParamsFromPath(path), config: { swr: { dedupingInterval: 5000, diff --git a/apps/renterd/components/Files/FileContextMenu/index.tsx b/apps/renterd/components/Files/FileContextMenu/index.tsx index c09f29db4..a083a4d25 100644 --- a/apps/renterd/components/Files/FileContextMenu/index.tsx +++ b/apps/renterd/components/Files/FileContextMenu/index.tsx @@ -19,11 +19,10 @@ import { useFileDelete } from '../useFileDelete' import { CopyMetadataMenuItem } from './CopyMetadataMenuItem' type Props = { - name: string path: string } -export function FileContextMenu({ name, path }: Props) { +export function FileContextMenu({ path }: Props) { const { downloadFiles, getFileUrl } = useFiles() const deleteFile = useFileDelete() @@ -39,7 +38,7 @@ export function FileContextMenu({ name, path }: Props) { Actions { - downloadFiles([name]) + downloadFiles([path]) }} > diff --git a/apps/renterd/components/Files/FilesActionsMenu.tsx b/apps/renterd/components/Files/FilesActionsMenu.tsx index 8694f8482..cd219a237 100644 --- a/apps/renterd/components/Files/FilesActionsMenu.tsx +++ b/apps/renterd/components/Files/FilesActionsMenu.tsx @@ -1,4 +1,5 @@ import { + Add16, Button, CloudUpload16, FolderAdd16, @@ -12,7 +13,7 @@ import { useCanUpload } from './useCanUpload' export function FilesActionsMenu() { const { openDialog } = useDialog() - const { uploadFiles } = useFiles() + const { uploadFiles, isViewingBuckets } = useFiles() const canUpload = useCanUpload() @@ -24,20 +25,33 @@ export function FilesActionsMenu() { return (
- - - + {isViewingBuckets ? ( + + ) : ( + <> + + + + + )}
) diff --git a/apps/renterd/components/Files/FilesBreadcrumbMenu.tsx b/apps/renterd/components/Files/FilesBreadcrumbMenu.tsx index 11b0f23dd..3258e4b41 100644 --- a/apps/renterd/components/Files/FilesBreadcrumbMenu.tsx +++ b/apps/renterd/components/Files/FilesBreadcrumbMenu.tsx @@ -27,7 +27,7 @@ export function FilesBreadcrumbMenu() { > Files - {!!activeDirectory.length && ( + {activeDirectory.length > 0 && ( diff --git a/apps/renterd/components/Files/FilesBucketDeleteDialog.tsx b/apps/renterd/components/Files/FilesBucketDeleteDialog.tsx new file mode 100644 index 000000000..6d4d2ff8e --- /dev/null +++ b/apps/renterd/components/Files/FilesBucketDeleteDialog.tsx @@ -0,0 +1,114 @@ +import { + Paragraph, + Dialog, + triggerErrorToast, + triggerSuccessToast, + ConfigFields, + useOnInvalid, + FormSubmitButton, + FieldText, + Code, +} from '@siafoundation/design-system' +import { useCallback, useMemo } from 'react' +import { useForm } from 'react-hook-form' +import { useDialog } from '../../contexts/dialog' +import { useBucketDelete } from '@siafoundation/react-renterd' + +const defaultValues = { + name: '', +} + +function getFields(name: string): ConfigFields { + return { + name: { + type: 'text', + title: 'Name', + placeholder: name, + validation: { + required: 'required', + validate: { + notDefault: () => + name === 'default' || 'cannot delete default bucket', + equals: (value) => value === name || 'bucket name does not match', + }, + }, + }, + } +} + +type Props = { + trigger?: React.ReactNode + open: boolean + onOpenChange: (val: boolean) => void +} + +export function FilesBucketDeleteDialog({ + trigger, + open, + onOpenChange, +}: Props) { + const { id: name, closeDialog } = useDialog() + + const bucketDelete = useBucketDelete() + const form = useForm({ + mode: 'all', + defaultValues, + }) + + const onSubmit = useCallback( + async (values: typeof defaultValues) => { + const response = await bucketDelete.delete({ + params: { + name: values.name, + }, + }) + if (response.error) { + triggerErrorToast(response.error) + } else { + triggerSuccessToast('Bucket permanently deleted.') + form.reset() + closeDialog() + } + }, + [form, bucketDelete, closeDialog] + ) + + const fields = useMemo(() => getFields(name), [name]) + + const onInvalid = useOnInvalid(fields) + + return ( + { + if (!val) { + form.reset(defaultValues) + } + onOpenChange(val) + }} + contentVariants={{ + className: 'w-[400px]', + }} + onSubmit={form.handleSubmit(onSubmit, onInvalid)} + > +
+ + Are you sure you would like to delete the following bucket and all the + contained files? + +
+ {name} +
+ + Enter the bucket name to confirm the removal. + + + + Delete + +
+
+ ) +} diff --git a/apps/renterd/components/Files/FilesCmd/FilesSearchCmd/index.tsx b/apps/renterd/components/Files/FilesCmd/FilesSearchCmd/index.tsx index 35c20a6d4..c6aece6f5 100644 --- a/apps/renterd/components/Files/FilesCmd/FilesSearchCmd/index.tsx +++ b/apps/renterd/components/Files/FilesCmd/FilesSearchCmd/index.tsx @@ -2,9 +2,9 @@ import { CommandGroup, CommandItemSearch } from '../../../CmdRoot/Item' import { Page } from '../../../CmdRoot/types' import { useObjectSearch } from '@siafoundation/react-renterd' import { - getDirectoryFromPath, + getDirectorySegmentsFromPath, isDirectory, -} from '../../../../contexts/files/utils' +} from '../../../../contexts/files/paths' import { useFiles } from '../../../../contexts/files' import { Document16, FolderIcon, Text } from '@siafoundation/design-system' import { FileSearchEmpty } from './FileSearchEmpty' @@ -29,11 +29,12 @@ export function FilesSearchCmd({ beforeSelect?: () => void afterSelect?: () => void }) { - const { setActiveDirectory } = useFiles() + const { activeBucket, setActiveDirectory } = useFiles() const onSearchPage = currentPage?.namespace === filesSearchPage.namespace const results = useObjectSearch({ disabled: !onSearchPage, params: { + bucket: activeBucket || 'default', key: debouncedSearch, skip: 0, limit: 10, @@ -62,7 +63,7 @@ export function FilesSearchCmd({ key={path} onSelect={() => { beforeSelect() - setActiveDirectory(() => getDirectoryFromPath(path)) + setActiveDirectory(() => getDirectorySegmentsFromPath(path)) afterSelect() }} value={path} diff --git a/apps/renterd/components/Files/FilesCreateBucketDialog.tsx b/apps/renterd/components/Files/FilesCreateBucketDialog.tsx new file mode 100644 index 000000000..3259e5122 --- /dev/null +++ b/apps/renterd/components/Files/FilesCreateBucketDialog.tsx @@ -0,0 +1,89 @@ +import { + Dialog, + FormFieldFormik, + FormSubmitButtonFormik, + triggerErrorToast, + triggerToast, +} from '@siafoundation/design-system' +import { useBucketCreate } from '@siafoundation/react-renterd' +import { useFormik } from 'formik' +import * as Yup from 'yup' + +const initialValues = { + name: '', +} + +const validationSchema = Yup.object().shape({ + name: Yup.string().required('Required'), +}) + +type Props = { + trigger?: React.ReactNode + open: boolean + onOpenChange: (val: boolean) => void +} + +export function FilesCreateBucketDialog({ + trigger, + open, + onOpenChange, +}: Props) { + const bucketCreate = useBucketCreate() + + const formik = useFormik({ + initialValues, + validationSchema, + onSubmit: async (values, actions) => { + const response = await bucketCreate.post({ + payload: { + name: values.name, + }, + }) + if (response.error) { + triggerErrorToast(response.error) + } else { + triggerToast('Directory created.') + actions.resetForm() + onOpenChange(false) + } + }, + }) + + return ( + { + if (!open) { + formik.resetForm() + } + onOpenChange(open) + }} + contentVariants={{ + className: 'w-[400px]', + }} + > +
+
+
+ + + Create + +
+
+
+
+ ) +} diff --git a/apps/renterd/components/Files/FilesCreateDirectoryDialog.tsx b/apps/renterd/components/Files/FilesCreateDirectoryDialog.tsx index 8706b2119..2fb12f322 100644 --- a/apps/renterd/components/Files/FilesCreateDirectoryDialog.tsx +++ b/apps/renterd/components/Files/FilesCreateDirectoryDialog.tsx @@ -9,6 +9,7 @@ import { useObjectUpload } from '@siafoundation/react-renterd' import { useFiles } from '../../contexts/files' import { useFormik } from 'formik' import * as Yup from 'yup' +import { bucketAndKeyParamsFromPath } from '../../contexts/files/paths' const initialValues = { name: '', @@ -37,9 +38,9 @@ export function FilesCreateDirectoryDialog({ validationSchema, onSubmit: async (values, actions) => { const response = await upload.put({ - params: { - key: activeDirectoryPath.slice(1) + values.name + '/', - }, + params: bucketAndKeyParamsFromPath( + activeDirectoryPath + values.name + '/' + ), payload: null, }) if (response.error) { diff --git a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuCount.tsx b/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuCount.tsx index 6bb0919d9..71965ca42 100644 --- a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuCount.tsx +++ b/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuCount.tsx @@ -3,7 +3,7 @@ import { useFiles } from '../../../contexts/files' import { useObjectStats } from '@siafoundation/react-renterd' export function FilesStatsMenuCount() { - const { pageCount } = useFiles() + const { isViewingABucket, pageCount } = useFiles() const stats = useObjectStats({ config: { swr: { @@ -15,13 +15,22 @@ export function FilesStatsMenuCount() { }, }) + if (isViewingABucket) { + return ( + + + {pageCount.toLocaleString()} + {stats.data + ? ` of ${stats.data?.numObjects.toLocaleString()} files` + : ' files'} + + + ) + } return ( - + - {pageCount.toLocaleString()} - {stats.data - ? ` of ${stats.data?.numObjects.toLocaleString()} files` - : ' files'} + {stats.data ? `${stats.data?.numObjects.toLocaleString()} files` : ''} ) diff --git a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuHealth.tsx b/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuHealth.tsx index ee6ee3f31..b32381ff0 100644 --- a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuHealth.tsx +++ b/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuHealth.tsx @@ -2,11 +2,15 @@ import { Separator, Text, Tooltip } from '@siafoundation/design-system' import { useObjectDirectory } from '@siafoundation/react-renterd' import { useMemo } from 'react' import { healthThresholds, useHealthLabel } from '../../../hooks/useHealthLabel' +import { useFiles } from '../../../contexts/files' export function FilesStatsMenuHealth() { + const { activeBucket } = useFiles() const obj = useObjectDirectory({ + disabled: !activeBucket, params: { key: '', + bucket: activeBucket, }, config: { swr: { @@ -30,6 +34,10 @@ export function FilesStatsMenuHealth() { isDirectory: true, }) + if (!activeBucket) { + return null + } + if (!obj.data?.entries || obj.data.entries.length === 0) { return null } diff --git a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx b/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx index 5621eeed3..18e805a78 100644 --- a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx +++ b/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx @@ -7,7 +7,7 @@ import { useAutopilotNotConfigured } from '../checks/useAutopilotNotConfigured' import { useNotEnoughContracts } from '../checks/useNotEnoughContracts' export function FilesStatsMenuWarnings() { - const { dataState, activeDirectoryPath } = useFiles() + const { dataState, isViewingRootOfABucket, isViewingBuckets } = useFiles() const contractSetMismatch = useContractSetMismatch() const defaultContractSetNotSet = useDefaultContractSetNotSet() const autopilotNotConfigured = useAutopilotNotConfigured() @@ -64,14 +64,16 @@ export function FilesStatsMenuWarnings() { ) } - // only show if not on the root directory because the explorer empty state shows the same info + const autopilotNotConfiguredViewingBuckets = + autopilotNotConfigured.active && isViewingBuckets const autopilotNotConfiguredRootDirectory = autopilotNotConfigured.active && - activeDirectoryPath === '/' && + isViewingRootOfABucket && dataState !== 'noneYet' const autopilotNotConfiguredNotRootDirectory = - autopilotNotConfigured.active && activeDirectoryPath !== '/' + autopilotNotConfigured.active && !isViewingRootOfABucket if ( + autopilotNotConfiguredViewingBuckets || autopilotNotConfiguredRootDirectory || autopilotNotConfiguredNotRootDirectory ) { @@ -97,14 +99,19 @@ export function FilesStatsMenuWarnings() { ) } - // only show if not on the root directory because the explorer empty state shows the same info - const notEnoughContractsRootDirectory = + const notEnoughContractsViewingBuckets = + notEnoughContracts.active && isViewingBuckets + const notEnoughContractsRootDirectoryAndExistingFiles = notEnoughContracts.active && - activeDirectoryPath === '/' && + isViewingRootOfABucket && dataState !== 'noneYet' const notEnoughContractsNotRootDirectory = - notEnoughContracts.active && activeDirectoryPath !== '/' - if (notEnoughContractsRootDirectory || notEnoughContractsNotRootDirectory) { + notEnoughContracts.active && !isViewingRootOfABucket + if ( + notEnoughContractsViewingBuckets || + notEnoughContractsRootDirectoryAndExistingFiles || + notEnoughContractsNotRootDirectory + ) { return (
diff --git a/apps/renterd/components/Files/checks/useNotEnoughContracts.tsx b/apps/renterd/components/Files/checks/useNotEnoughContracts.tsx index 5e1dd44bc..1149c67ea 100644 --- a/apps/renterd/components/Files/checks/useNotEnoughContracts.tsx +++ b/apps/renterd/components/Files/checks/useNotEnoughContracts.tsx @@ -10,9 +10,12 @@ export function useNotEnoughContracts() { }, }, }) - const { datasetCount } = useContracts() + const { datasetCount, isLoading: isContractsLoading } = useContracts() - const active = redundancy.data && datasetCount < redundancy.data.totalShards + const active = + redundancy.data && + !isContractsLoading && + datasetCount < redundancy.data.totalShards return { active, diff --git a/apps/renterd/components/Files/useDirectoryDelete.tsx b/apps/renterd/components/Files/useDirectoryDelete.tsx index dd854c9a3..581147a1e 100644 --- a/apps/renterd/components/Files/useDirectoryDelete.tsx +++ b/apps/renterd/components/Files/useDirectoryDelete.tsx @@ -8,6 +8,7 @@ import { useDialog } from '../../contexts/dialog' import { useCallback } from 'react' import { useObjectDelete } from '@siafoundation/react-renterd' import { humanBytes } from '@siafoundation/sia-js' +import { bucketAndKeyParamsFromPath } from '../../contexts/files/paths' export function useDirectoryDelete() { const { openConfirmDialog } = useDialog() @@ -37,7 +38,7 @@ export function useDirectoryDelete() { ), onConfirm: async () => { const response = await deleteObject.delete({ - params: { key: path.slice(1), batch: true }, + params: { ...bucketAndKeyParamsFromPath(path), batch: true }, }) if (response.error) { diff --git a/apps/renterd/components/Files/useFileDelete.tsx b/apps/renterd/components/Files/useFileDelete.tsx index 0e14989ec..5f9b00dd1 100644 --- a/apps/renterd/components/Files/useFileDelete.tsx +++ b/apps/renterd/components/Files/useFileDelete.tsx @@ -7,6 +7,7 @@ import { import { useDialog } from '../../contexts/dialog' import { useCallback } from 'react' import { useObjectDelete } from '@siafoundation/react-renterd' +import { bucketAndKeyParamsFromPath } from '../../contexts/files/paths' export function useFileDelete() { const { openConfirmDialog } = useDialog() @@ -35,7 +36,7 @@ export function useFileDelete() { ), onConfirm: async () => { const response = await deleteObject.delete({ - params: { key: path.slice(1) }, + params: bucketAndKeyParamsFromPath(path), }) if (response.error) { diff --git a/apps/renterd/components/Hosts/HostsFilterDropdownMenu.tsx b/apps/renterd/components/Hosts/HostsFilterDropdownMenu.tsx deleted file mode 100644 index 40b9eb778..000000000 --- a/apps/renterd/components/Hosts/HostsFilterDropdownMenu.tsx +++ /dev/null @@ -1,276 +0,0 @@ -// import { -// Button, -// Add16, -// Filter16, -// DropdownMenu, -// TextField, -// Text, -// ChevronRight16, -// ipRegex, -// } from '@siafoundation/design-system' -// import { useCallback, useMemo, useEffect, useRef, useState } from 'react' -// import { HostFilter, useHosts } from '../../contexts/hosts' -// import { cx } from 'class-variance-authority' - -// export function HostsFilterDropdownMenu() { -// const { setFilter } = useHosts() -// const [value, setValue] = useState('') -// const [open, setOpen] = useState(false) - -// const inputRef = useRef() -// const [index, setIndex] = useState(0) - -// useEffect(() => { -// if (open) { -// setTimeout(() => { -// inputRef.current?.focus() -// }, 0) -// } else { -// setValue('') -// setIndex(0) -// } -// }, [open]) - -// const close = useCallback(() => { -// setOpen(false) -// setValue('') -// setIndex(0) -// }, [setOpen, setValue, setIndex]) - -// const matchers = useMemo(() => { -// const whitelist = (value: string) => -// 'whitelist white list allowlist allow list'.includes(value) -// const blacklist = (value: string) => -// 'blacklist black list denylist deny list'.includes(value) -// const isIP = (value: string) => -// ipRegex.v4().test(value) || ipRegex.v6().test(value) -// const publicKey = (value: string) => -// !whitelist(value) && !blacklist(value) && !isIP(value) -// const address = (value: string) => !whitelist(value) && !blacklist(value) -// return { -// whitelist, -// blacklist, -// publicKey, -// address, -// } -// }, []) - -// const options: { -// key: string -// type: HostFilter['type'] -// match: (value: string) => boolean -// filter: () => void -// display: () => React.ReactNode -// }[] = useMemo( -// () => [ -// { -// key: 'publicKey', -// type: 'contains', -// match: matchers.publicKey, -// display: () => ( -//
-// -// Public key -// -// -// -// -// {value} -//
-// ), -// filter: () => -// setFilter({ -// key: 'publicKey', -// type: 'contains', -// value, -// }), -// }, -// { -// key: 'address', -// type: 'contains', -// match: matchers.address, -// display: () => ( -//
-// IP -// -// -// -// {value} -//
-// ), -// filter: () => -// setFilter({ -// key: 'address', -// type: 'contains', -// value, -// }), -// }, -// { -// key: 'whitelist', -// type: 'bool', -// match: matchers.whitelist, -// display: () => ( -// -// on whitelist -// -// ), -// filter: () => -// setFilter({ -// key: 'whitelist', -// type: 'bool', -// value: true, -// }), -// }, -// { -// key: 'notwhitelist', -// type: 'bool', -// match: matchers.whitelist, -// display: () => ( -// -// not on whitelist -// -// ), -// filter: () => -// setFilter({ -// key: 'whitelist', -// type: 'bool', -// value: false, -// }), -// }, -// { -// key: 'blacklist', -// type: 'bool', -// match: matchers.blacklist, -// display: () => ( -// -// on blacklist -// -// ), -// filter: () => -// setFilter({ -// key: 'blacklist', -// type: 'bool', -// value: true, -// }), -// }, -// { -// key: 'notblacklist', -// type: 'bool', -// match: matchers.blacklist, -// display: () => ( -// -// not on blacklist -// -// ), -// filter: () => -// setFilter({ -// key: 'blacklist', -// type: 'bool', -// value: false, -// }), -// }, -// ], -// [value, setFilter, matchers] -// ) - -// const filteredOptions = useMemo( -// () => options.filter(({ match }) => !value || match(value)), -// [options, value] -// ) - -// const indexUp = useCallback(() => { -// setIndex((i) => { -// if (i <= 0) { -// return filteredOptions.length - 1 -// } -// return i - 1 -// }) -// }, [setIndex, filteredOptions]) - -// const indexDown = useCallback(() => { -// setIndex((i) => { -// if (i >= filteredOptions.length - 1) { -// return 0 -// } -// return i + 1 -// }) -// }, [setIndex, filteredOptions]) - -// const select = useCallback( -// (index: number) => { -// const option = filteredOptions[index] -// if (option.type === 'bool' || value) { -// option.filter() -// close() -// } -// }, -// [filteredOptions, close, value] -// ) - -// return ( -// -// -// Filter -// -// -// } -// contentProps={{ -// align: 'start', -// onKeyDown: (e) => { -// if (e.key === 'Tab') { -// if (e.shiftKey) { -// indexUp() -// } else { -// indexDown() -// } -// return -// } -// if (e.key === 'ArrowUp') { -// indexUp() -// return -// } -// if (e.key === 'ArrowDown') { -// indexDown() -// return -// } -// if (e.key === 'Enter') { -// select(index) -// return -// } -// }, -// }} -// className="w-60" -// > -// { -// setIndex(0) -// setValue(e.target.value) -// }} -// /> -//
-// {filteredOptions.map(({ key, display }, i) => ( -//
setIndex(i)} -// onClick={() => select(i)} -// className={cx([ -// 'py-1 px-2 rounded cursor-pointer overflow-hidden', -// i === index && 'bg-gray-200', -// ])} -// > -// {display()} -//
-// ))} -//
-//
-// ) -// } - -export const foo = 1 diff --git a/apps/renterd/components/TransfersBar.tsx b/apps/renterd/components/TransfersBar.tsx index ba98c5a1c..3bc7392b6 100644 --- a/apps/renterd/components/TransfersBar.tsx +++ b/apps/renterd/components/TransfersBar.tsx @@ -50,7 +50,7 @@ export function TransfersBar() { className="flex flex-col gap-1 border-t first:border-t-0 border-gray-200 dark:border-graydark-300 px-3 py-2" > - {upload.path.slice(1)} + {upload.path} - {download.path.slice(1)} + {download.path} (val ? openDialog(dialog) : closeDialog())} /> + (val ? openDialog(dialog) : closeDialog())} + /> + (val ? openDialog(dialog) : closeDialog())} + /> (val ? openDialog(dialog) : closeDialog())} diff --git a/apps/renterd/contexts/files/columns.tsx b/apps/renterd/contexts/files/columns.tsx index 6e484261d..59dae346b 100644 --- a/apps/renterd/contexts/files/columns.tsx +++ b/apps/renterd/contexts/files/columns.tsx @@ -14,6 +14,7 @@ import BigNumber from 'bignumber.js' import { useFiles } from '.' import { ObjectData, TableColumnId } from './types' import { FilesHealthColumn } from '../../components/Files/Columns/FilesHealthColumn' +import { BucketContextMenu } from '../../components/Files/BucketContextMenu' type Context = { currentHeight: number @@ -35,7 +36,7 @@ export const columns: FilesTableColumn[] = [ fixed: true, cellClassName: 'w-[50px] !pl-2 !pr-2 [&+*]:!pl-0', render: function TypeColumn({ - data: { isUploading, isDirectory, name, path, size }, + data: { isUploading, type, name, path, size }, }) { const { setActiveDirectory } = useFiles() if (isUploading) { @@ -59,10 +60,12 @@ export const columns: FilesTableColumn[] = [ ) } - return isDirectory ? ( + return type === 'bucket' ? ( + + ) : type === 'directory' ? ( ) : ( - + ) }, }, @@ -71,9 +74,25 @@ export const columns: FilesTableColumn[] = [ label: 'name', category: 'general', contentClassName: 'max-w-[600px]', - render: function NameColumn({ data: { name, isDirectory } }) { + render: function NameColumn({ data: { name, type } }) { const { setActiveDirectory } = useFiles() - if (isDirectory) { + if (type === 'bucket') { + return ( + { + e.stopPropagation() + setActiveDirectory(() => [name]) + }} + > + {name} + + ) + } + if (type === 'directory') { if (name === '..') { return ( } @@ -141,6 +163,9 @@ export const columns: FilesTableColumn[] = [ label: 'health', contentClassName: 'justify-center', render: function HealthColumn({ data }) { + if (data.type === 'bucket') { + return null + } return }, }, diff --git a/apps/renterd/contexts/files/dataset.tsx b/apps/renterd/contexts/files/dataset.tsx new file mode 100644 index 000000000..88953e144 --- /dev/null +++ b/apps/renterd/contexts/files/dataset.tsx @@ -0,0 +1,95 @@ +import { useBuckets, useObjectDirectory } from '@siafoundation/react-renterd' +import { sortBy, toPairs } from 'lodash' +import useSWR from 'swr' +import { useContracts } from '../contracts' +import { ObjectData } from './types' +import { + bucketAndKeyParamsFromPath, + bucketAndResponseKeyToFilePath, + getBucketFromPath, + getDirPath, + getFilename, + getFilePath, + isDirectory, +} from './paths' + +type Props = { + activeDirectoryPath: string + uploadsList: ObjectData[] +} + +export function useDataset({ activeDirectoryPath, uploadsList }: Props) { + const buckets = useBuckets() + + const bucket = getBucketFromPath(activeDirectoryPath) + const response = useObjectDirectory({ + disabled: !bucket, + params: bucketAndKeyParamsFromPath(activeDirectoryPath), + }) + + const { dataset: allContracts } = useContracts() + + const d = useSWR( + response.isValidating || buckets.isValidating + ? null + : [ + response.data, + uploadsList, + allContracts, + buckets.data, + bucket, + activeDirectoryPath, + ], + () => { + const dataMap: Record = {} + if (!bucket) { + buckets.data?.forEach(({ name }) => { + const bucket = name + const path = getDirPath(bucket, '') + dataMap[name] = { + id: path, + path, + bucket, + size: 0, + health: 0, + name: name, + type: 'bucket', + } + }) + } else if (response.data) { + response.data.entries?.forEach(({ name: key, size, health }) => { + const path = bucketAndResponseKeyToFilePath(bucket, key) + dataMap[path] = { + id: path, + path, + bucket, + size, + health, + name: getFilename(key), + type: isDirectory(key) ? 'directory' : 'file', + } + }) + uploadsList + .filter( + ({ path, name }) => path === getFilePath(activeDirectoryPath, name) + ) + .forEach((upload) => { + dataMap[upload.path] = upload + }) + } + const all = sortBy( + toPairs(dataMap).map((p) => p[1]), + 'path' + ) + return all + }, + { + keepPreviousData: true, + } + ) + + return { + response, + dataset: d.data, + } +} diff --git a/apps/renterd/contexts/files/downloads.tsx b/apps/renterd/contexts/files/downloads.tsx new file mode 100644 index 000000000..e23821f25 --- /dev/null +++ b/apps/renterd/contexts/files/downloads.tsx @@ -0,0 +1,130 @@ +import { triggerErrorToast } from '@siafoundation/design-system' +import { useAppSettings } from '@siafoundation/react-core' +import { useObjectDownloadFunc } from '@siafoundation/react-renterd' +import { throttle } from 'lodash' +import { useCallback, useMemo, useState } from 'react' +import { ObjectData } from './types' +import { + FullPath, + bucketAndKeyParamsFromPath, + getBucketFromPath, + getFilename, +} from './paths' + +type UploadsMap = Record + +type Props = { + activeDirectoryPath: string +} + +export function useDownloads({ activeDirectoryPath }: Props) { + const download = useObjectDownloadFunc() + const [downloadsMap, setDownloadsMap] = useState({}) + + const updateDownloadProgress = useCallback( + (obj: { + path: string + bucket: string + name: string + loaded: number + size: number + }) => { + setDownloadsMap((download) => ({ + ...download, + [obj.path]: { + id: obj.path, + path: obj.path, + name: obj.name, + bucket: obj.bucket, + size: obj.size, + loaded: obj.loaded, + isUploading: false, + type: 'file', + }, + })) + }, + [setDownloadsMap] + ) + + const removeDownload = useCallback( + (path: string) => { + setDownloadsMap((downloads) => { + delete downloads[path] + return { + ...downloads, + } + }) + }, + [setDownloadsMap] + ) + + const downloadFiles = async (files: FullPath[]) => { + files.forEach(async (path) => { + let isDone = false + const bucket = getBucketFromPath(path) + const name = getFilename(path) + const onDownloadProgress = throttle((e) => { + if (isDone) { + return + } + updateDownloadProgress({ + name, + path, + bucket, + loaded: e.loaded, + size: e.total, + }) + }, 2000) + updateDownloadProgress({ + name, + path, + bucket, + loaded: 0, + size: 1, + }) + const response = await download.get(name, { + params: bucketAndKeyParamsFromPath(path), + config: { + axios: { + onDownloadProgress, + }, + }, + }) + isDone = true + if (response.error) { + triggerErrorToast(response.error) + removeDownload(path) + } else { + removeDownload(path) + // triggerToast(`Download complete: ${name}`) + } + }) + } + + const downloadsList = useMemo( + () => Object.entries(downloadsMap).map((d) => d[1]), + [downloadsMap] + ) + + const { settings } = useAppSettings() + const getFileUrl = useCallback( + (name: string, authenticated: boolean) => { + const path = `/worker/objects${name}` + // Parse settings.api if its set otherwise URL + const origin = settings.api || location.origin + const scheme = origin.startsWith('https') ? 'https' : 'http' + const host = origin.replace('https://', '').replace('http://', '') + if (authenticated) { + return `${scheme}://:${settings.password}@${host}/api${path}` + } + return `${scheme}://${host}/api${path}` + }, + [settings] + ) + + return { + downloadFiles, + downloadsList, + getFileUrl, + } +} diff --git a/apps/renterd/contexts/files/index.tsx b/apps/renterd/contexts/files/index.tsx index 6752c8a7f..8fcdd305d 100644 --- a/apps/renterd/contexts/files/index.tsx +++ b/apps/renterd/contexts/files/index.tsx @@ -1,28 +1,12 @@ import { - triggerErrorToast, - triggerToast, useClientFilteredDataset, useClientFilters, useDatasetEmptyState, useTableState, } from '@siafoundation/design-system' -import { useAppSettings } from '@siafoundation/react-core' -import { - useObjectDirectory, - useObjectDownloadFunc, - useObjectUpload, -} from '@siafoundation/react-renterd' -import { sortBy, throttle, toPairs } from 'lodash' import { useRouter } from 'next/router' -import { - createContext, - useCallback, - useContext, - useMemo, - useState, -} from 'react' +import { createContext, useCallback, useContext, useMemo } from 'react' import { TransfersBar } from '../../components/TransfersBar' -import { useContracts } from '../contracts' import { columns } from './columns' import { defaultSortField, @@ -30,259 +14,49 @@ import { ObjectData, sortOptions, } from './types' -import { getFilename, getFullPath, isDirectory } from './utils' - -type UploadsMap = Record +import { FullPath, FullPathSegments, pathSegmentsToPath } from './paths' +import { useUploads } from './uploads' +import { useDownloads } from './downloads' +import { useDataset } from './dataset' function useFilesMain() { const router = useRouter() const limit = Number(router.query.limit || 20) const offset = Number(router.query.offset || 0) - // activeDirectory is the path split into an array of parts, stored in the router path - const activeDirectory = useMemo( - () => (router.query.path as string[]) || [], + // [bucket, key, directory] + const activeDirectory = useMemo( + () => (router.query.path as FullPathSegments) || [], [router.query.path] ) - // activeDirectoryPath is the path string, formatted in the way renterd expects - const activeDirectoryPath = useMemo(() => { - return activeDirectory.length ? `/${activeDirectory.join('/')}/` : '/' + + // bucket + const activeBucket = useMemo(() => { + return activeDirectory[0] + }, [activeDirectory]) + + // bucket/key/directory/ + const activeDirectoryPath = useMemo(() => { + return pathSegmentsToPath(activeDirectory) + '/' }, [activeDirectory]) const setActiveDirectory = useCallback( - (fn: (activeDirectory: string[]) => string[]) => { + (fn: (activeDirectory: FullPathSegments) => FullPathSegments) => { const nextActiveDirectory = fn(activeDirectory) router.push('/files/' + nextActiveDirectory.join('/')) }, [router, activeDirectory] ) - const upload = useObjectUpload() - const [uploadsMap, setUploadsMap] = useState({}) - - const updateUploadProgress = useCallback( - (obj: { path: string; name: string; loaded: number; size: number }) => { - setUploadsMap((uploads) => ({ - ...uploads, - [obj.path]: { - id: obj.path, - path: obj.path, - name: obj.name, - size: obj.size, - loaded: obj.loaded, - isUploading: true, - isDirectory: false, - }, - })) - }, - [setUploadsMap] - ) - - const removeUpload = useCallback( - (path: string) => { - setUploadsMap((uploads) => { - delete uploads[path] - return { - ...uploads, - } - }) - }, - [setUploadsMap] - ) - - const uploadFiles = async (files: File[]) => { - files.forEach(async (file) => { - const name = file.name - const path = getFullPath(activeDirectoryPath, name) - const onUploadProgress = throttle( - (e) => - updateUploadProgress({ - name, - path, - loaded: e.loaded, - size: e.total, - }), - 2000 - ) - updateUploadProgress({ - name, - path, - loaded: 0, - size: 1, - }) - const response = await upload.put({ - params: { - key: path.slice(1), - }, - payload: file, - config: { - axios: { - onUploadProgress, - }, - }, - }) - if (response.error) { - triggerErrorToast(response.error) - removeUpload(path) - } else { - removeUpload(path) - triggerToast(`Upload complete: ${name}`) - } - }) - } - - const uploadsList = useMemo( - () => Object.entries(uploadsMap).map((u) => u[1]), - [uploadsMap] - ) - - const download = useObjectDownloadFunc() - const [downloadsMap, setDownloadsMap] = useState({}) - - const updateDownloadProgress = useCallback( - (obj: { path: string; name: string; loaded: number; size: number }) => { - setDownloadsMap((download) => ({ - ...download, - [obj.path]: { - id: obj.path, - path: obj.path, - name: obj.name, - size: obj.size, - loaded: obj.loaded, - isUploading: false, - isDirectory: false, - }, - })) - }, - [setDownloadsMap] - ) - - const removeDownload = useCallback( - (path: string) => { - setDownloadsMap((downloads) => { - delete downloads[path] - return { - ...downloads, - } - }) - }, - [setDownloadsMap] - ) - - const { settings } = useAppSettings() - const getFileUrl = useCallback( - (name: string, authenticated: boolean) => { - const path = `/worker/objects${name}` - // Parse settings.api if its set otherwise URL - const origin = settings.api || location.origin - const scheme = origin.startsWith('https') ? 'https' : 'http' - const host = origin.replace('https://', '').replace('http://', '') - if (authenticated) { - return `${scheme}://:${settings.password}@${host}/api${path}` - } - return `${scheme}://${host}/api${path}` - }, - [settings] - ) - - const downloadFiles = async (files: string[]) => { - files.forEach(async (name) => { - const path = getFullPath(activeDirectoryPath, name) - let isDone = false - const onDownloadProgress = throttle((e) => { - if (isDone) { - return - } - updateDownloadProgress({ - name, - path, - loaded: e.loaded, - size: e.total, - }) - }, 2000) - updateDownloadProgress({ - name, - path, - loaded: 0, - size: 1, - }) - const response = await download.get(name, { - params: { - key: path.slice(1), - }, - config: { - axios: { - onDownloadProgress, - }, - }, - }) - isDone = true - if (response.error) { - triggerErrorToast(response.error) - removeDownload(path) - } else { - removeDownload(path) - // triggerToast(`Download complete: ${name}`) - } - }) - } - - const downloadsList = useMemo( - () => Object.entries(downloadsMap).map((d) => d[1]), - [downloadsMap] - ) - - const response = useObjectDirectory({ - params: { - key: activeDirectoryPath.slice(1), - // limit: limit, - // offset: offset, - }, - config: { - swr: { keepPreviousData: true }, - }, + const { uploadFiles, uploadsList } = useUploads({ activeDirectoryPath }) + const { downloadFiles, downloadsList, getFileUrl } = useDownloads({ + activeDirectoryPath, }) - const { dataset: allContracts } = useContracts() - - const dataset = useMemo(() => { - if (!response.data) { - return null - } - - const dataMap: Record = {} - - response.data.entries?.forEach(({ name: path, size, health }) => { - // If there is a directory stub file filter it out. - if (path === activeDirectoryPath) { - return - } - dataMap[path] = { - id: path, - path, - size, - health, - name: getFilename(path), - isDirectory: isDirectory(path), - } - }) - uploadsList - .filter( - ({ path, name }) => path === getFullPath(activeDirectoryPath, name) - ) - .forEach((upload) => { - dataMap[upload.path] = upload - }) - const all = sortBy( - toPairs(dataMap).map((p) => p[1]), - 'path' - ) - return all - // Purposely do not include activeDirectoryPath - we only want to update - // when new data fetching is complete. Leaving it in wipes makes the - // directory stub path matching logic temporarily invalid. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [response.data, uploadsList, allContracts]) + const { response, dataset } = useDataset({ + activeDirectoryPath, + uploadsList, + }) const { configurableColumns, @@ -319,13 +93,13 @@ function useFilesMain() { if (!datasetFiltered) { return null } - if (activeDirectory.length > 0) { + if (activeDirectory.length > 0 && datasetFiltered.length > 0) { return [ { id: '..', name: '..', path: '..', - isDirectory: true, + type: 'directory', }, ...datasetFiltered, ] @@ -351,7 +125,15 @@ function useFilesMain() { filters ) + const isViewingBuckets = activeDirectory.length === 0 + const isViewingRootOfABucket = activeDirectory.length === 1 + const isViewingABucket = activeDirectory.length > 0 + return { + isViewingBuckets, + isViewingABucket, + isViewingRootOfABucket, + activeBucket, activeDirectory, setActiveDirectory, activeDirectoryPath, diff --git a/apps/renterd/contexts/files/paths.spec.ts b/apps/renterd/contexts/files/paths.spec.ts new file mode 100644 index 000000000..40203fa43 --- /dev/null +++ b/apps/renterd/contexts/files/paths.spec.ts @@ -0,0 +1,59 @@ +import { bucketAndKeyParamsFromPath, getDirPath, getFilePath } from './paths' + +describe('getFilePath', () => { + it('a', () => { + expect(getFilePath('bucket/dir/', '/path/to/file.txt')).toEqual( + 'bucket/dir/path/to/file.txt' + ) + }) + it('b', () => { + expect(getFilePath('bucket/dir/', '')).toEqual('bucket/dir/') + }) + it('b', () => { + expect(getFilePath('bucket/dir/', '/')).toEqual('bucket/dir/') + }) +}) + +describe('getDirPath', () => { + it('a', () => { + expect(getDirPath('bucket/dir/', '/path/to/dir')).toEqual( + 'bucket/dir/path/to/dir/' + ) + }) + it('b', () => { + expect(getDirPath('bucket/dir/', '')).toEqual('bucket/dir/') + }) + it('c', () => { + expect(getDirPath('bucket/dir/', '/')).toEqual('bucket/dir/') + }) +}) + +describe('bucketAndKeyParamsFromPath', () => { + it('works for file', () => { + expect(bucketAndKeyParamsFromPath('bucket/path/to/file.txt')).toEqual({ + bucket: 'bucket', + key: 'path/to/file.txt', + }) + }) + + it('works for directory', () => { + expect(bucketAndKeyParamsFromPath('bucket/path/to/directory/')).toEqual({ + bucket: 'bucket', + key: 'path/to/directory/', + }) + }) + + it('works for empty', () => { + expect(bucketAndKeyParamsFromPath('bucket')).toEqual({ + bucket: 'bucket', + key: '', + }) + }) + + it('works for empty with trailing', () => { + expect(bucketAndKeyParamsFromPath('bucket/')).toEqual({ + bucket: 'bucket', + key: '', + }) + }) +}) diff --git a/apps/renterd/contexts/files/paths.ts b/apps/renterd/contexts/files/paths.ts new file mode 100644 index 000000000..f3aecd754 --- /dev/null +++ b/apps/renterd/contexts/files/paths.ts @@ -0,0 +1,63 @@ +export type FullPathSegments = string[] +export type FullPath = string +export type KeyPath = string + +export function getFilePath(dirPath: FullPath, name: string): FullPath { + const n = name.startsWith('/') ? name.slice(1) : name + return dirPath + n +} + +export function getDirPath(dirPath: FullPath, name: string): FullPath { + const path = getFilePath(dirPath, name) + return path.endsWith('/') ? path : path + '/' +} + +// response keys start with a slash, eg /path/to/file.txt +export function bucketAndResponseKeyToFilePath( + bucket: string, + key: KeyPath +): FullPath { + return `${bucket}${key}` +} + +export function getBucketFromPath(path: FullPath): string { + return path.split('/')[0] +} + +function getKeyFromPath(path: FullPath): KeyPath { + const segsWithoutBucket = path.split('/').slice(1).join('/') + return `/${segsWithoutBucket}` +} + +export function bucketAndKeyParamsFromPath(path: FullPath): { + bucket: string + key: KeyPath +} { + return { + bucket: getBucketFromPath(path), + key: getKeyFromPath(path).slice(1), + } +} + +export function getFilename(filePath: FullPath): string { + const parts = filePath.split('/') + if (filePath.endsWith('/')) { + return `${parts[parts.length - 2]}/` + } + return parts[parts.length - 1] +} + +export function isDirectory(path: FullPath): boolean { + return path.endsWith('/') +} + +export function getDirectorySegmentsFromPath(path: FullPath): FullPathSegments { + if (isDirectory(path)) { + return path.slice(0, -1).split('/') + } + return path.split('/').slice(0, -1) +} + +export function pathSegmentsToPath(segments: FullPathSegments): FullPath { + return segments.join('/') +} diff --git a/apps/renterd/contexts/files/types.ts b/apps/renterd/contexts/files/types.ts index 4917aeda4..faff1d47d 100644 --- a/apps/renterd/contexts/files/types.ts +++ b/apps/renterd/contexts/files/types.ts @@ -1,10 +1,17 @@ +import { FullPath } from './paths' + +export type ObjectType = 'bucket' | 'directory' | 'file' + export type ObjectData = { - id: string - path: string + id: FullPath + // path is exacty bucket + returned key + // eg: default + /path/to/file.txt = default/path/to/file.txt + path: FullPath + bucket: string name: string health?: number size: number - isDirectory?: boolean + type: ObjectType isUploading?: boolean loaded?: number } diff --git a/apps/renterd/contexts/files/uploads.tsx b/apps/renterd/contexts/files/uploads.tsx new file mode 100644 index 000000000..30fd72a8a --- /dev/null +++ b/apps/renterd/contexts/files/uploads.tsx @@ -0,0 +1,111 @@ +import { triggerErrorToast, triggerToast } from '@siafoundation/design-system' +import { useObjectUpload } from '@siafoundation/react-renterd' +import { throttle } from 'lodash' +import { useCallback, useMemo, useState } from 'react' +import { ObjectData } from './types' +import { + bucketAndKeyParamsFromPath, + getBucketFromPath, + getFilePath, +} from './paths' + +type UploadsMap = Record + +type Props = { + activeDirectoryPath: string +} + +export function useUploads({ activeDirectoryPath }: Props) { + const upload = useObjectUpload() + const [uploadsMap, setUploadsMap] = useState({}) + + const updateUploadProgress = useCallback( + (obj: { + path: string + name: string + bucket: string + loaded: number + size: number + }) => { + setUploadsMap((uploads) => ({ + ...uploads, + [obj.path]: { + id: obj.path, + path: obj.path, + bucket: obj.bucket, + name: obj.name, + size: obj.size, + loaded: obj.loaded, + isUploading: true, + type: 'file', + }, + })) + }, + [setUploadsMap] + ) + + const removeUpload = useCallback( + (path: string) => { + setUploadsMap((uploads) => { + delete uploads[path] + return { + ...uploads, + } + }) + }, + [setUploadsMap] + ) + + const uploadFiles = async (files: File[]) => { + files.forEach(async (file) => { + const name = file.name + // TODO: check if name has /prefix + const path = getFilePath(activeDirectoryPath, name) + const bucket = getBucketFromPath(path) + const onUploadProgress = throttle( + (e) => + updateUploadProgress({ + name, + path, + bucket, + loaded: e.loaded, + size: e.total, + }), + 2000 + ) + updateUploadProgress({ + name, + path, + bucket, + loaded: 0, + size: 1, + }) + const response = await upload.put({ + params: bucketAndKeyParamsFromPath(path), + payload: file, + config: { + axios: { + onUploadProgress, + }, + }, + }) + if (response.error) { + triggerErrorToast(response.error) + removeUpload(path) + } else { + removeUpload(path) + triggerToast(`Upload complete: ${name}`) + } + }) + } + + const uploadsList = useMemo( + () => Object.entries(uploadsMap).map((u) => u[1]), + [uploadsMap] + ) + + return { + uploadFiles, + uploadsList, + } +} diff --git a/apps/renterd/contexts/files/utils.ts b/apps/renterd/contexts/files/utils.ts deleted file mode 100644 index 1324f1e98..000000000 --- a/apps/renterd/contexts/files/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -export function getFullPath(dirPathStr: string, name: string) { - return dirPathStr + name -} - -export function getFilename(filePath: string) { - const parts = filePath.split('/') - if (filePath.endsWith('/')) { - return `${parts[parts.length - 2]}/` - } - return parts[parts.length - 1] -} - -export function isDirectory(path: string) { - return path.endsWith('/') -} - -export function getDirectoryFromPath(path: string) { - if (isDirectory(path)) { - return path.slice(1).slice(0, -1).split('/') - } - return path.slice(1).split('/').slice(0, -1) -} diff --git a/apps/renterd/dialogs/AlertsDialog.tsx b/apps/renterd/dialogs/AlertsDialog.tsx index 5bcf35217..b58093da6 100644 --- a/apps/renterd/dialogs/AlertsDialog.tsx +++ b/apps/renterd/dialogs/AlertsDialog.tsx @@ -29,7 +29,7 @@ import { ContractContextMenuFromId } from '../components/Contracts/ContractConte import { HostContextMenu } from '../components/Hosts/HostContextMenu' import { useDialog } from '../contexts/dialog' import { useFiles } from '../contexts/files' -import { getDirectoryFromPath } from '../contexts/files/utils' +import { getDirectorySegmentsFromPath } from '../contexts/files/paths' type Props = { open: boolean @@ -323,7 +323,9 @@ const dataFields = { size="12" noWrap onClick={() => { - setActiveDirectory(() => getDirectoryFromPath(o.name)) + setActiveDirectory(() => + getDirectorySegmentsFromPath(o.name) + ) closeDialog() }} > diff --git a/apps/website/components/PageHead.tsx b/apps/website/components/PageHead.tsx index 2e2c476ef..fd00bb8ec 100644 --- a/apps/website/components/PageHead.tsx +++ b/apps/website/components/PageHead.tsx @@ -8,7 +8,6 @@ type Props = { image: string date?: string path: string - analytics?: boolean children?: React.ReactNode } diff --git a/libs/design-system/src/icons/BucketIcon.tsx b/libs/design-system/src/icons/BucketIcon.tsx new file mode 100644 index 000000000..89181a4d4 --- /dev/null +++ b/libs/design-system/src/icons/BucketIcon.tsx @@ -0,0 +1,17 @@ +type Props = { + size?: number +} + +export function BucketIcon({ size = 24 }: Props) { + return ( + + + + ) +} diff --git a/libs/design-system/src/index.ts b/libs/design-system/src/index.ts index c22fc796c..23916ab65 100644 --- a/libs/design-system/src/index.ts +++ b/libs/design-system/src/index.ts @@ -145,6 +145,7 @@ export * from './icons/LogoMenuIcon' export * from './icons/MenuIcon' export * from './icons/SimpleLogoIcon' export * from './icons/RedditIcon' +export * from './icons/BucketIcon' export * from './icons/BarsProgressIcon' export * from './icons/FileContractIcon' export * from './icons/HardDriveIcon' diff --git a/libs/react-renterd/src/bus.ts b/libs/react-renterd/src/bus.ts index 10fc681b0..a6402a687 100644 --- a/libs/react-renterd/src/bus.ts +++ b/libs/react-renterd/src/bus.ts @@ -421,12 +421,32 @@ export function useContractsetUpdate( export type Bucket = { name: string + createdAt: string } -export function useBuckets(args?: HookArgsSwr<{ key: string }, Bucket[]>) { +export function useBuckets(args?: HookArgsSwr) { return useGetSwr({ ...args, route: '/bus/buckets' }) } +export function useBucketCreate( + args?: HookArgsCallback +) { + return usePostFunc({ ...args, route: '/bus/buckets' }, async (mutate) => { + mutate((key) => key.startsWith('/bus/buckets')) + }) +} + +export function useBucketDelete( + args?: HookArgsCallback<{ name: string }, void, never> +) { + return useDeleteFunc( + { ...args, route: '/bus/buckets/:name' }, + async (mutate) => { + mutate((key) => key.startsWith('/bus/buckets')) + } + ) +} + export type ObjEntry = { name: string size: number @@ -434,29 +454,42 @@ export type ObjEntry = { } export function useObjectDirectory( - args: HookArgsSwr<{ key: string }, { entries: ObjEntry[] }> + args: HookArgsSwr<{ key: string; bucket: string }, { entries: ObjEntry[] }> ) { return useGetSwr({ ...args, route: '/bus/objects/:key' }) } -export function useObject(args: HookArgsSwr<{ key: string }, { object: Obj }>) { +export function useObject( + args: HookArgsSwr<{ key: string; bucket: string }, { object: Obj }> +) { return useGetSwr({ ...args, route: '/bus/objects/:key' }) } export function useObjectSearch( - args: HookArgsSwr<{ key: string; skip: number; limit: number }, ObjEntry[]> + args: HookArgsSwr< + { key: string; bucket: string; skip: number; limit: number }, + ObjEntry[] + > ) { return useGetSwr({ ...args, route: '/bus/search/objects' }) } export function useObjectAdd( - args: HookArgsCallback<{ key: string }, AddObjectRequest, never> + args: HookArgsCallback< + { key: string; bucket: string }, + AddObjectRequest, + never + > ) { return usePutFunc({ ...args, route: '/bus/objects/:key' }) } export function useObjectDelete( - args?: HookArgsCallback<{ key: string; batch?: boolean }, void, never> + args?: HookArgsCallback< + { key: string; bucket: string; batch?: boolean }, + void, + never + > ) { return useDeleteFunc( { ...args, route: '/bus/objects/:key' }, diff --git a/libs/react-renterd/src/worker.ts b/libs/react-renterd/src/worker.ts index 497e00732..2e8c940a0 100644 --- a/libs/react-renterd/src/worker.ts +++ b/libs/react-renterd/src/worker.ts @@ -27,13 +27,13 @@ export function useWorkerState(args?: HookArgsSwr) { } export function useObjectDownloadFunc( - args?: HookArgsCallback<{ key: string }, void, Blob> + args?: HookArgsCallback<{ key: string; bucket: string }, void, Blob> ) { return useGetDownloadFunc({ ...args, route: '/worker/objects/:key' }) } export function useObjectUpload( - args?: HookArgsCallback<{ key: string }, File, void> + args?: HookArgsCallback<{ key: string; bucket: string }, File, void> ) { return usePutFunc( { diff --git a/server/build-deploy.sh b/server/build-deploy.sh index 48e7ea238..d62386db6 100755 --- a/server/build-deploy.sh +++ b/server/build-deploy.sh @@ -1,5 +1,6 @@ #!/bin/bash -eu -nx run-many --target=build --projects=website,assets,crons,explorer,renterd,hostd,walletd --prod +nx run-many --target=build --projects=website,assets,crons,renterd,hostd,walletd --prod +nx run explorer:build:production nx run explorer:build:production-testnet pm2 reload server/pm2.config.js