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 (
+
+ )
+}
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 (
+
+ )
+}
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