diff --git a/.changeset/dull-readers-sit.md b/.changeset/dull-readers-sit.md
new file mode 100644
index 000000000..bc4f2da89
--- /dev/null
+++ b/.changeset/dull-readers-sit.md
@@ -0,0 +1,5 @@
+---
+'renterd': minor
+---
+
+File upload and directory creation are now disabled until enough contracts are formed.
diff --git a/.changeset/odd-pillows-try.md b/.changeset/odd-pillows-try.md
new file mode 100644
index 000000000..dc077238c
--- /dev/null
+++ b/.changeset/odd-pillows-try.md
@@ -0,0 +1,5 @@
+---
+'renterd': minor
+---
+
+New users are now more clearly instructed to configure autopilot and to wait for enough contracts before files can be uploaded.
diff --git a/apps/renterd/components/Files/EmptyState.tsx b/apps/renterd/components/Files/EmptyState.tsx
new file mode 100644
index 000000000..18175ca67
--- /dev/null
+++ b/apps/renterd/components/Files/EmptyState.tsx
@@ -0,0 +1,79 @@
+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 { StateError } from './StateError'
+import { StateNoneMatching } from './StateNoneMatching'
+import { StateNoneYet } from './StateNoneYet'
+
+export function EmptyState() {
+ const { dataState, activeDirectoryPath } = useFiles()
+
+ const autopilotNotConfigured = useAutopilotNotConfigured()
+ const notEnoughContracts = useNotEnoughContracts()
+
+ if (dataState === 'noneMatchingFilters') {
+ return
+ }
+
+ if (dataState === 'error') {
+ return
+ }
+
+ // only show on root directory and when there are no files
+ if (
+ activeDirectoryPath === '/' &&
+ dataState === 'noneYet' &&
+ autopilotNotConfigured.active
+ ) {
+ return (
+
+
+
+
+
+
+ Before you can upload files you must configure autopilot. Autopilot
+ finds contracts with hosts based on the settings you choose.
+ Autopilot also repairs your data as hosts come and go.
+
+
+ Configure autopilot →
+
+
+
+ )
+ }
+
+ // only show on root directory and when there are no files
+ if (
+ activeDirectoryPath === '/' &&
+ dataState === 'noneYet' &&
+ notEnoughContracts.active
+ ) {
+ return (
+
+
+
+
+
+
+ There are not enough contracts to upload data yet. Redundancy is
+ configured to use {notEnoughContracts.required} shards which means
+ at least that many contracts are required.
+
+
+ {notEnoughContracts.count}/{notEnoughContracts.required}
+
+
+
+ )
+ }
+
+ if (dataState === 'noneYet') {
+ return
+ }
+
+ return null
+}
diff --git a/apps/renterd/components/Files/FilesActionsMenu.tsx b/apps/renterd/components/Files/FilesActionsMenu.tsx
index aeab2e015..8694f8482 100644
--- a/apps/renterd/components/Files/FilesActionsMenu.tsx
+++ b/apps/renterd/components/Files/FilesActionsMenu.tsx
@@ -8,13 +8,17 @@ import { useFiles } from '../../contexts/files'
import { useDropzone } from 'react-dropzone'
import { FilesViewDropdownMenu } from './FilesViewDropdownMenu'
import { useDialog } from '../../contexts/dialog'
+import { useCanUpload } from './useCanUpload'
export function FilesActionsMenu() {
const { openDialog } = useDialog()
const { uploadFiles } = useFiles()
+ const canUpload = useCanUpload()
+
const { getRootProps, getInputProps } = useDropzone({
noDrag: true,
+ noClick: !canUpload,
onDrop: uploadFiles,
})
@@ -23,11 +27,12 @@ export function FilesActionsMenu() {
openDialog('filesSearch')} tip="Search files">
-
+
openDialog('filesCreateDirectory')}
tip="Create directory"
>
diff --git a/apps/renterd/components/Files/FilesExplorer.tsx b/apps/renterd/components/Files/FilesExplorer.tsx
index 1cff5a056..6dcf59fd1 100644
--- a/apps/renterd/components/Files/FilesExplorer.tsx
+++ b/apps/renterd/components/Files/FilesExplorer.tsx
@@ -1,8 +1,7 @@
import { Table, Dropzone } from '@siafoundation/design-system'
import { useFiles } from '../../contexts/files'
-import { StateError } from './StateError'
-import { StateNoneMatching } from './StateNoneMatching'
-import { StateNoneYet } from './StateNoneYet'
+import { EmptyState } from './EmptyState'
+import { useCanUpload } from './useCanUpload'
export function FilesExplorer() {
const {
@@ -16,20 +15,17 @@ export function FilesExplorer() {
sortableColumns,
toggleSort,
} = useFiles()
+ const canUpload = useCanUpload()
return (
-
0}>
+ 0}
+ noDrag={!canUpload}
+ >
- ) : dataState === 'noneYet' ? (
-
- ) : dataState === 'error' ? (
-
- ) : null
- }
+ emptyState={ }
pageSize={10}
data={datasetPage}
columns={columns}
diff --git a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx b/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx
index 7a7dc7f72..5621eeed3 100644
--- a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx
+++ b/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx
@@ -1,106 +1,122 @@
import { Link, Text, Tooltip, Warning16 } from '@siafoundation/design-system'
-import { useAutopilotConfig } from '@siafoundation/react-renterd'
-import { useIsApcsEqDcs } from '../../../hooks/useIsApcsEqDcs'
+import { useFiles } from '../../../contexts/files'
import { routes } from '../../../config/routes'
-import { useContractSetSettings } from '../../../hooks/useContractSetSettings'
-import { useApp } from '../../../contexts/app'
+import { useContractSetMismatch } from '../checks/useContractSetMismatch'
+import { useDefaultContractSetNotSet } from '../checks/useDefaultContractSetNotSet'
+import { useAutopilotNotConfigured } from '../checks/useAutopilotNotConfigured'
+import { useNotEnoughContracts } from '../checks/useNotEnoughContracts'
export function FilesStatsMenuWarnings() {
- const { autopilot } = useApp()
- const apc = useAutopilotConfig({
- config: {
- swr: {
- errorRetryCount: 0,
- },
- },
- })
- const css = useContractSetSettings()
- const isApcsEqDcs = useIsApcsEqDcs()
+ const { dataState, activeDirectoryPath } = useFiles()
+ const contractSetMismatch = useContractSetMismatch()
+ const defaultContractSetNotSet = useDefaultContractSetNotSet()
+ const autopilotNotConfigured = useAutopilotNotConfigured()
+ const notEnoughContracts = useNotEnoughContracts()
- let warning = 'none'
-
- if (
- autopilot.status === 'on' &&
- !isApcsEqDcs.isValidating &&
- !isApcsEqDcs.data
- ) {
- warning = 'contractSetMismatch'
+ // onboard/warn about default contract set
+ if (defaultContractSetNotSet.active) {
+ return (
+
+
+
+
+
+ Configure a default contract set to get started.{' '}
+
+ Configuration →
+
+
+
+ )
}
- if (autopilot.status === 'on' && apc.error) {
- warning = 'setupAutopilot'
- }
-
- if (css.data && !css.data?.default) {
- warning = 'setupDefaultContractSet'
- }
-
- return (
- <>
- {warning === 'setupDefaultContractSet' && (
-
-
-
-
-
- Configure a default contract set to get started.{' '}
-
- Configuration →
-
-
-
- )}
- {warning === 'setupAutopilot' && (
+ // warn about contract set mismatch
+ if (contractSetMismatch.active) {
+ return (
+
+ The autopilot contract set does not match the default contract set.
+ This means that by default workers will not upload data to contracts
+ that autopilot manages. Unless these contract are being manually
+ maintained, this will result in data loss. Continue with caution or
+ update the autopilot contract set to match the default contract set.
+ >
+ }
+ >
- Configure autopilot to get started.{' '}
-
- Autopilot →
-
+ Uploaded data will not be managed by autopilot.
- )}
- {warning === 'contractSetMismatch' && (
-
- The autopilot contract set does not match the default contract
- set. This means that by default workers will not upload data to
- contracts that autopilot manages. Unless these contract are being
- manually maintained, this will result in data loss. Continue with
- caution or update the autopilot contract set to match the default
- contract set.
- >
- }
- >
-
-
-
-
-
- Uploaded data will not be managed by autopilot.
-
-
-
- )}
- >
- )
+
+ )
+ }
+
+ // only show if not on the root directory because the explorer empty state shows the same info
+ const autopilotNotConfiguredRootDirectory =
+ autopilotNotConfigured.active &&
+ activeDirectoryPath === '/' &&
+ dataState !== 'noneYet'
+ const autopilotNotConfiguredNotRootDirectory =
+ autopilotNotConfigured.active && activeDirectoryPath !== '/'
+ if (
+ autopilotNotConfiguredRootDirectory ||
+ autopilotNotConfiguredNotRootDirectory
+ ) {
+ return (
+
+
+
+
+
+ Configure autopilot to get started.{' '}
+
+ Autopilot →
+
+
+
+ )
+ }
+
+ // only show if not on the root directory because the explorer empty state shows the same info
+ const notEnoughContractsRootDirectory =
+ notEnoughContracts.active &&
+ activeDirectoryPath === '/' &&
+ dataState !== 'noneYet'
+ const notEnoughContractsNotRootDirectory =
+ notEnoughContracts.active && activeDirectoryPath !== '/'
+ if (notEnoughContractsRootDirectory || notEnoughContractsNotRootDirectory) {
+ return (
+
+
+
+
+
+ Not enought contracts to upload files. {notEnoughContracts.count}/
+ {notEnoughContracts.required}
+
+
+ )
+ }
+
+ return null
}
diff --git a/apps/renterd/components/Files/checks/useAutopilotNotConfigured.tsx b/apps/renterd/components/Files/checks/useAutopilotNotConfigured.tsx
new file mode 100644
index 000000000..316c60ed6
--- /dev/null
+++ b/apps/renterd/components/Files/checks/useAutopilotNotConfigured.tsx
@@ -0,0 +1,17 @@
+import { useAutopilotConfig } from '@siafoundation/react-renterd'
+import { useApp } from '../../../contexts/app'
+
+export function useAutopilotNotConfigured() {
+ const { autopilot } = useApp()
+ const apc = useAutopilotConfig({
+ config: {
+ swr: {
+ errorRetryCount: 0,
+ },
+ },
+ })
+
+ return {
+ active: autopilot.status === 'on' && !!apc.error,
+ }
+}
diff --git a/apps/renterd/components/Files/checks/useContractSetMismatch.tsx b/apps/renterd/components/Files/checks/useContractSetMismatch.tsx
new file mode 100644
index 000000000..3fa0fe3a7
--- /dev/null
+++ b/apps/renterd/components/Files/checks/useContractSetMismatch.tsx
@@ -0,0 +1,15 @@
+import { useIsApcsEqDcs } from '../../../hooks/useIsApcsEqDcs'
+import { useApp } from '../../../contexts/app'
+
+export function useContractSetMismatch() {
+ const { autopilot } = useApp()
+ const isApcsEqDcs = useIsApcsEqDcs()
+
+ // warn about contract set mismatch
+ const active =
+ autopilot.status === 'on' && !isApcsEqDcs.isValidating && !isApcsEqDcs.data
+
+ return {
+ active,
+ }
+}
diff --git a/apps/renterd/components/Files/checks/useDefaultContractSetNotSet.tsx b/apps/renterd/components/Files/checks/useDefaultContractSetNotSet.tsx
new file mode 100644
index 000000000..628e3a860
--- /dev/null
+++ b/apps/renterd/components/Files/checks/useDefaultContractSetNotSet.tsx
@@ -0,0 +1,9 @@
+import { useContractSetSettings } from '../../../hooks/useContractSetSettings'
+
+export function useDefaultContractSetNotSet() {
+ const css = useContractSetSettings()
+
+ return {
+ active: css.data && !css.data?.default,
+ }
+}
diff --git a/apps/renterd/components/Files/checks/useNotEnoughContracts.tsx b/apps/renterd/components/Files/checks/useNotEnoughContracts.tsx
new file mode 100644
index 000000000..5e1dd44bc
--- /dev/null
+++ b/apps/renterd/components/Files/checks/useNotEnoughContracts.tsx
@@ -0,0 +1,22 @@
+import { useContracts } from '../../../contexts/contracts'
+import { useRedundancySettings } from '../../../hooks/useRedundancySettings'
+
+export function useNotEnoughContracts() {
+ const redundancy = useRedundancySettings({
+ config: {
+ swr: {
+ // Do not automatically refetch
+ revalidateOnFocus: false,
+ },
+ },
+ })
+ const { datasetCount } = useContracts()
+
+ const active = redundancy.data && datasetCount < redundancy.data.totalShards
+
+ return {
+ active,
+ count: datasetCount,
+ required: redundancy.data?.totalShards || 0,
+ }
+}
diff --git a/apps/renterd/components/Files/useCanUpload.tsx b/apps/renterd/components/Files/useCanUpload.tsx
new file mode 100644
index 000000000..ed876d692
--- /dev/null
+++ b/apps/renterd/components/Files/useCanUpload.tsx
@@ -0,0 +1,8 @@
+import { useAutopilotNotConfigured } from './checks/useAutopilotNotConfigured'
+import { useNotEnoughContracts } from './checks/useNotEnoughContracts'
+
+export function useCanUpload() {
+ const autopilotNotConfigured = useAutopilotNotConfigured()
+ const notEnoughContracts = useNotEnoughContracts()
+ return !autopilotNotConfigured.active && !notEnoughContracts.active
+}
diff --git a/apps/renterd/contexts/hosts/index.tsx b/apps/renterd/contexts/hosts/index.tsx
index 82d8f1d19..cd372805e 100644
--- a/apps/renterd/contexts/hosts/index.tsx
+++ b/apps/renterd/contexts/hosts/index.tsx
@@ -222,7 +222,7 @@ function useHostsMain() {
autopilot.status === 'on' ? autopilotResponse.error : regularResponse.error
const dataState = useDatasetEmptyState(dataset, isValidating, error, filters)
- const isAutopilotConfigured = autopilot.status.data?.configured
+ const isAutopilotConfigured = autopilot.state.data?.configured
const tableContext = useMemo(
() => ({
isAutopilotConfigured,
diff --git a/libs/react-renterd/src/worker.ts b/libs/react-renterd/src/worker.ts
index 291ab5aeb..497e00732 100644
--- a/libs/react-renterd/src/worker.ts
+++ b/libs/react-renterd/src/worker.ts
@@ -19,7 +19,7 @@ type WorkerState = StateResponse & {
const workerStateKey = '/worker/state'
-export function useBusState(args?: HookArgsSwr) {
+export function useWorkerState(args?: HookArgsSwr) {
return useGetSwr({
...args,
route: workerStateKey,