diff --git a/.changeset/breezy-mayflies-kneel.md b/.changeset/breezy-mayflies-kneel.md new file mode 100644 index 000000000..b06b07a10 --- /dev/null +++ b/.changeset/breezy-mayflies-kneel.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Configuration values such as download and upload utilization are now per month instead of period. diff --git a/.changeset/breezy-spoons-rest.md b/.changeset/breezy-spoons-rest.md new file mode 100644 index 000000000..dba276188 --- /dev/null +++ b/.changeset/breezy-spoons-rest.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The configuration now has an advanced mode that allows the user to view and change all settings. diff --git a/.changeset/cool-yaks-accept.md b/.changeset/cool-yaks-accept.md new file mode 100644 index 000000000..9badb648d --- /dev/null +++ b/.changeset/cool-yaks-accept.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The configuration is now much simpler by default, only requiring storage amount and price to be set during onboarding. diff --git a/.changeset/gorgeous-birds-push.md b/.changeset/gorgeous-birds-push.md new file mode 100644 index 000000000..3846c1967 --- /dev/null +++ b/.changeset/gorgeous-birds-push.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The configuration page now shows the changed status on fields if the user has made a change but the server values were since updated. diff --git a/.changeset/hip-cheetahs-fix.md b/.changeset/hip-cheetahs-fix.md new file mode 100644 index 000000000..1c41f8b54 --- /dev/null +++ b/.changeset/hip-cheetahs-fix.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The default simple configuration automatically sets any unset advanced options to typical defaults. Settings with existing values are not changed. diff --git a/.changeset/neat-shoes-cover.md b/.changeset/neat-shoes-cover.md new file mode 100644 index 000000000..04936d32d --- /dev/null +++ b/.changeset/neat-shoes-cover.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The autopilot settings have been merged into a single configuration page. diff --git a/.changeset/rich-numbers-smoke.md b/.changeset/rich-numbers-smoke.md new file mode 100644 index 000000000..1772be0b5 --- /dev/null +++ b/.changeset/rich-numbers-smoke.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/react-core': minor +--- + +Request hooks now have a standalone option that allows them to revalidate under a unique key. diff --git a/.changeset/weak-fans-search.md b/.changeset/weak-fans-search.md new file mode 100644 index 000000000..7a462b3ae --- /dev/null +++ b/.changeset/weak-fans-search.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The spending estimates now show per TB and total per month. diff --git a/.changeset/wet-rings-hope.md b/.changeset/wet-rings-hope.md new file mode 100644 index 000000000..74f7e13b6 --- /dev/null +++ b/.changeset/wet-rings-hope.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +New users will now see an onboarding wizard that prompts the user to complete the necessary setup steps - it also shows the status and progress of each. diff --git a/apps/renterd/components/Autopilot/fields.tsx b/apps/renterd/components/Autopilot/fields.tsx deleted file mode 100644 index 2ce7d34cd..000000000 --- a/apps/renterd/components/Autopilot/fields.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/* eslint-disable react/no-unescaped-entities */ -import { Code, ConfigFields } from '@siafoundation/design-system' -import BigNumber from 'bignumber.js' -import React from 'react' - -export const scDecimalPlaces = 6 - -export const defaultValues = { - // contracts - set: '', - amount: undefined as BigNumber | undefined, - allowance: undefined as BigNumber | undefined, - period: undefined as BigNumber | undefined, - renewWindow: undefined as BigNumber | undefined, - download: undefined as BigNumber | undefined, - upload: undefined as BigNumber | undefined, - storage: undefined as BigNumber | undefined, - // hosts - allowRedundantIPs: false, - maxDowntimeHours: undefined as BigNumber | undefined, - // wallet - defragThreshold: undefined as BigNumber | undefined, -} - -export type SettingsData = typeof defaultValues - -export const fields: ConfigFields< - typeof defaultValues, - 'contracts' | 'hosts' | 'wallet' -> = { - // contracts - storage: { - type: 'number', - category: 'contracts', - title: 'Expected storage', - description: <>The amount of storage you would like to rent in TB., - units: 'TB', - validation: { - required: 'required', - }, - }, - upload: { - type: 'number', - category: 'contracts', - title: 'Expected upload', - description: ( - <>The amount of upload bandwidth you plan to use each period in TB. - ), - units: 'TB/period', - validation: { - required: 'required', - }, - }, - download: { - type: 'number', - category: 'contracts', - title: 'Expected download', - description: ( - <>The amount of download bandwidth you plan to use each period in TB. - ), - units: 'TB/period', - validation: { - required: 'required', - }, - }, - allowance: { - type: 'siacoin', - category: 'contracts', - title: 'Allowance', - description: ( - <>The amount of Siacoin you would like to spend for the period. - ), - decimalsLimitSc: scDecimalPlaces, - validation: { - required: 'required', - }, - }, - period: { - type: 'number', - category: 'contracts', - title: 'Period', - description: <>The length of the storage contracts., - units: 'weeks', - suggestion: new BigNumber(6), - suggestionTip: 'Typically 6 weeks.', - validation: { - required: 'required', - }, - }, - renewWindow: { - type: 'number', - category: 'contracts', - title: 'Renew window', - description: ( - <> - The number of weeks prior to contract expiration that Sia will attempt - to renew your contracts. - - ), - units: 'weeks', - decimalsLimit: 6, - suggestion: new BigNumber(2), - suggestionTip: 'Typically 2 weeks.', - validation: { - required: 'required', - }, - }, - amount: { - type: 'number', - category: 'contracts', - title: 'Hosts', - description: <>The number of hosts to create contracts with., - units: 'hosts', - decimalsLimit: 0, - suggestion: new BigNumber(50), - suggestionTip: 'Typically 50 hosts.', - validation: { - required: 'required', - }, - }, - set: { - type: 'text', - category: 'contracts', - title: 'Contract set', - description: ( - <> - The contract set that autopilot should use. This should typically be the - same as the default contract set. - - ), - placeholder: 'autopilot', - suggestion: 'autopilot', - suggestionTip: ( - <> - The default contract set is autopilot. - - ), - validation: { - required: 'required', - }, - }, - - // hosts - allowRedundantIPs: { - type: 'boolean', - category: 'hosts', - title: 'Redundant IPs', - description: ( - <> - Whether or not to allow forming contracts with multiple hosts in the - same IP subnet. The subnets used are /16 for IPv4, and /64 for IPv6. - - ), - suggestion: false, - suggestionTip: 'Defaults to off.', - validation: {}, - }, - maxDowntimeHours: { - type: 'number', - category: 'hosts', - title: 'Max downtime', - description: ( - <> - The maximum amount of host downtime that autopilot will tolerate in - hours. - - ), - units: 'hours', - suggestion: new BigNumber(1440), - suggestionTip: 'Defaults to 1,440 which is 60 days.', - validation: { - required: 'required', - }, - }, - - // wallet - defragThreshold: { - type: 'number', - category: 'wallet', - title: 'Defrag threshold', - description: <>The threshold after which autopilot will defrag outputs., - units: 'outputs', - suggestion: new BigNumber(1000), - suggestionTip: 'Defaults to 1,000.', - validation: { - required: 'required', - }, - }, -} diff --git a/apps/renterd/components/Autopilot/index.tsx b/apps/renterd/components/Autopilot/index.tsx deleted file mode 100644 index 2de702db6..000000000 --- a/apps/renterd/components/Autopilot/index.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import { - Text, - Button, - triggerSuccessToast, - TBToBytes, - ValueSc, - useSiacoinFiat, - ValueNum, - ConfigurationPanel, - triggerErrorToast, - useOnInvalid, - Switch, - ControlGroup, - Popover, - Label, - Paragraph, -} from '@siafoundation/design-system' -import { Reset16, Save16, Settings16 } from '@siafoundation/react-icons' -import BigNumber from 'bignumber.js' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { RenterdSidenav } from '../RenterdSidenav' -import { routes } from '../../config/routes' -import { useDialog } from '../../contexts/dialog' -import { RenterdAuthedLayout } from '../RenterdAuthedLayout' -import { - AutopilotConfig, - autopilotHostsKey, - useAutopilotConfig, - useAutopilotConfigUpdate, -} from '@siafoundation/react-renterd' -import { humanBytes, toHastings, toScale } from '@siafoundation/sia-js' -import { defaultValues, fields } from './fields' -import { transformDown, transformUp } from './transform' -import { useForm } from 'react-hook-form' -import { useSyncContractSet } from './useSyncContractSet' -import { delay, useMutate } from '@siafoundation/react-core' - -export function Autopilot() { - const { openDialog } = useDialog() - const configUpdate = useAutopilotConfigUpdate() - const config = useAutopilotConfig({ - // Do not automatically refetch - config: { - swr: { - revalidateOnFocus: false, - errorRetryCount: 0, - }, - }, - }) - const { - shouldSyncDefaultContractSet, - setShouldSyncDefaultContractSet, - syncDefaultContractSet, - } = useSyncContractSet() - - const form = useForm({ - mode: 'all', - defaultValues, - }) - - const resetFormData = useCallback( - (data: AutopilotConfig) => { - form.reset(transformDown(data)) - }, - [form] - ) - - // init - when new config is fetched, set the form - const [hasInit, setHasInit] = useState(false) - useEffect(() => { - if (config.data && !hasInit) { - resetFormData(config.data) - setHasInit(true) - return - } - if (config.error?.status === 404 && !hasInit) { - form.reset(defaultValues) - setHasInit(true) - return - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [config.data, config.error]) - - const reset = useCallback(async () => { - const data = await config.mutate() - if (!data) { - triggerErrorToast('Error fetching config.') - } else { - resetFormData(data) - } - }, [config, resetFormData]) - - const mutate = useMutate() - const onValid = useCallback( - async (values: typeof defaultValues) => { - try { - const firstTimeSettingConfig = !config.data - await configUpdate.put({ - payload: transformUp(values, config.data), - }) - triggerSuccessToast('Configuration has been saved.') - syncDefaultContractSet(values.set) - - // if autopilot is being configured for the first time, - // revalidate the empty hosts list. - if (firstTimeSettingConfig) { - const refreshHostsAfterDelay = async () => { - await delay(5_000) - mutate((key) => key.startsWith(autopilotHostsKey)) - await delay(5_000) - mutate((key) => key.startsWith(autopilotHostsKey)) - } - refreshHostsAfterDelay() - } - - reset() - } catch (e) { - triggerErrorToast((e as Error).message) - console.log(e) - } - }, - [config.data, configUpdate, reset, syncDefaultContractSet, mutate] - ) - - const onInvalid = useOnInvalid(fields) - - const onSubmit = useMemo( - () => form.handleSubmit(onValid, onInvalid), - [form, onValid, onInvalid] - ) - - const storage = form.watch('storage') - const period = form.watch('period') - const allowance = form.watch('allowance') - - const canEstimate = useMemo(() => { - return !( - !storage || - !period || - !allowance || - storage.isZero() || - period.isZero() || - allowance.isZero() - ) - }, [storage, period, allowance]) - - const estimatedSpending = useMemo(() => { - if (!canEstimate) { - return new BigNumber(0) - } - const estimatePerPeriod = allowance - const estimatePerWeek = estimatePerPeriod.div(period) - const estimatePerMonth = estimatePerWeek.div(7).times(30) - return toScale(estimatePerMonth, 0) - }, [canEstimate, period, allowance]) - - const changeCount = Object.entries(form.formState.dirtyFields).filter( - ([_, val]) => !!val - ).length - - const { fiat, currency } = useSiacoinFiat({ sc: estimatedSpending }) - - return ( - } - stats={ - !canEstimate ? ( - - Enter expected storage, period, and allowance values to estimate - monthly spending. - - ) : ( -
- - Estimate: - -
- - {fiat && ( -
- - `(${currency.prefix}${v.toFixed(currency.fixed)})` - } - /> -
- )} - - per month to store {humanBytes(TBToBytes(storage).toNumber())} - -
-
- ) - } - actions={ -
- {!!changeCount && ( - - {changeCount === 1 ? '1 change' : `${changeCount} changes`} - - )} - - - - - - - } - > -
- -
- - setShouldSyncDefaultContractSet(val) - } - > - sync default contract set - - - Automatically update the default contract set to be the same - as the autopilot contract set when changes are saved. - -
-
-
-
-
- } - openSettings={() => openDialog('settings')} - > -
- - - -
-
- ) -} diff --git a/apps/renterd/components/Autopilot/transform.spec.ts b/apps/renterd/components/Autopilot/transform.spec.ts deleted file mode 100644 index 9e6267f64..000000000 --- a/apps/renterd/components/Autopilot/transform.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -import BigNumber from 'bignumber.js' -import { transformDown, transformUp } from './transform' - -describe('data transforms', () => { - it('down', () => { - expect( - transformDown({ - wallet: { - defragThreshold: 1000, - }, - hosts: { - allowRedundantIPs: false, - maxDowntimeHours: 1440, - scoreOverrides: null, - }, - contracts: { - set: 'autopilot', - amount: 51, - allowance: '6006000000000000000000000000', - period: 6048, - renewWindow: 2248, - download: 1099511627776, - upload: 1100000000000, - storage: 1000000000000, - }, - }) - ).toEqual({ - set: 'autopilot', - allowance: new BigNumber('6006'), - amount: new BigNumber('51'), - period: new BigNumber('6'), - renewWindow: new BigNumber('2.2301587301587302'), - download: new BigNumber('1.099511627776'), - upload: new BigNumber('1.1'), - storage: new BigNumber('1'), - allowRedundantIPs: false, - maxDowntimeHours: new BigNumber('1440'), - defragThreshold: new BigNumber('1000'), - }) - }) - - it('up', () => { - expect( - transformUp({ - set: 'autopilot', - allowance: new BigNumber('6006'), - amount: new BigNumber('51'), - period: new BigNumber('6'), - renewWindow: new BigNumber('2.2301587301587302'), - download: new BigNumber('1.099511627776'), - upload: new BigNumber('1.1'), - storage: new BigNumber('1'), - allowRedundantIPs: false, - maxDowntimeHours: new BigNumber('1440'), - defragThreshold: new BigNumber('1000'), - }) - ).toEqual({ - wallet: { - defragThreshold: 1000, - }, - hosts: { - allowRedundantIPs: false, - maxDowntimeHours: 1440, - scoreOverrides: null, - }, - contracts: { - set: 'autopilot', - amount: 51, - allowance: '6006000000000000000000000000', - period: 6048, - renewWindow: 2248, - download: 1099511627776, - upload: 1100000000000, - storage: 1000000000000, - }, - }) - }) - - it('accepts unknown values', () => { - expect( - transformUp( - { - set: 'autopilot', - allowance: new BigNumber('6006'), - amount: new BigNumber('51'), - period: new BigNumber('6'), - renewWindow: new BigNumber('2.2301587301587302'), - download: new BigNumber('1.099511627776'), - upload: new BigNumber('1.1'), - storage: new BigNumber('1'), - allowRedundantIPs: false, - maxDowntimeHours: new BigNumber('1440'), - defragThreshold: new BigNumber('1000'), - }, - { - foobar1: 'value', - wallet: { - foobar: 'value', - }, - contracts: { - foobar: 'value', - period: 7777, - }, - hosts: { - foobar: 'value', - }, - } - ) - ).toEqual({ - foobar1: 'value', - wallet: { - foobar: 'value', - defragThreshold: 1000, - }, - hosts: { - foobar: 'value', - allowRedundantIPs: false, - maxDowntimeHours: 1440, - scoreOverrides: null, - }, - contracts: { - foobar: 'value', - set: 'autopilot', - amount: 51, - allowance: '6006000000000000000000000000', - period: 6048, - renewWindow: 2248, - download: 1099511627776, - upload: 1100000000000, - storage: 1000000000000, - }, - }) - }) -}) diff --git a/apps/renterd/components/Autopilot/transform.ts b/apps/renterd/components/Autopilot/transform.ts deleted file mode 100644 index cbce379fc..000000000 --- a/apps/renterd/components/Autopilot/transform.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - blocksToWeeks, - bytesToTB, - TBToBytes, - weeksToBlocks, -} from '@siafoundation/design-system' -import { AutopilotConfig } from '@siafoundation/react-renterd' -import { toHastings, toSiacoins } from '@siafoundation/sia-js' -import BigNumber from 'bignumber.js' -import { scDecimalPlaces, SettingsData } from './fields' - -export function transformUp( - values: SettingsData, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - existingValues?: any -): AutopilotConfig { - return { - ...existingValues, - contracts: { - ...existingValues?.contracts, - set: values.set, - amount: Math.round(values.amount.toNumber()), - allowance: toHastings(values.allowance).toString(), - period: Math.round(weeksToBlocks(values.period.toNumber())), - renewWindow: Math.round(weeksToBlocks(values.renewWindow.toNumber())), - download: TBToBytes(values.download).toNumber(), - upload: TBToBytes(values.upload).toNumber(), - storage: TBToBytes(values.storage).toNumber(), - }, - hosts: { - ...existingValues?.hosts, - maxDowntimeHours: values.maxDowntimeHours.toNumber(), - allowRedundantIPs: values.allowRedundantIPs, - scoreOverrides: existingValues?.hosts.scoreOverrides || null, - }, - wallet: { - ...existingValues?.wallet, - defragThreshold: values.defragThreshold.toNumber(), - }, - } -} - -export function transformDown(config: AutopilotConfig): SettingsData { - const set = config.contracts.set - const allowance = toSiacoins(config.contracts.allowance, scDecimalPlaces) - const amount = new BigNumber(config.contracts.amount) - const period = new BigNumber(blocksToWeeks(config.contracts.period)) - const renewWindow = new BigNumber(blocksToWeeks(config.contracts.renewWindow)) - const download = bytesToTB(new BigNumber(config.contracts.download)) - const upload = bytesToTB(new BigNumber(config.contracts.upload)) - const storage = bytesToTB(new BigNumber(config.contracts.storage)) - - return { - // contracts - set, - allowance, - amount, - period, - renewWindow, - download, - upload, - storage, - // hosts - allowRedundantIPs: config.hosts.allowRedundantIPs, - maxDowntimeHours: new BigNumber(config.hosts.maxDowntimeHours), - // wallet - defragThreshold: new BigNumber(config.wallet.defragThreshold), - } -} diff --git a/apps/renterd/components/CmdRoot/AutopilotCmdGroup.tsx b/apps/renterd/components/CmdRoot/AutopilotCmdGroup.tsx deleted file mode 100644 index 8e97a1292..000000000 --- a/apps/renterd/components/CmdRoot/AutopilotCmdGroup.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { routes } from '../../config/routes' -import { useRouter } from 'next/router' -import { useDialog } from '../../contexts/dialog' -import { CommandGroup, CommandItemNav, CommandItemSearch } from './Item' -import { Page } from './types' - -const commandPage = { - namespace: 'autopilot', - label: 'Autopilot', -} - -type Props = { - currentPage: Page - parentPage?: Page - pushPage: (page: Page) => void -} - -export function AutopilotCmdGroup({ - parentPage, - currentPage, - pushPage, -}: Props) { - const router = useRouter() - const { closeDialog } = useDialog() - return ( - - { - pushPage(commandPage) - }} - > - {commandPage.label} - - { - router.push(routes.autopilot.index) - closeDialog() - }} - > - Open autopilot - - { - router.push(routes.autopilot.contracts) - closeDialog() - }} - > - Configure contracts - - { - router.push(routes.autopilot.hosts) - closeDialog() - }} - > - Configure hosts - - { - router.push(routes.autopilot.wallet) - closeDialog() - }} - > - Configure wallet - - - ) -} diff --git a/apps/renterd/components/CmdRoot/ConfigCmdGroup.tsx b/apps/renterd/components/CmdRoot/ConfigCmdGroup.tsx index 6f26208f9..37aaf79b6 100644 --- a/apps/renterd/components/CmdRoot/ConfigCmdGroup.tsx +++ b/apps/renterd/components/CmdRoot/ConfigCmdGroup.tsx @@ -3,6 +3,8 @@ import { useRouter } from 'next/router' import { useDialog } from '../../contexts/dialog' import { CommandGroup, CommandItemNav, CommandItemSearch } from './Item' import { Page } from './types' +import { useConfig } from '../../contexts/config' +import { useApp } from '../../contexts/app' const commandPage = { namespace: 'configuration', @@ -17,7 +19,9 @@ type Props = { export function ConfigCmdGroup({ currentPage, parentPage, pushPage }: Props) { const router = useRouter() + const { showAdvanced } = useConfig() const { closeDialog } = useDialog() + const { autopilot } = useApp() return ( Open configuration + {autopilot.status === 'on' && ( + { + router.push(routes.config.storage) + closeDialog() + }} + > + Configure storage + + )} { - router.push(routes.config.gouging) + router.push(routes.config.pricing) closeDialog() }} > - Configure gouging - - { - router.push(routes.config.redundancy) - closeDialog() - }} - > - Configure redundancy + Configure pricing + {showAdvanced && ( + <> + {autopilot.status === 'on' && ( + <> + { + router.push(routes.config.hosts) + closeDialog() + }} + > + Configure hosts + + { + router.push(routes.config.wallet) + closeDialog() + }} + > + Configure wallet + + + )} + { + router.push(routes.config.contracts) + closeDialog() + }} + > + Configure contracts + + { + router.push(routes.config.uploads) + closeDialog() + }} + > + Configure uploads + + { + router.push(routes.config.redundancy) + closeDialog() + }} + > + Configure redundancy + + + )} ) } diff --git a/apps/renterd/components/CmdRoot/index.tsx b/apps/renterd/components/CmdRoot/index.tsx index 182a10054..324ad5868 100644 --- a/apps/renterd/components/CmdRoot/index.tsx +++ b/apps/renterd/components/CmdRoot/index.tsx @@ -10,7 +10,6 @@ import { WalletCmdGroup } from './WalletCmdGroup' import { AppCmdGroup } from './AppCmdGroup' import { useCallback, useState } from 'react' import { NodeCmdGroup } from './NodeCmdGroup' -import { AutopilotCmdGroup } from './AutopilotCmdGroup' import { ConfigCmdGroup } from './ConfigCmdGroup' import { useDialog } from '../../contexts/dialog' import { useRouter } from 'next/router' @@ -23,7 +22,6 @@ import { FilesCmd } from '../Files/FilesCmd' import { useHosts } from '../../contexts/hosts' import { useDebounce } from 'use-debounce' import { CmdEmptyDefault } from './CmdEmpty' -import { useApp } from '../../contexts/app' type Props = { panel?: boolean @@ -32,7 +30,6 @@ type Props = { export function CmdRoot({ panel }: Props) { const { resetFilters: resetContractsFilters } = useContracts() const { resetFilters: resetHostsFilters } = useHosts() - const { autopilot } = useApp() const { closeDialog } = useDialog() const router = useRouter() const [search, setSearch] = useState('') @@ -105,9 +102,6 @@ export function CmdRoot({ panel }: Props) { afterSelect() }} /> - {autopilot.status === 'on' && ( - - )} + {!!changeCount && ( + + {changeCount === 1 ? '1 change' : `${changeCount} changes`} + + )} + + + + + + + } + > +
+ +
+ setShouldSyncDefaultContractSet(val)} + > + sync default contract set + + + Automatically update the default contract set to be the same as + the autopilot contract set when changes are saved. + +
+
+
+
+ + ) +} diff --git a/apps/renterd/components/Config/ConfigNav.tsx b/apps/renterd/components/Config/ConfigNav.tsx new file mode 100644 index 000000000..4616b9889 --- /dev/null +++ b/apps/renterd/components/Config/ConfigNav.tsx @@ -0,0 +1,22 @@ +import { Text, Switch, Tooltip } from '@siafoundation/design-system' +import { useConfig } from '../../contexts/config' + +export function ConfigNav() { + const { showAdvanced, setShowAdvanced } = useConfig() + + return ( +
+ +
+ setShowAdvanced(checked)} + /> + + Advanced + +
+
+
+ ) +} diff --git a/apps/renterd/components/Config/ConfigStats.tsx b/apps/renterd/components/Config/ConfigStats.tsx new file mode 100644 index 000000000..54085d6ce --- /dev/null +++ b/apps/renterd/components/Config/ConfigStats.tsx @@ -0,0 +1,97 @@ +import { + Text, + TBToBytes, + ValueSc, + ValueNum, + useSiacoinFiat, +} from '@siafoundation/design-system' +import { humanBytes, toHastings } from '@siafoundation/sia-js' +import { useConfig } from '../../contexts/config' +import { useApp } from '../../contexts/app' + +export function AutopilotStats() { + const { autopilot } = useApp() + const { + canEstimate, + estimatedSpendingPerMonth, + estimatedSpendingPerTB, + redundancyMultiplier, + storageTB, + showAdvanced, + } = useConfig() + const perMonth = useSiacoinFiat({ sc: estimatedSpendingPerMonth }) + const perTB = useSiacoinFiat({ sc: estimatedSpendingPerTB }) + + if (autopilot.status !== 'on') { + return null + } + + return !canEstimate ? ( + + {showAdvanced + ? 'Enter expected storage, period, and allowance values to estimate monthly spending.' + : 'Enter expected storage and max price to estimate monthly spending.'} + + ) : ( +
+ + Estimate: + +
+ + {perTB.fiat && ( +
+ + `(${perTB.currency.prefix}${v.toFixed(perTB.currency.fixed)})` + } + /> +
+ )} + + per TB/month with {redundancyMultiplier.toFixed(1)}x redundancy + +
+
+ + {perMonth.fiat && ( +
+ + `(${perMonth.currency.prefix}${v.toFixed( + perMonth.currency.fixed + )})` + } + /> +
+ )} + + to store {humanBytes(TBToBytes(storageTB).toNumber())}/month with{' '} + {redundancyMultiplier.toFixed(1)}x redundancy + +
+
+ ) +} diff --git a/apps/renterd/components/Config/fields.tsx b/apps/renterd/components/Config/fields.tsx deleted file mode 100644 index 218f5c690..000000000 --- a/apps/renterd/components/Config/fields.tsx +++ /dev/null @@ -1,422 +0,0 @@ -/* eslint-disable react/no-unescaped-entities */ -import { - Code, - ConfigFields, - FieldSwitch, - hoursInDays, - secondsInMinutes, - Text, - Tooltip, -} from '@siafoundation/design-system' -import BigNumber from 'bignumber.js' -import React from 'react' - -export const scDecimalPlaces = 6 - -export const defaultContractSet = { - contractSet: '', -} - -export const defaultUploadPacking = { - uploadPackingEnabled: false, -} - -export const defaultConfigApp = { - includeRedundancyMaxStoragePrice: true, - includeRedundancyMaxUploadPrice: true, -} - -export const defaultGouging = { - maxRpcPrice: undefined as BigNumber | undefined, - maxStoragePrice: undefined as BigNumber | undefined, - maxContractPrice: undefined as BigNumber | undefined, - maxDownloadPrice: undefined as BigNumber | undefined, - maxUploadPrice: undefined as BigNumber | undefined, - minMaxCollateral: undefined as BigNumber | undefined, - hostBlockHeightLeeway: undefined as BigNumber | undefined, - minPriceTableValidity: undefined as BigNumber | undefined, - minAccountExpiry: undefined as BigNumber | undefined, - minMaxEphemeralAccountBalance: undefined as BigNumber | undefined, -} - -export const defaultRedundancy = { - minShards: undefined as BigNumber | undefined, - totalShards: undefined as BigNumber | undefined, -} - -export const defaultValues = { - // contract set - ...defaultContractSet, - // upload packing - ...defaultUploadPacking, - // gouging - ...defaultGouging, - // redundancy - ...defaultRedundancy, - // config app - ...defaultConfigApp, -} - -export type ContractSetData = typeof defaultContractSet -export type UploadPackingData = typeof defaultUploadPacking -export type ConfigAppData = typeof defaultConfigApp -export type GougingData = typeof defaultGouging -export type RedundancyData = typeof defaultRedundancy -export type SettingsData = typeof defaultValues - -type Categories = 'contractset' | 'uploadpacking' | 'gouging' | 'redundancy' - -type GetFields = { - redundancyMultiplier: BigNumber - includeRedundancyMaxStoragePrice: boolean - includeRedundancyMaxUploadPrice: boolean - storageAverage?: BigNumber - uploadAverage?: BigNumber - downloadAverage?: BigNumber - contractAverage?: BigNumber -} - -export function getFields({ - redundancyMultiplier, - includeRedundancyMaxStoragePrice, - includeRedundancyMaxUploadPrice, - storageAverage, - uploadAverage, - downloadAverage, - contractAverage, -}: GetFields): ConfigFields { - return { - // contract - contractSet: { - category: 'contractset', - type: 'text', - title: 'Default contract set', - placeholder: 'autopilot', - suggestion: 'autopilot', - suggestionTip: ( - <> - Autopilot users will typically want to keep this the same as the - autopilot contract set. - - ), - description: ( - <>The default contract set is where data is uploaded to by default. - ), - validation: { - required: 'required', - }, - }, - uploadPackingEnabled: { - category: 'uploadpacking', - type: 'boolean', - title: 'Upload packing', - description: ( - <> - Data on the Sia network is stored in 40MiB sectors so by default - uploaded files are divided and padded into these 40MiB peices. This - means that storage is wasted on padding but more importantly files - smaller than 40MiB still use 40MiB of space. Upload packing avoids - this waste by buffering files and packing them together before they - are uploaded to the network. This trades some performance for storage - efficiency. It is also important to note that because buffered files - are temporarily stored on disk they must be considered when backing up - your renterd data. - - ), - validation: {}, - }, - // gouging - maxStoragePrice: { - category: 'gouging', - type: 'siacoin', - title: 'Max storage price', - description: <>The max allowed price to store 1 TB per month., - units: 'SC/TB/month', - average: storageAverage, - averageTip: getAverageTip( - includeRedundancyMaxStoragePrice, - redundancyMultiplier - ), - after: function After({ form, fields }) { - return ( - -
- - - Including {redundancyMultiplier.toFixed(1)}x redundancy - - -
-
- ) - }, - decimalsLimitSc: scDecimalPlaces, - validation: { - required: 'required', - }, - }, - maxUploadPrice: { - category: 'gouging', - type: 'siacoin', - title: 'Max upload price', - description: <>The max allowed price to upload 1 TB., - units: 'SC/TB/month', - average: uploadAverage, - averageTip: getAverageTip( - includeRedundancyMaxUploadPrice, - redundancyMultiplier - ), - after: function After({ form, fields }) { - return ( - -
- - - Including {redundancyMultiplier.toFixed(1)}x redundancy - - -
-
- ) - }, - decimalsLimitSc: scDecimalPlaces, - validation: { - required: 'required', - }, - }, - maxDownloadPrice: { - category: 'gouging', - type: 'siacoin', - title: 'Max download price', - description: <>The max allowed price to download 1 TB., - units: 'SC/TB/month', - average: downloadAverage, - averageTip: `Averages provided by Sia Central.`, - decimalsLimitSc: scDecimalPlaces, - validation: { - required: 'required', - }, - }, - maxContractPrice: { - category: 'gouging', - type: 'siacoin', - title: 'Max contract price', - description: <>The max allowed price to form a contract., - average: contractAverage, - decimalsLimitSc: scDecimalPlaces, - tipsDecimalsLimitSc: 3, - validation: { - required: 'required', - }, - }, - maxRpcPrice: { - category: 'gouging', - type: 'siacoin', - title: 'Max RPC price', - description: ( - <>The max allowed base price for RPCs in siacoins per million calls. - ), - units: 'SC/million', - decimalsLimitSc: scDecimalPlaces, - validation: { - required: 'required', - }, - }, - minMaxCollateral: { - category: 'gouging', - type: 'siacoin', - title: 'Min max collateral', - description: ( - <>The min value for max collateral in the host's price settings. - ), - decimalsLimitSc: scDecimalPlaces, - validation: { - required: 'required', - }, - }, - hostBlockHeightLeeway: { - category: 'gouging', - type: 'number', - title: 'Block height leeway', - description: ( - <> - The amount of blocks of leeway given to the host block height in the - host's price table. - - ), - units: 'blocks', - decimalsLimit: 0, - suggestion: new BigNumber(6), - suggestionTip: 'The recommended value is 6 blocks.', - validation: { - required: 'required', - validate: { - min: (value) => - new BigNumber(value as BigNumber).gte(3) || - 'must be at least 3 blocks', - }, - }, - }, - minPriceTableValidity: { - category: 'gouging', - type: 'number', - title: 'Min price table validity', - units: 'minutes', - description: ( - <>The min accepted value for `Validity` in the host's price settings. - ), - validation: { - required: 'required', - validate: { - min: (value) => - new BigNumber(value as BigNumber).gte(secondsInMinutes(10)) || - 'must be at least 10 seconds', - }, - }, - }, - minAccountExpiry: { - category: 'gouging', - type: 'number', - title: 'Min account expiry', - units: 'days', - description: ( - <> - The min accepted value for `AccountExpiry` in the host's price - settings. - - ), - validation: { - required: 'required', - validate: { - min: (value) => - new BigNumber(value as BigNumber).gte(hoursInDays(1)) || - 'must be at least 1 hour', - }, - }, - }, - minMaxEphemeralAccountBalance: { - category: 'gouging', - type: 'siacoin', - title: 'Min max ephemeral account balance', - description: ( - <> - The min accepted value for `MaxEphemeralAccountBalance` in the host's - price settings. - - ), - decimalsLimitSc: scDecimalPlaces, - validation: { - required: 'required', - validate: { - min: (value) => - new BigNumber(value as BigNumber).gte(1) || 'must be at least 1 SC', - }, - }, - }, - - // Redundancy - minShards: { - type: 'number', - category: 'redundancy', - title: 'Min shards', - description: <>The min amount of shards needed to reconstruct a slab., - units: 'shards', - validation: { - required: 'required', - validate: { - min: (value) => - new BigNumber(value as BigNumber).gt(0) || 'must be greater than 0', - }, - }, - trigger: ['totalShards'], - }, - totalShards: { - type: 'number', - category: 'redundancy', - title: 'Total shards', - description: <>The total amount of shards for each slab., - units: 'shards', - validation: { - required: 'required', - validate: { - gteMinShards: (value, values) => - new BigNumber(value as BigNumber).gte(values.minShards) || - 'must be at least equal to min shards', - max: (value) => - new BigNumber(value as BigNumber).lt(256) || - 'must be less than 256', - }, - }, - }, - // hidden fields used by other config options - includeRedundancyMaxStoragePrice: { - type: 'boolean', - title: 'Include redundancy', - validation: {}, - }, - includeRedundancyMaxUploadPrice: { - type: 'boolean', - title: 'Include redundancy', - validation: {}, - }, - } -} - -function getAverageTip( - includeRedundancy: boolean, - redundancyMultiplier: BigNumber -) { - if (includeRedundancy) { - return `The average price is adjusted for ${redundancyMultiplier.toFixed( - 1 - )}x redundancy. Averages provided by Sia Central.` - } - return `The average price is not adjusted for redundancy. Averages provided by Sia Central.` -} - -function getRedundancyTip( - includeRedundancy: boolean, - redundancyMultiplier: BigNumber -) { - if (includeRedundancy) { - return ( -
- - Specified max price includes the cost of{' '} - {redundancyMultiplier.toFixed(1)}x redundancy. - - - Redundancy is calculated from the ratio of data shards:{' '} - min shards / total shards. - -
- ) - } - return `Specified max price does not include redundancy.` -} diff --git a/apps/renterd/components/Config/index.tsx b/apps/renterd/components/Config/index.tsx index 2a18a739a..010ae12da 100644 --- a/apps/renterd/components/Config/index.tsx +++ b/apps/renterd/components/Config/index.tsx @@ -1,387 +1,61 @@ -import { - Text, - Button, - triggerSuccessToast, - triggerErrorToast, - ConfigurationPanel, - TBToBytes, - monthsToBlocks, - useOnInvalid, -} from '@siafoundation/design-system' -import { Reset16, Save16 } from '@siafoundation/react-icons' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { ConfigurationPanel } from '@siafoundation/design-system' import { RenterdSidenav } from '../RenterdSidenav' import { routes } from '../../config/routes' import { useDialog } from '../../contexts/dialog' -import { RenterdAuthedLayout } from '../../components/RenterdAuthedLayout' -import { - ContractSetSettings, - GougingSettings, - RedundancySettings, - UploadPackingSettings, - useSettingUpdate, -} from '@siafoundation/react-renterd' -import { defaultValues, getFields } from './fields' -import { - getRedundancyMultiplier, - getRedundancyMultiplierIfIncluded, - transformDown, - transformUpConfigApp, - transformUpContractSet, - transformUpGouging, - transformUpRedundancy, - transformUpUploadPacking, -} from './transform' -import { useForm } from 'react-hook-form' -import { toSiacoins } from '@siafoundation/sia-js' -import { useContractSetSettings } from '../../hooks/useContractSetSettings' -import { useGougingSettings } from '../../hooks/useGougingSettings' -import { useRedundancySettings } from '../../hooks/useRedundancySettings' -import { - ConfigDisplayOptions, - configDisplayOptionsKey, - useConfigDisplayOptions, -} from '../../hooks/useConfigDisplayOptions' -import { useUploadPackingSettings } from '../../hooks/useUploadPackingSettings' -import { useSiaCentralHostsNetworkAverages } from '@siafoundation/react-sia-central' +import { RenterdAuthedLayout } from '../RenterdAuthedLayout' +import { useConfig } from '../../contexts/config' +import { AutopilotStats } from './ConfigStats' +import { ConfigActions } from './ConfigActions' +import { ConfigNav } from './ConfigNav' export function Config() { const { openDialog } = useDialog() - // settings that 404 when empty - const contractSet = useContractSetSettings({ - config: { - swr: { - // Do not automatically refetch - revalidateOnFocus: false, - errorRetryCount: 0, - }, - }, - }) - const configApp = useConfigDisplayOptions({ - config: { - swr: { - // Do not automatically refetch - revalidateOnFocus: false, - errorRetryCount: 0, - }, - }, - }) - // settings with initial defaults - const gouging = useGougingSettings({ - config: { - swr: { - // Do not automatically refetch - revalidateOnFocus: false, - }, - }, - }) - const redundancy = useRedundancySettings({ - config: { - swr: { - // Do not automatically refetch - revalidateOnFocus: false, - }, - }, - }) - const uploadPacking = useUploadPackingSettings({ - config: { - swr: { - // Do not automatically refetch - revalidateOnFocus: false, - }, - }, - }) - - const settingUpdate = useSettingUpdate() - - const averages = useSiaCentralHostsNetworkAverages({ - config: { - swr: { - revalidateOnFocus: false, - }, - }, - }) - - const form = useForm({ - mode: 'all', - defaultValues, - }) - - const resetFormData = useCallback( - ( - contractSetData: ContractSetSettings | undefined, - uploadPackingData: UploadPackingSettings, - gougingData: GougingSettings, - redundancyData: RedundancySettings, - configAppData: ConfigDisplayOptions | undefined - ) => { - form.reset( - transformDown( - contractSetData, - uploadPackingData, - gougingData, - redundancyData, - configAppData - ) - ) - }, - [form] - ) - - // init - when new config is fetched, set the form - const [hasInit, setHasInit] = useState(false) - useEffect(() => { - if ( - gouging.data && - redundancy.data && - uploadPacking.data && - (contractSet.data || contractSet.error) && - (configApp.data || configApp.error) && - !hasInit - ) { - resetFormData( - contractSet.data, - uploadPacking.data, - gouging.data, - redundancy.data, - configApp.data - ) - setHasInit(true) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - contractSet.data, - contractSet.error, - uploadPacking.data, - gouging.data, - redundancy.data, - configApp.data, - configApp.error, - ]) - - const reset = useCallback(async () => { - const contractSetData = await contractSet.mutate() - const gougingData = await gouging.mutate() - const redundancyData = await redundancy.mutate() - const uploadPackingData = await uploadPacking.mutate() - const configAppData = await configApp.mutate() - if (!gougingData || !redundancyData) { - triggerErrorToast('Error fetching settings.') - } else { - resetFormData( - contractSetData, - uploadPackingData, - gougingData, - redundancyData, - configAppData - ) - } - }, [ - contractSet, - gouging, - uploadPacking, - redundancy, - configApp, - resetFormData, - ]) - - const minShards = form.watch('minShards') - const totalShards = form.watch('totalShards') - const includeRedundancyMaxStoragePrice = form.watch( - 'includeRedundancyMaxStoragePrice' - ) - const includeRedundancyMaxUploadPrice = form.watch( - 'includeRedundancyMaxUploadPrice' - ) - - const fields = useMemo(() => { - const redundancyMultiplier = getRedundancyMultiplier(minShards, totalShards) - if (averages.data) { - return getFields({ - redundancyMultiplier, - includeRedundancyMaxStoragePrice, - includeRedundancyMaxUploadPrice, - storageAverage: toSiacoins(averages.data.settings.storage_price) // bytes/block - .times(monthsToBlocks(1)) // bytes/month - .times(TBToBytes(1)) // TB/month - .times( - getRedundancyMultiplierIfIncluded( - minShards, - totalShards, - includeRedundancyMaxStoragePrice - ) - ), // redundancy - uploadAverage: toSiacoins(averages.data.settings.upload_price) // bytes - .times(TBToBytes(1)) // TB - .times( - getRedundancyMultiplierIfIncluded( - minShards, - totalShards, - includeRedundancyMaxUploadPrice - ) - ), // redundancy - downloadAverage: toSiacoins(averages.data.settings.download_price) // bytes - .times(TBToBytes(1)), // TB - contractAverage: toSiacoins(averages.data.settings.contract_price), - }) - } - return getFields({ - redundancyMultiplier, - includeRedundancyMaxStoragePrice, - includeRedundancyMaxUploadPrice, - }) - }, [ - averages.data, - minShards, - totalShards, - includeRedundancyMaxStoragePrice, - includeRedundancyMaxUploadPrice, - ]) - - const onValid = useCallback( - async (values: typeof defaultValues) => { - if (!gouging.data || !redundancy.data) { - return - } - try { - const contractSetResponse = await settingUpdate.put({ - params: { - key: 'contractset', - }, - payload: transformUpContractSet( - values, - contractSet.data as Record - ), - }) - const uploadPackingResponse = await settingUpdate.put({ - params: { - key: 'uploadpacking', - }, - payload: transformUpUploadPacking( - values, - uploadPacking.data as Record - ), - }) - const gougingResponse = await settingUpdate.put({ - params: { - key: 'gouging', - }, - payload: transformUpGouging( - values, - gouging.data as Record - ), - }) - const redundancyResponse = await settingUpdate.put({ - params: { - key: 'redundancy', - }, - payload: transformUpRedundancy( - values, - redundancy.data as Record - ), - }) - const configAppResponse = await settingUpdate.put({ - params: { - key: configDisplayOptionsKey, - }, - payload: transformUpConfigApp( - values, - configApp.data as Record - ), - }) - if (contractSetResponse.error) { - throw Error(contractSetResponse.error) - } - if (uploadPackingResponse.error) { - throw Error(uploadPackingResponse.error) - } - if (gougingResponse.error) { - throw Error(gougingResponse.error) - } - if (redundancyResponse.error) { - throw Error(redundancyResponse.error) - } - if (configAppResponse.error) { - throw Error(configAppResponse.error) - } - triggerSuccessToast('Configuration has been saved.') - reset() - } catch (e) { - triggerErrorToast((e as Error).message) - console.log(e) - } - }, - [ - settingUpdate, - contractSet, - uploadPacking, - redundancy, - gouging, - configApp, - reset, - ] - ) - - const onInvalid = useOnInvalid(fields) - - const onSubmit = useMemo( - () => form.handleSubmit(onValid, onInvalid), - [form, onValid, onInvalid] - ) - - const changeCount = Object.entries(form.formState.dirtyFields).filter( - ([_, val]) => !!val - ).length + const { form, fields } = useConfig() return ( } sidenav={} - actions={ -
- {!!changeCount && ( - - {changeCount === 1 ? '1 change' : `${changeCount} changes`} - - )} - - -
- } + stats={} + actions={} openSettings={() => openDialog('settings')} > -
+
+ + + diff --git a/apps/renterd/components/Config/transform.spec.ts b/apps/renterd/components/Config/transform.spec.ts deleted file mode 100644 index e247448e5..000000000 --- a/apps/renterd/components/Config/transform.spec.ts +++ /dev/null @@ -1,219 +0,0 @@ -import BigNumber from 'bignumber.js' -import { - transformDown, - transformUpContractSet, - transformUpGouging, - transformUpRedundancy, -} from './transform' - -describe('data transforms', () => { - it('down', () => { - expect( - transformDown( - { default: 'myset' }, - { enabled: true }, - { - hostBlockHeightLeeway: 4, - maxContractPrice: '20000000000000000000000000', - maxDownloadPrice: '1004310000000000000000000000', - maxRPCPrice: '99970619000000000000000000', - maxStoragePrice: '210531181019', - maxUploadPrice: '1000232323000000000000000000', - minAccountExpiry: 86400000000000, - minMaxCollateral: '10000000000000000000000000', - minMaxEphemeralAccountBalance: '1000000000000000000000000', - minPriceTableValidity: 300000000000, - }, - { - minShards: 10, - totalShards: 30, - }, - { - includeRedundancyMaxStoragePrice: false, - includeRedundancyMaxUploadPrice: false, - } - ) - ).toEqual({ - contractSet: 'myset', - uploadPackingEnabled: true, - hostBlockHeightLeeway: new BigNumber(4), - maxContractPrice: new BigNumber('20'), - maxDownloadPrice: new BigNumber('1004.31'), - maxRpcPrice: new BigNumber('99970619'), - maxStoragePrice: new BigNumber('909.494702'), - maxUploadPrice: new BigNumber('1000.232323'), - minAccountExpiry: new BigNumber(1), - minMaxCollateral: new BigNumber('10'), - minMaxEphemeralAccountBalance: new BigNumber('1'), - minPriceTableValidity: new BigNumber(5), - minShards: new BigNumber(10), - totalShards: new BigNumber(30), - includeRedundancyMaxStoragePrice: false, - includeRedundancyMaxUploadPrice: false, - }) - }) - - it('down with include redundancy for storage and upload', () => { - expect( - transformDown( - { default: 'myset' }, - { enabled: true }, - { - hostBlockHeightLeeway: 4, - maxContractPrice: '20000000000000000000000000', - maxDownloadPrice: '1004310000000000000000000000', - maxRPCPrice: '99970619000000000000000000', - maxStoragePrice: '210531181019', - maxUploadPrice: '1000232323000000000000000000', - minAccountExpiry: 86400000000000, - minMaxCollateral: '10000000000000000000000000', - minMaxEphemeralAccountBalance: '1000000000000000000000000', - minPriceTableValidity: 300000000000, - }, - { - minShards: 10, - totalShards: 30, - }, - { - includeRedundancyMaxStoragePrice: true, - includeRedundancyMaxUploadPrice: true, - } - ) - ).toEqual({ - contractSet: 'myset', - uploadPackingEnabled: true, - hostBlockHeightLeeway: new BigNumber(4), - maxContractPrice: new BigNumber('20'), - maxDownloadPrice: new BigNumber('1004.31'), - maxRpcPrice: new BigNumber('99970619'), - maxStoragePrice: new BigNumber('2728.484106'), - maxUploadPrice: new BigNumber('3000.696969'), - minAccountExpiry: new BigNumber(1), - minMaxCollateral: new BigNumber('10'), - minMaxEphemeralAccountBalance: new BigNumber('1'), - minPriceTableValidity: new BigNumber(5), - minShards: new BigNumber(10), - totalShards: new BigNumber(30), - includeRedundancyMaxStoragePrice: true, - includeRedundancyMaxUploadPrice: true, - }) - }) - - it('up contractset', () => { - expect( - transformUpContractSet( - { - contractSet: 'myset', - }, - { - default: '77777777777', - foobar: 'value', - } - ) - ).toEqual({ - default: 'myset', - foobar: 'value', - }) - }) - - it('up gouging', () => { - expect( - transformUpGouging( - { - contractSet: 'myset', - uploadPackingEnabled: false, - hostBlockHeightLeeway: new BigNumber(4), - maxContractPrice: new BigNumber('20'), - maxDownloadPrice: new BigNumber('1004.31'), - maxRpcPrice: new BigNumber('99970619'), - maxStoragePrice: new BigNumber('909.494702'), - maxUploadPrice: new BigNumber('1000.232323'), - minAccountExpiry: new BigNumber(1), - minMaxCollateral: new BigNumber('10'), - minMaxEphemeralAccountBalance: new BigNumber('1'), - minPriceTableValidity: new BigNumber(5), - minShards: new BigNumber(10), - totalShards: new BigNumber(30), - includeRedundancyMaxStoragePrice: false, - includeRedundancyMaxUploadPrice: false, - }, - { - maxStoragePrice: '77777777777', - foobar: 'value', - } - ) - ).toEqual({ - foobar: 'value', - hostBlockHeightLeeway: 4, - maxContractPrice: '20000000000000000000000000', - maxDownloadPrice: '1004310000000000000000000000', - maxRPCPrice: '99970619000000000000000000', - maxStoragePrice: '210531181019', - maxUploadPrice: '1000232323000000000000000000', - minAccountExpiry: 86400000000000, - minMaxCollateral: '10000000000000000000000000', - minMaxEphemeralAccountBalance: '1000000000000000000000000', - minPriceTableValidity: 300000000000, - }) - }) - - it('up gouging with include redundancy for storage', () => { - expect( - transformUpGouging( - { - contractSet: 'myset', - uploadPackingEnabled: false, - hostBlockHeightLeeway: new BigNumber(4), - maxContractPrice: new BigNumber('20'), - maxDownloadPrice: new BigNumber('1004.31'), - maxRpcPrice: new BigNumber('99970619'), - maxStoragePrice: new BigNumber('909.494702'), - maxUploadPrice: new BigNumber('1000.232323'), - minAccountExpiry: new BigNumber(1), - minMaxCollateral: new BigNumber('10'), - minMaxEphemeralAccountBalance: new BigNumber('1'), - minPriceTableValidity: new BigNumber(5), - minShards: new BigNumber(10), - totalShards: new BigNumber(30), - includeRedundancyMaxStoragePrice: true, - includeRedundancyMaxUploadPrice: false, - }, - { - maxStoragePrice: '77777777777', - foobar: 'value', - } - ) - ).toEqual({ - foobar: 'value', - hostBlockHeightLeeway: 4, - maxContractPrice: '20000000000000000000000000', - maxDownloadPrice: '1004310000000000000000000000', - maxRPCPrice: '99970619000000000000000000', - maxStoragePrice: '70177060340', - maxUploadPrice: '1000232323000000000000000000', - minAccountExpiry: 86400000000000, - minMaxCollateral: '10000000000000000000000000', - minMaxEphemeralAccountBalance: '1000000000000000000000000', - minPriceTableValidity: 300000000000, - }) - }) - - it('up redundancy', () => { - expect( - transformUpRedundancy( - { - minShards: new BigNumber(10), - totalShards: new BigNumber(30), - }, - { - minShards: 77, - foobar: 'value', - } - ) - ).toEqual({ - foobar: 'value', - minShards: 10, - totalShards: 30, - }) - }) -}) diff --git a/apps/renterd/components/Files/EmptyState/index.tsx b/apps/renterd/components/Files/EmptyState/index.tsx index 12fc37c89..96c6c20ee 100644 --- a/apps/renterd/components/Files/EmptyState/index.tsx +++ b/apps/renterd/components/Files/EmptyState/index.tsx @@ -39,7 +39,7 @@ export function EmptyState() { finds contracts with hosts based on the settings you choose. Autopilot also repairs your data as hosts come and go. - + Configure autopilot →
diff --git a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx b/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx index 1061f0dc4..4d4ca657c 100644 --- a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx +++ b/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx @@ -86,12 +86,12 @@ export function FilesStatsMenuWarnings() { Configure autopilot to get started.{' '} Autopilot → diff --git a/apps/renterd/components/Files/checks/useNotEnoughContracts.tsx b/apps/renterd/components/Files/checks/useNotEnoughContracts.tsx index 1149c67ea..ef972a8d5 100644 --- a/apps/renterd/components/Files/checks/useNotEnoughContracts.tsx +++ b/apps/renterd/components/Files/checks/useNotEnoughContracts.tsx @@ -2,24 +2,18 @@ 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, isLoading: isContractsLoading } = useContracts() + const redundancy = useRedundancySettings() + const { datasetConfirmedCount, isLoading: isContractsLoading } = + useContracts() const active = redundancy.data && !isContractsLoading && - datasetCount < redundancy.data.totalShards + datasetConfirmedCount < redundancy.data.totalShards return { active, - count: datasetCount, + count: datasetConfirmedCount, required: redundancy.data?.totalShards || 0, } } diff --git a/apps/renterd/components/OnboardingBar.tsx b/apps/renterd/components/OnboardingBar.tsx new file mode 100644 index 000000000..0f17df6ea --- /dev/null +++ b/apps/renterd/components/OnboardingBar.tsx @@ -0,0 +1,269 @@ +import { + Button, + Link, + Logo, + Panel, + ScrollArea, + Text, + Tooltip, +} from '@siafoundation/design-system' +import { + RadioButton16, + CheckmarkFilled16, + Launch16, + PendingFilled16, + Subtract24, +} from '@siafoundation/react-icons' +import { useState } from 'react' +import { useApp } from '../contexts/app' +import { useSyncStatus } from '../hooks/useSyncStatus' +import { routes } from '../config/routes' +import { useDialog } from '../contexts/dialog' +import { useNotEnoughContracts } from './Files/checks/useNotEnoughContracts' +import { useAutopilotConfig, useWallet } from '@siafoundation/react-renterd' +import BigNumber from 'bignumber.js' +import { humanSiacoin } from '@siafoundation/sia-js' +import { useAppSettings } from '@siafoundation/react-core' + +export function OnboardingBar() { + const { isUnlocked } = useAppSettings() + const app = useApp() + const { openDialog } = useDialog() + const wallet = useWallet() + const autopilot = useAutopilotConfig({ + config: { + swr: { + errorRetryInterval: 10_000, + }, + }, + }) + const [maximized, setMaximized] = useState(true) + + const syncStatus = useSyncStatus() + const notEnoughContracts = useNotEnoughContracts() + + if (!isUnlocked || app.autopilot.status !== 'on') { + return null + } + + const walletBalance = new BigNumber(wallet.data?.confirmed || 0) + const allowance = new BigNumber(autopilot.data?.contracts.allowance || 0) + + const step1Configured = app.autopilot.state.data?.configured + const step2Synced = syncStatus.isSynced + const step3Funded = + app.autopilot.state.data?.configured && + wallet.data && + walletBalance.gte(allowance) + const step4Contracts = !notEnoughContracts.active + const steps = [step1Configured, step2Synced, step3Funded, step4Contracts] + const totalSteps = steps.length + const completedSteps = steps.filter((step) => step).length + + if (totalSteps === completedSteps) { + return null + } + + if (maximized) { + return ( +
+ + +
+
+ + + Welcome to Sia + +
+ +
+
+ + Get set up by completing the following steps. Once they are + complete, you can start uploading files. + +
+
+ Step 1: Configure your storage settings + + } + description={ + 'Specify how much data you plan to store and your target price.' + } + action={ + step1Configured ? ( + + + + ) : ( + <> + + + + + + + + ) + } + /> +
+ Step 2: Wait for the blockchain to sync + + } + description={ + 'The blockchain will sync in the background, this takes some time. No user action required.' + } + action={ + step2Synced ? ( + + + + ) : ( + <> + + {syncStatus.syncPercent}% + + + + + + ) + } + /> +
openDialog('addressDetails')} + ellipsis + size="14" + underline="hover" + > + Step 3: Fund your wallet + + } + description={`Fund your wallet with at least ${humanSiacoin( + allowance + )} siacoin to cover the required allowance.${ + syncStatus.isWalletSynced + ? '' + : ' Balance will not be accurate until wallet is finished scanning.' + }`} + action={ + step3Funded ? ( + + + + ) : ( + <> + + {syncStatus.walletScanPercent}% + + openDialog('addressDetails')} + > + + + + + + + ) + } + /> +
+ Step 4: Wait for storage contracts to form + + } + description={ + 'Once all other steps are complete, contracts will automatically form. No user action required.' + } + action={ + step4Contracts ? ( + + + + ) : ( + <> + + {notEnoughContracts.count}/{notEnoughContracts.required} + + + + + + ) + } + /> + + +
+ ) + } + return ( +
+ +
+ ) +} + +type SectionProps = { + title: React.ReactNode + action: React.ReactNode + description: React.ReactNode +} + +function Section({ title, action, description }: SectionProps) { + return ( +
+
+
+
{title}
+ {action} +
+
+ + {description} + +
+
+
+ ) +} diff --git a/apps/renterd/components/RenterdSidenav.tsx b/apps/renterd/components/RenterdSidenav.tsx index 5bb428ba5..02e84faea 100644 --- a/apps/renterd/components/RenterdSidenav.tsx +++ b/apps/renterd/components/RenterdSidenav.tsx @@ -4,17 +4,14 @@ import { FolderIcon, FileContractIcon, BarsProgressIcon, - PlaneIcon, BellIcon, } from '@siafoundation/react-icons' import { useAlerts } from '@siafoundation/react-renterd' import { cx } from 'class-variance-authority' import { routes } from '../config/routes' -import { useApp } from '../contexts/app' import { useDialog } from '../contexts/dialog' export function RenterdSidenav() { - const { autopilot } = useApp() const alerts = useAlerts() const { openDialog } = useDialog() @@ -28,11 +25,6 @@ export function RenterdSidenav() { - {autopilot.status === 'on' && ( - - - - )} diff --git a/apps/renterd/components/TransfersBar.tsx b/apps/renterd/components/TransfersBar.tsx index 172d02b0b..fcd91a4ab 100644 --- a/apps/renterd/components/TransfersBar.tsx +++ b/apps/renterd/components/TransfersBar.tsx @@ -14,12 +14,14 @@ import { } from '@siafoundation/react-icons' import { useState } from 'react' import { useFiles } from '../contexts/files' +import { useAppSettings } from '@siafoundation/react-core' function getProgress(transfer: { loaded?: number; size?: number }) { return transfer.loaded !== undefined ? transfer.loaded / transfer.size : 1 } export function TransfersBar() { + const { isUnlocked } = useAppSettings() const { uploadsList, uploadCancel, downloadsList, downloadCancel } = useFiles() const [maximized, setMaximized] = useState(true) @@ -27,6 +29,10 @@ export function TransfersBar() { const uploadCount = uploadsList.length const downloadCount = downloadsList.length + if (!isUnlocked) { + return null + } + if (uploadCount === 0 && downloadCount === 0) { return null } diff --git a/apps/renterd/config/providers.tsx b/apps/renterd/config/providers.tsx index 06b0d67bd..ae8fc59a9 100644 --- a/apps/renterd/config/providers.tsx +++ b/apps/renterd/config/providers.tsx @@ -4,6 +4,7 @@ import { ContractsProvider } from '../contexts/contracts' import { HostsProvider } from '../contexts/hosts' import React from 'react' import { AppProvider } from '../contexts/app' +import { ConfigProvider } from '../contexts/config' type Props = { children: React.ReactNode @@ -12,18 +13,20 @@ type Props = { export function Providers({ children }: Props) { return ( - - - - - {/* this is here so that dialogs can use all the other providers, + + + + + + {/* this is here so that dialogs can use all the other providers, and the other providers can trigger dialogs */} - - {children} - - - - + + {children} + + + + + ) } diff --git a/apps/renterd/config/routes.ts b/apps/renterd/config/routes.ts index 258dd52aa..474b59b54 100644 --- a/apps/renterd/config/routes.ts +++ b/apps/renterd/config/routes.ts @@ -5,15 +5,14 @@ export const routes = { files: { index: '/files', }, - autopilot: { - index: '/autopilot', - contracts: '/autopilot#contracts', - hosts: '/autopilot#hosts', - wallet: '/autopilot#wallet', - }, config: { index: '/config', - gouging: '/config#gouging', + storage: '/config#storage', + pricing: '/config#pricing', + hosts: '/config#hosts', + wallet: '/config#wallet', + contracts: '/config#contracts', + uploads: '/config#uploads', redundancy: '/config#redundancy', }, contracts: { diff --git a/apps/renterd/contexts/config/fields.tsx b/apps/renterd/contexts/config/fields.tsx new file mode 100644 index 000000000..c0a62f76b --- /dev/null +++ b/apps/renterd/contexts/config/fields.tsx @@ -0,0 +1,643 @@ +/* eslint-disable react/no-unescaped-entities */ +import { + Code, + ConfigFields, + FieldSwitch, + Text, + Tooltip, + hoursInDays, + secondsInMinutes, + toFixedMax, +} from '@siafoundation/design-system' +import BigNumber from 'bignumber.js' +import React from 'react' +import { + defaultValues, + advancedDefaultAutopilot, + advancedDefaultContractSet, + advancedDefaultGouging, +} from './types' + +export const scDecimalPlaces = 6 + +type Categories = + | 'storage' + | 'gouging' + | 'hosts' + | 'wallet' + | 'contractset' + | 'uploadpacking' + | 'redundancy' + +type GetFields = { + isAutopilotEnabled: boolean + showAdvanced: boolean + redundancyMultiplier: BigNumber + includeRedundancyMaxStoragePrice: boolean + includeRedundancyMaxUploadPrice: boolean + storageAverage?: BigNumber + uploadAverage?: BigNumber + downloadAverage?: BigNumber + contractAverage?: BigNumber +} + +export function getFields({ + isAutopilotEnabled, + showAdvanced, + redundancyMultiplier, + includeRedundancyMaxStoragePrice, + includeRedundancyMaxUploadPrice, + storageAverage, + uploadAverage, + downloadAverage, + contractAverage, +}: GetFields): ConfigFields { + return { + // storage + storageTB: { + type: 'number', + category: 'storage', + title: 'Expected storage', + description: <>The amount of storage you would like to rent in TB., + units: 'TB', + hidden: !isAutopilotEnabled, + validation: isAutopilotEnabled + ? { + required: 'required', + } + : {}, + }, + uploadTBMonth: { + type: 'number', + category: 'storage', + title: 'Expected upload', + description: ( + <>The amount of upload bandwidth you plan to use each month in TB. + ), + units: 'TB/month', + hidden: !isAutopilotEnabled || !showAdvanced, + validation: + isAutopilotEnabled && showAdvanced + ? { + required: 'required', + } + : {}, + }, + downloadTBMonth: { + type: 'number', + category: 'storage', + title: 'Expected download', + description: ( + <>The amount of download bandwidth you plan to use each month in TB. + ), + units: 'TB/month', + hidden: !isAutopilotEnabled || !showAdvanced, + validation: + isAutopilotEnabled && showAdvanced + ? { + required: 'required', + } + : {}, + }, + allowanceMonth: { + type: 'siacoin', + category: 'storage', + title: 'Allowance', + description: ( + <>The amount of Siacoin you would like to spend for the period. + ), + decimalsLimitSc: scDecimalPlaces, + hidden: !isAutopilotEnabled || !showAdvanced, + // always required, but set in background unless advanced mode + validation: { + required: 'required', + }, + }, + periodWeeks: { + type: 'number', + category: 'storage', + title: 'Period', + description: <>The length of the storage contracts., + units: 'weeks', + suggestion: advancedDefaultAutopilot.periodWeeks, + suggestionTip: `Typically ${advancedDefaultAutopilot.periodWeeks} weeks.`, + hidden: !isAutopilotEnabled || !showAdvanced, + validation: + isAutopilotEnabled && showAdvanced + ? { + required: 'required', + } + : {}, + }, + renewWindowWeeks: { + type: 'number', + category: 'storage', + title: 'Renew window', + description: ( + <> + The number of weeks prior to contract expiration that Sia will attempt + to renew your contracts. + + ), + units: 'weeks', + decimalsLimit: 6, + suggestion: advancedDefaultAutopilot.renewWindowWeeks, + suggestionTip: `Typically ${advancedDefaultAutopilot.renewWindowWeeks} weeks.`, + hidden: !isAutopilotEnabled || !showAdvanced, + validation: + isAutopilotEnabled && showAdvanced + ? { + required: 'required', + } + : {}, + }, + amountHosts: { + type: 'number', + category: 'storage', + title: 'Hosts', + description: <>The number of hosts to create contracts with., + units: 'hosts', + decimalsLimit: 0, + suggestion: advancedDefaultAutopilot.amountHosts, + suggestionTip: `Typically ${advancedDefaultAutopilot.amountHosts} hosts.`, + hidden: !isAutopilotEnabled || !showAdvanced, + validation: + isAutopilotEnabled && showAdvanced + ? { + required: 'required', + } + : {}, + }, + autopilotContractSet: { + type: 'text', + category: 'storage', + title: 'Contract set', + description: ( + <> + The contract set that autopilot should use. This should typically be + the same as the default contract set. + + ), + placeholder: advancedDefaultAutopilot.autopilotContractSet, + suggestion: advancedDefaultAutopilot.autopilotContractSet, + suggestionTip: ( + <> + The default contract set is{' '} + {advancedDefaultAutopilot.autopilotContractSet}. + + ), + hidden: !isAutopilotEnabled || !showAdvanced, + validation: + isAutopilotEnabled && showAdvanced + ? { + required: 'required', + } + : {}, + }, + + // hosts + allowRedundantIPs: { + type: 'boolean', + category: 'hosts', + title: 'Redundant IPs', + description: ( + <> + Whether or not to allow forming contracts with multiple hosts in the + same IP subnet. The subnets used are /16 for IPv4, and /64 for IPv6. + + ), + suggestion: advancedDefaultAutopilot.allowRedundantIPs, + suggestionTip: `Defaults to ${ + advancedDefaultAutopilot.allowRedundantIPs ? 'on' : 'off' + }.`, + hidden: !isAutopilotEnabled || !showAdvanced, + validation: {}, + }, + maxDowntimeHours: { + type: 'number', + category: 'hosts', + title: 'Max downtime', + description: ( + <> + The maximum amount of host downtime that autopilot will tolerate in + hours. + + ), + units: 'hours', + suggestion: advancedDefaultAutopilot.maxDowntimeHours, + suggestionTip: `Defaults to ${advancedDefaultAutopilot.maxDowntimeHours + .toNumber() + .toLocaleString()} which is ${toFixedMax( + new BigNumber( + hoursInDays(advancedDefaultAutopilot.maxDowntimeHours.toNumber()) + ), + 1 + )} days.`, + hidden: !isAutopilotEnabled || !showAdvanced, + validation: + isAutopilotEnabled && showAdvanced + ? { + required: 'required', + } + : {}, + }, + + // wallet + defragThreshold: { + type: 'number', + category: 'wallet', + title: 'Defrag threshold', + description: ( + <>The threshold after which autopilot will defrag wallet outputs. + ), + units: 'outputs', + suggestion: advancedDefaultAutopilot.defragThreshold, + suggestionTip: 'Defaults to 1,000.', + hidden: !isAutopilotEnabled || !showAdvanced, + validation: + isAutopilotEnabled && showAdvanced + ? { + required: 'required', + } + : {}, + }, + + // contract + defaultContractSet: { + category: 'contractset', + type: 'text', + title: 'Default contract set', + placeholder: advancedDefaultContractSet.defaultContractSet, + suggestion: advancedDefaultContractSet.defaultContractSet, + suggestionTip: ( + <> + Autopilot users will typically want to keep this the same as the + autopilot contract set. + + ), + description: ( + <>The default contract set is where data is uploaded to by default. + ), + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + } + : {}, + }, + uploadPackingEnabled: { + category: 'uploadpacking', + type: 'boolean', + title: 'Upload packing', + description: ( + <> + Data on the Sia network is stored in 40MiB sectors so by default + uploaded files are divided and padded into these 40MiB peices. This + means that storage is wasted on padding but more importantly files + smaller than 40MiB still use 40MiB of space. Upload packing avoids + this waste by buffering files and packing them together before they + are uploaded to the network. This trades some performance for storage + efficiency. It is also important to note that because buffered files + are temporarily stored on disk they must be considered when backing up + your renterd data. + + ), + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + } + : {}, + }, + + // gouging + maxStoragePriceTBMonth: { + category: 'gouging', + type: 'siacoin', + title: 'Max storage price', + description: <>The max allowed price to store 1 TB per month., + units: 'SC/TB/month', + average: storageAverage, + averageTip: getAverageTip( + includeRedundancyMaxStoragePrice, + redundancyMultiplier + ), + after: function After({ form, fields }) { + return ( + +
+ + + Including {redundancyMultiplier.toFixed(1)}x redundancy + + +
+
+ ) + }, + decimalsLimitSc: scDecimalPlaces, + validation: { + required: 'required', + }, + }, + maxUploadPriceTB: { + category: 'gouging', + type: 'siacoin', + title: 'Max upload price', + description: <>The max allowed price to upload 1 TB., + units: 'SC/TB/month', + average: uploadAverage, + averageTip: getAverageTip( + includeRedundancyMaxUploadPrice, + redundancyMultiplier + ), + after: function After({ form, fields }) { + return ( + +
+ + + Including {redundancyMultiplier.toFixed(1)}x redundancy + + +
+
+ ) + }, + decimalsLimitSc: scDecimalPlaces, + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + } + : {}, + }, + maxDownloadPriceTB: { + category: 'gouging', + type: 'siacoin', + title: 'Max download price', + description: <>The max allowed price to download 1 TB., + units: 'SC/TB/month', + average: downloadAverage, + averageTip: `Averages provided by Sia Central.`, + decimalsLimitSc: scDecimalPlaces, + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + } + : {}, + }, + maxContractPrice: { + category: 'gouging', + type: 'siacoin', + title: 'Max contract price', + description: <>The max allowed price to form a contract., + average: contractAverage, + decimalsLimitSc: scDecimalPlaces, + tipsDecimalsLimitSc: 3, + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + } + : {}, + }, + maxRpcPriceMillion: { + category: 'gouging', + type: 'siacoin', + title: 'Max RPC price', + description: ( + <>The max allowed base price for RPCs in siacoins per million calls. + ), + units: 'SC/million', + decimalsLimitSc: scDecimalPlaces, + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + } + : {}, + }, + minMaxCollateral: { + category: 'gouging', + type: 'siacoin', + title: 'Min max collateral', + description: ( + <>The min value for max collateral in the host's price settings. + ), + decimalsLimitSc: scDecimalPlaces, + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + } + : {}, + }, + hostBlockHeightLeeway: { + category: 'gouging', + type: 'number', + title: 'Block height leeway', + description: ( + <> + The amount of blocks of leeway given to the host block height in the + host's price table. + + ), + units: 'blocks', + decimalsLimit: 0, + suggestion: advancedDefaultGouging.hostBlockHeightLeeway, + suggestionTip: 'The recommended value is 6 blocks.', + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + validate: { + min: (value) => + new BigNumber(value as BigNumber).gte(3) || + 'must be at least 3 blocks', + }, + } + : {}, + }, + minPriceTableValidityMinutes: { + category: 'gouging', + type: 'number', + title: 'Min price table validity', + units: 'minutes', + description: ( + <>The min accepted value for `Validity` in the host's price settings. + ), + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + validate: { + min: (value) => + new BigNumber(value as BigNumber).gte(secondsInMinutes(10)) || + 'must be at least 10 seconds', + }, + } + : {}, + }, + minAccountExpiryDays: { + category: 'gouging', + type: 'number', + title: 'Min account expiry', + units: 'days', + description: ( + <> + The min accepted value for `AccountExpiry` in the host's price + settings. + + ), + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + validate: { + min: (value) => + new BigNumber(value as BigNumber).gte(hoursInDays(1)) || + 'must be at least 1 hour', + }, + } + : {}, + }, + minMaxEphemeralAccountBalance: { + category: 'gouging', + type: 'siacoin', + title: 'Min max ephemeral account balance', + description: ( + <> + The min accepted value for `MaxEphemeralAccountBalance` in the host's + price settings. + + ), + decimalsLimitSc: scDecimalPlaces, + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + validate: { + min: (value) => + new BigNumber(value as BigNumber).gte(1) || + 'must be at least 1 SC', + }, + } + : {}, + }, + + // Redundancy + minShards: { + type: 'number', + category: 'redundancy', + title: 'Min shards', + description: <>The min amount of shards needed to reconstruct a slab., + units: 'shards', + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + validate: { + min: (value) => + new BigNumber(value as BigNumber).gt(0) || + 'must be greater than 0', + }, + } + : {}, + trigger: ['totalShards'], + }, + totalShards: { + type: 'number', + category: 'redundancy', + title: 'Total shards', + description: <>The total amount of shards for each slab., + units: 'shards', + hidden: !showAdvanced, + validation: showAdvanced + ? { + required: 'required', + validate: { + gteMinShards: (value, values) => + new BigNumber(value as BigNumber).gte(values.minShards) || + 'must be at least equal to min shards', + max: (value) => + new BigNumber(value as BigNumber).lt(256) || + 'must be less than 256', + }, + } + : {}, + }, + + // hidden fields used by other config options + includeRedundancyMaxStoragePrice: { + type: 'boolean', + title: 'Include redundancy', + validation: {}, + }, + includeRedundancyMaxUploadPrice: { + type: 'boolean', + title: 'Include redundancy', + validation: {}, + }, + } +} + +function getAverageTip( + includeRedundancy: boolean, + redundancyMultiplier: BigNumber +) { + if (includeRedundancy) { + return `The average price is adjusted for ${redundancyMultiplier.toFixed( + 1 + )}x redundancy. Averages provided by Sia Central.` + } + return `The average price is not adjusted for redundancy. Averages provided by Sia Central.` +} + +function getRedundancyTip( + includeRedundancy: boolean, + redundancyMultiplier: BigNumber +) { + if (includeRedundancy) { + return ( +
+ + Specified max price includes the cost of{' '} + {redundancyMultiplier.toFixed(1)}x redundancy. + + + Redundancy is calculated from the ratio of data shards:{' '} + min shards / total shards. + +
+ ) + } + return `Specified max price does not include redundancy.` +} diff --git a/apps/renterd/contexts/config/index.tsx b/apps/renterd/contexts/config/index.tsx new file mode 100644 index 000000000..79a3384c7 --- /dev/null +++ b/apps/renterd/contexts/config/index.tsx @@ -0,0 +1,542 @@ +import React, { createContext, useContext } from 'react' +import { + triggerSuccessToast, + triggerErrorToast, + useOnInvalid, + monthsToBlocks, + TBToBytes, +} from '@siafoundation/design-system' +import BigNumber from 'bignumber.js' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + AutopilotConfig, + autopilotHostsKey, + useAutopilotConfig, + useAutopilotConfigUpdate, + ContractSetSettings, + GougingSettings, + RedundancySettings, + UploadPackingSettings, + useSettingUpdate, +} from '@siafoundation/react-renterd' +import { toScale, toSiacoins } from '@siafoundation/sia-js' +import { getFields } from './fields' +import { SettingsData, defaultValues } from './types' +import { + getRedundancyMultiplier, + getRedundancyMultiplierIfIncluded, + transformDown, + transformUpAutopilot, + transformUpConfigApp, + transformUpContractSet, + transformUpGouging, + transformUpRedundancy, + transformUpUploadPacking, +} from './transform' +import { useForm } from 'react-hook-form' +import { useSyncContractSet } from './useSyncContractSet' +import { delay, useMutate } from '@siafoundation/react-core' +import { useContractSetSettings } from '../../hooks/useContractSetSettings' +import { + ConfigDisplayOptions, + configDisplayOptionsKey, + useConfigDisplayOptions, +} from '../../hooks/useConfigDisplayOptions' +import { useGougingSettings } from '../../hooks/useGougingSettings' +import { useRedundancySettings } from '../../hooks/useRedundancySettings' +import { useUploadPackingSettings } from '../../hooks/useUploadPackingSettings' +import { useSiaCentralHostsNetworkAverages } from '@siafoundation/react-sia-central' +import useLocalStorageState from 'use-local-storage-state' +import { useApp } from '../app' + +export function useConfigMain() { + const app = useApp() + const isAutopilotEnabled = app.autopilot.status === 'on' + // settings that 404 when empty + const autopilot = useAutopilotConfig({ + disabled: !isAutopilotEnabled, + standalone: 'configFormAutopilot', + config: { + swr: { + errorRetryCount: 0, + }, + }, + }) + const contractSet = useContractSetSettings({ + standalone: 'configFormContractSet', + config: { + swr: { + errorRetryCount: 0, + }, + }, + }) + const configApp = useConfigDisplayOptions({ + standalone: 'configFormConfigApp', + config: { + swr: { + errorRetryCount: 0, + }, + }, + }) + // settings with initial defaults + const gouging = useGougingSettings({ + standalone: 'configFormGouging', + }) + const redundancy = useRedundancySettings({ + standalone: 'configFormRedundancy', + }) + const uploadPacking = useUploadPackingSettings({ + standalone: 'configFormUploadPacking', + }) + + const settingUpdate = useSettingUpdate() + + const averages = useSiaCentralHostsNetworkAverages({ + config: { + swr: { + revalidateOnFocus: false, + }, + }, + }) + const [showAdvanced, setShowAdvanced] = useLocalStorageState( + 'v0/config/showAdvanced', + { + defaultValue: false, + } + ) + const { + shouldSyncDefaultContractSet, + setShouldSyncDefaultContractSet, + syncDefaultContractSet, + } = useSyncContractSet() + const autopilotUpdate = useAutopilotConfigUpdate() + + const form = useForm({ + mode: 'all', + defaultValues, + }) + + const resetFormData = useCallback( + ( + autopilotData: AutopilotConfig | undefined, + contractSetData: ContractSetSettings | undefined, + uploadPackingData: UploadPackingSettings, + gougingData: GougingSettings, + redundancyData: RedundancySettings, + configAppData: ConfigDisplayOptions | undefined + ) => { + const settingsData = transformDown( + autopilotData, + contractSetData, + uploadPackingData, + gougingData, + redundancyData, + configAppData + ) + form.reset(settingsData) + return settingsData + }, + [form] + ) + + const didDataRevalidate = useMemo( + () => [ + autopilot.data, + autopilot.error, + contractSet.data, + contractSet.error, + uploadPacking.data, + gouging.data, + redundancy.data, + configApp.data, + configApp.error, + ], + [ + autopilot.data, + autopilot.error, + contractSet.data, + contractSet.error, + uploadPacking.data, + gouging.data, + redundancy.data, + configApp.data, + configApp.error, + ] + ) + + const resetFormDataIfAllDataFetched = useCallback((): SettingsData | null => { + if ( + (!isAutopilotEnabled || autopilot.data || autopilot.error) && + gouging.data && + redundancy.data && + uploadPacking.data && + (contractSet.data || contractSet.error) && + (configApp.data || configApp.error) + ) { + return resetFormData( + autopilot.data, + contractSet.data, + uploadPacking.data, + gouging.data, + redundancy.data, + configApp.data + ) + } + return null + }, [ + isAutopilotEnabled, + resetFormData, + autopilot.data, + autopilot.error, + contractSet.data, + contractSet.error, + uploadPacking.data, + gouging.data, + redundancy.data, + configApp.data, + configApp.error, + ]) + + // init - when new config is fetched, set the form + const [hasInit, setHasInit] = useState(false) + useEffect(() => { + if (app.autopilot.status === 'init') { + return + } + if (!hasInit) { + const didReset = resetFormDataIfAllDataFetched() + if (didReset) { + setHasInit(true) + } + } + }, [hasInit, app.autopilot.status, resetFormDataIfAllDataFetched]) + + const revalidateAndResetFormData = useCallback(async () => { + const autopilotData = isAutopilotEnabled + ? await autopilot.mutate() + : undefined + const contractSetData = await contractSet.mutate() + const gougingData = await gouging.mutate() + const redundancyData = await redundancy.mutate() + const uploadPackingData = await uploadPacking.mutate() + const configAppData = await configApp.mutate() + if (!gougingData || !redundancyData) { + triggerErrorToast('Error fetching settings.') + } else { + resetFormData( + autopilotData, + contractSetData, + uploadPackingData, + gougingData, + redundancyData, + configAppData + ) + } + }, [ + isAutopilotEnabled, + autopilot, + contractSet, + gouging, + uploadPacking, + redundancy, + configApp, + resetFormData, + ]) + + const minShards = form.watch('minShards') + const totalShards = form.watch('totalShards') + const includeRedundancyMaxStoragePrice = form.watch( + 'includeRedundancyMaxStoragePrice' + ) + const includeRedundancyMaxUploadPrice = form.watch( + 'includeRedundancyMaxUploadPrice' + ) + const storageTB = form.watch('storageTB') + const allowanceMonth = form.watch('allowanceMonth') + const maxStoragePriceTBMonth = form.watch('maxStoragePriceTBMonth') + + const redundancyMultiplier = useMemo( + () => getRedundancyMultiplier(minShards, totalShards), + [minShards, totalShards] + ) + const fields = useMemo(() => { + if (averages.data) { + return getFields({ + isAutopilotEnabled, + showAdvanced, + redundancyMultiplier, + includeRedundancyMaxStoragePrice, + includeRedundancyMaxUploadPrice, + storageAverage: toSiacoins(averages.data.settings.storage_price) // bytes/block + .times(monthsToBlocks(1)) // bytes/month + .times(TBToBytes(1)) // TB/month + .times( + getRedundancyMultiplierIfIncluded( + minShards, + totalShards, + includeRedundancyMaxStoragePrice + ) + ), // redundancy + uploadAverage: toSiacoins(averages.data.settings.upload_price) // bytes + .times(TBToBytes(1)) // TB + .times( + getRedundancyMultiplierIfIncluded( + minShards, + totalShards, + includeRedundancyMaxUploadPrice + ) + ), // redundancy + downloadAverage: toSiacoins(averages.data.settings.download_price) // bytes + .times(TBToBytes(1)), // TB + contractAverage: toSiacoins(averages.data.settings.contract_price), + }) + } + return getFields({ + isAutopilotEnabled, + showAdvanced, + redundancyMultiplier, + includeRedundancyMaxStoragePrice, + includeRedundancyMaxUploadPrice, + }) + }, [ + isAutopilotEnabled, + showAdvanced, + averages.data, + redundancyMultiplier, + minShards, + totalShards, + includeRedundancyMaxStoragePrice, + includeRedundancyMaxUploadPrice, + ]) + + const mutate = useMutate() + const onValid = useCallback( + async (values: typeof defaultValues) => { + if (!gouging.data || !redundancy.data) { + return + } + try { + const firstTimeSettingConfig = isAutopilotEnabled && !autopilot.data + const autopilotResponse = isAutopilotEnabled + ? await autopilotUpdate.put({ + payload: transformUpAutopilot(values, autopilot.data), + }) + : undefined + const contractSetResponse = await settingUpdate.put({ + params: { + key: 'contractset', + }, + payload: transformUpContractSet(values, contractSet.data), + }) + const uploadPackingResponse = await settingUpdate.put({ + params: { + key: 'uploadpacking', + }, + payload: transformUpUploadPacking(values, uploadPacking.data), + }) + const gougingResponse = await settingUpdate.put({ + params: { + key: 'gouging', + }, + payload: transformUpGouging(values, gouging.data), + }) + const redundancyResponse = await settingUpdate.put({ + params: { + key: 'redundancy', + }, + payload: transformUpRedundancy(values, redundancy.data), + }) + const configAppResponse = await settingUpdate.put({ + params: { + key: configDisplayOptionsKey, + }, + payload: transformUpConfigApp(values, configApp.data), + }) + if (autopilotResponse?.error) { + throw Error(autopilotResponse.error) + } + if (contractSetResponse.error) { + throw Error(contractSetResponse.error) + } + if (uploadPackingResponse.error) { + throw Error(uploadPackingResponse.error) + } + if (gougingResponse.error) { + throw Error(gougingResponse.error) + } + if (redundancyResponse.error) { + throw Error(redundancyResponse.error) + } + if (configAppResponse.error) { + throw Error(configAppResponse.error) + } + + triggerSuccessToast('Configuration has been saved.') + if (isAutopilotEnabled) { + syncDefaultContractSet(values.autopilotContractSet) + } + + // if autopilot is being configured for the first time, + // revalidate the empty hosts list. + if (firstTimeSettingConfig) { + const refreshHostsAfterDelay = async () => { + await delay(5_000) + mutate((key) => key.startsWith(autopilotHostsKey)) + await delay(5_000) + mutate((key) => key.startsWith(autopilotHostsKey)) + } + refreshHostsAfterDelay() + } + + await revalidateAndResetFormData() + } catch (e) { + triggerErrorToast((e as Error).message) + console.log(e) + } + }, + [ + isAutopilotEnabled, + autopilot, + autopilotUpdate, + revalidateAndResetFormData, + syncDefaultContractSet, + mutate, + settingUpdate, + contractSet, + uploadPacking, + redundancy, + gouging, + configApp, + ] + ) + + const onInvalid = useOnInvalid(fields) + + const onSubmit = useMemo( + () => form.handleSubmit(onValid, onInvalid), + [form, onValid, onInvalid] + ) + + const canEstimate = useMemo(() => { + if (!isAutopilotEnabled) { + return false + } + return !( + !storageTB || + !allowanceMonth || + storageTB.isZero() || + allowanceMonth.isZero() + ) + }, [isAutopilotEnabled, storageTB, allowanceMonth]) + + const estimatedSpendingPerMonth = useMemo(() => { + if (!canEstimate) { + return new BigNumber(0) + } + return toScale(allowanceMonth, 0) + }, [canEstimate, allowanceMonth]) + + const estimatedSpendingPerTB = useMemo(() => { + if (!canEstimate) { + return new BigNumber(0) + } + const estimatedSpendingPerMonthTB = estimatedSpendingPerMonth.div(storageTB) + return toScale(estimatedSpendingPerMonthTB, 0) + }, [canEstimate, estimatedSpendingPerMonth, storageTB]) + + // if simple mode, then calculate and set allowance when dependent values change + useEffect(() => { + if ( + !showAdvanced && + storageTB?.isGreaterThan(0) && + maxStoragePriceTBMonth?.isGreaterThan(0) + ) { + form.setValue( + 'allowanceMonth', + maxStoragePriceTBMonth + .times(storageTB) + .times( + !includeRedundancyMaxStoragePrice + ? getRedundancyMultiplier(minShards, totalShards) + : 1 + ), + { + shouldDirty: true, + } + ) + } + }, [ + form, + showAdvanced, + storageTB, + maxStoragePriceTBMonth, + minShards, + totalShards, + includeRedundancyMaxStoragePrice, + ]) + + // Resets so that stale values that are no longer in sync with what is on + // the daemon will show up as changed. + const resetWithUserChanges = useCallback(() => { + const currentFormValues = form.getValues() + const serverFormValues = resetFormDataIfAllDataFetched() + form.reset(serverFormValues) + for (const [key, value] of Object.entries(currentFormValues)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form.setValue(key as any, value, { + shouldDirty: true, + }) + } + }, [form, resetFormDataIfAllDataFetched]) + + useEffect(() => { + if (form.formState.isSubmitting) { + return + } + resetWithUserChanges() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + form, + // if form mode is toggled reset + showAdvanced, + // if any of the settings are revalidated reset + didDataRevalidate, + ]) + + const changeCount = Object.entries(form.formState.dirtyFields).filter( + ([_, val]) => !!val + ).length + + return { + onSubmit, + revalidateAndResetFormData, + form, + fields, + changeCount, + canEstimate, + estimatedSpendingPerMonth, + estimatedSpendingPerTB, + redundancyMultiplier, + storageTB, + shouldSyncDefaultContractSet, + setShouldSyncDefaultContractSet, + showAdvanced, + setShowAdvanced, + } +} + +type State = ReturnType + +const ConfigContext = createContext({} as State) +export const useConfig = () => useContext(ConfigContext) + +type Props = { + children: React.ReactNode +} + +export function ConfigProvider({ children }: Props) { + const state = useConfigMain() + return ( + {children} + ) +} diff --git a/apps/renterd/contexts/config/transform.spec.ts b/apps/renterd/contexts/config/transform.spec.ts new file mode 100644 index 000000000..942ee5c46 --- /dev/null +++ b/apps/renterd/contexts/config/transform.spec.ts @@ -0,0 +1,553 @@ +import BigNumber from 'bignumber.js' +import { + transformDown, + transformUpAutopilot, + transformUpContractSet, + transformUpGouging, + transformUpRedundancy, + valuePerMonthToPerPeriod, + valuePerPeriodToPerMonth, +} from './transform' +import { SettingsData } from './types' +import { + blocksToWeeks, + monthsToBlocks, + weeksToBlocks, +} from '@siafoundation/design-system' +import { toHastings } from '@siafoundation/sia-js' + +describe('tansforms', () => { + describe('down', () => { + it('default works', () => { + expect( + transformDown( + { + wallet: { + defragThreshold: 1000, + }, + hosts: { + allowRedundantIPs: false, + maxDowntimeHours: 1440, + scoreOverrides: null, + }, + contracts: { + set: 'autopilot', + amount: 51, + allowance: toHastings(500).toString(), + period: monthsToBlocks(1), + renewWindow: 2248, + download: 1099511627776, + upload: 1100000000000, + storage: 1000000000000, + }, + }, + { default: 'myset' }, + { enabled: true }, + { + hostBlockHeightLeeway: 4, + maxContractPrice: '20000000000000000000000000', + maxDownloadPrice: '1004310000000000000000000000', + maxRPCPrice: '99970619000000000000000000', + maxStoragePrice: '210531181019', + maxUploadPrice: '1000232323000000000000000000', + minAccountExpiry: 86400000000000, + minMaxCollateral: '10000000000000000000000000', + minMaxEphemeralAccountBalance: '1000000000000000000000000', + minPriceTableValidity: 300000000000, + }, + { + minShards: 10, + totalShards: 30, + }, + { + includeRedundancyMaxStoragePrice: false, + includeRedundancyMaxUploadPrice: false, + } + ) + ).toEqual({ + autopilotContractSet: 'autopilot', + allowanceMonth: new BigNumber('500'), + amountHosts: new BigNumber('51'), + periodWeeks: new BigNumber('4.285714285714286'), + renewWindowWeeks: new BigNumber('2.2301587301587302'), + downloadTBMonth: new BigNumber('1.1'), + uploadTBMonth: new BigNumber('1.1'), + storageTB: new BigNumber('1'), + allowRedundantIPs: false, + maxDowntimeHours: new BigNumber('1440'), + defragThreshold: new BigNumber('1000'), + defaultContractSet: 'myset', + uploadPackingEnabled: true, + hostBlockHeightLeeway: new BigNumber(4), + maxContractPrice: new BigNumber('20'), + maxDownloadPriceTB: new BigNumber('1004.31'), + maxRpcPriceMillion: new BigNumber('99970619'), + maxStoragePriceTBMonth: new BigNumber('909.494702'), + maxUploadPriceTB: new BigNumber('1000.232323'), + minAccountExpiryDays: new BigNumber(1), + minMaxCollateral: new BigNumber('10'), + minMaxEphemeralAccountBalance: new BigNumber('1'), + minPriceTableValidityMinutes: new BigNumber(5), + minShards: new BigNumber(10), + totalShards: new BigNumber(30), + includeRedundancyMaxStoragePrice: false, + includeRedundancyMaxUploadPrice: false, + } as SettingsData) + }) + + it('with include redundancy for storage and upload', () => { + expect( + transformDown( + { + wallet: { + defragThreshold: 1000, + }, + hosts: { + allowRedundantIPs: false, + maxDowntimeHours: 1440, + scoreOverrides: null, + }, + contracts: { + set: 'autopilot', + amount: 51, + allowance: '8408400000000000000000000000', + period: 6048, + renewWindow: 2248, + download: 1099511627776, + upload: 1100000000000, + storage: 1000000000000, + }, + }, + { default: 'myset' }, + { enabled: true }, + { + hostBlockHeightLeeway: 4, + maxContractPrice: '20000000000000000000000000', + maxDownloadPrice: '1004310000000000000000000000', + maxRPCPrice: '99970619000000000000000000', + maxStoragePrice: '210531181019', + maxUploadPrice: '1000232323000000000000000000', + minAccountExpiry: 86400000000000, + minMaxCollateral: '10000000000000000000000000', + minMaxEphemeralAccountBalance: '1000000000000000000000000', + minPriceTableValidity: 300000000000, + }, + { + minShards: 10, + totalShards: 30, + }, + { + includeRedundancyMaxStoragePrice: true, + includeRedundancyMaxUploadPrice: true, + } + ) + ).toEqual({ + autopilotContractSet: 'autopilot', + allowanceMonth: new BigNumber('6006'), + amountHosts: new BigNumber('51'), + periodWeeks: new BigNumber('6'), + renewWindowWeeks: new BigNumber('2.2301587301587302'), + downloadTBMonth: new BigNumber('0.79'), + uploadTBMonth: new BigNumber('0.79'), + storageTB: new BigNumber('1'), + allowRedundantIPs: false, + maxDowntimeHours: new BigNumber('1440'), + defragThreshold: new BigNumber('1000'), + defaultContractSet: 'myset', + uploadPackingEnabled: true, + hostBlockHeightLeeway: new BigNumber(4), + maxContractPrice: new BigNumber('20'), + maxDownloadPriceTB: new BigNumber('1004.31'), + maxRpcPriceMillion: new BigNumber('99970619'), + maxStoragePriceTBMonth: new BigNumber('2728.484106'), + maxUploadPriceTB: new BigNumber('3000.696969'), + minAccountExpiryDays: new BigNumber(1), + minMaxCollateral: new BigNumber('10'), + minMaxEphemeralAccountBalance: new BigNumber('1'), + minPriceTableValidityMinutes: new BigNumber(5), + minShards: new BigNumber(10), + totalShards: new BigNumber(30), + includeRedundancyMaxStoragePrice: true, + includeRedundancyMaxUploadPrice: true, + } as SettingsData) + }) + }) + + describe('up', () => { + it('up autopilot', () => { + expect( + transformUpAutopilot( + { + autopilotContractSet: 'autopilot', + allowanceMonth: new BigNumber('6006'), + amountHosts: new BigNumber('51'), + periodWeeks: new BigNumber('6'), + renewWindowWeeks: new BigNumber('2.2301587301587302'), + downloadTBMonth: new BigNumber('0.785365448411428571428571428571'), + uploadTBMonth: new BigNumber('0.785714285714285714285714285714'), + storageTB: new BigNumber('1'), + allowRedundantIPs: false, + maxDowntimeHours: new BigNumber('1440'), + defragThreshold: new BigNumber('1000'), + }, + undefined + ) + ).toEqual({ + wallet: { + defragThreshold: 1000, + }, + hosts: { + allowRedundantIPs: false, + maxDowntimeHours: 1440, + scoreOverrides: null, + }, + contracts: { + set: 'autopilot', + amount: 51, + allowance: '8408400000000000000000000000', + period: 6048, + renewWindow: 2248, + download: 1099511627776, + upload: 1100000000000, + storage: 1000000000000, + }, + }) + }) + + it('up autopilot accepts unknown values', () => { + expect( + transformUpAutopilot( + { + autopilotContractSet: 'autopilot', + allowanceMonth: new BigNumber('6006'), + amountHosts: new BigNumber('51'), + periodWeeks: new BigNumber('6'), + renewWindowWeeks: new BigNumber('2.2301587301587302'), + downloadTBMonth: new BigNumber('0.785365448411428571428571428571'), + uploadTBMonth: new BigNumber('0.785714285714285714285714285714'), + storageTB: new BigNumber('1'), + allowRedundantIPs: false, + maxDowntimeHours: new BigNumber('1440'), + defragThreshold: new BigNumber('1000'), + }, + { + foobar1: 'value', + wallet: { + foobar: 'value', + }, + contracts: { + foobar: 'value', + period: 7777, + }, + hosts: { + foobar: 'value', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + ) + ).toEqual({ + foobar1: 'value', + wallet: { + foobar: 'value', + defragThreshold: 1000, + }, + hosts: { + foobar: 'value', + allowRedundantIPs: false, + maxDowntimeHours: 1440, + scoreOverrides: null, + }, + contracts: { + foobar: 'value', + set: 'autopilot', + amount: 51, + allowance: '8408400000000000000000000000', + period: 6048, + renewWindow: 2248, + download: 1099511627776, + upload: 1100000000000, + storage: 1000000000000, + }, + }) + }) + + it('up contractset', () => { + expect( + transformUpContractSet( + { + defaultContractSet: 'myset', + }, + { + default: '77777777777', + foobar: 'value', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + ) + ).toEqual({ + default: 'myset', + foobar: 'value', + }) + }) + + it('up gouging', () => { + expect( + transformUpGouging( + { + autopilotContractSet: 'autopilot', + allowanceMonth: new BigNumber('6006'), + amountHosts: new BigNumber('51'), + periodWeeks: new BigNumber('6'), + renewWindowWeeks: new BigNumber('2.2301587301587302'), + downloadTBMonth: new BigNumber('0.785365448411428571428571428571'), + uploadTBMonth: new BigNumber('0.785714285714285714285714285714'), + storageTB: new BigNumber('1'), + allowRedundantIPs: false, + maxDowntimeHours: new BigNumber('1440'), + defragThreshold: new BigNumber('1000'), + defaultContractSet: 'myset', + uploadPackingEnabled: false, + hostBlockHeightLeeway: new BigNumber(4), + maxContractPrice: new BigNumber('20'), + maxDownloadPriceTB: new BigNumber('1004.31'), + maxRpcPriceMillion: new BigNumber('99970619'), + maxStoragePriceTBMonth: new BigNumber('909.494702'), + maxUploadPriceTB: new BigNumber('1000.232323'), + minAccountExpiryDays: new BigNumber(1), + minMaxCollateral: new BigNumber('10'), + minMaxEphemeralAccountBalance: new BigNumber('1'), + minPriceTableValidityMinutes: new BigNumber(5), + minShards: new BigNumber(10), + totalShards: new BigNumber(30), + includeRedundancyMaxStoragePrice: false, + includeRedundancyMaxUploadPrice: false, + }, + { + maxStoragePrice: '77777777777', + foobar: 'value', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + ) + ).toEqual({ + foobar: 'value', + hostBlockHeightLeeway: 4, + maxContractPrice: '20000000000000000000000000', + maxDownloadPrice: '1004310000000000000000000000', + maxRPCPrice: '99970619000000000000000000', + maxStoragePrice: '210531181019', + maxUploadPrice: '1000232323000000000000000000', + minAccountExpiry: 86400000000000, + minMaxCollateral: '10000000000000000000000000', + minMaxEphemeralAccountBalance: '1000000000000000000000000', + minPriceTableValidity: 300000000000, + }) + }) + + it('up gouging with include redundancy for storage', () => { + expect( + transformUpGouging( + { + autopilotContractSet: 'autopilot', + allowanceMonth: new BigNumber('6006'), + amountHosts: new BigNumber('51'), + periodWeeks: new BigNumber('6'), + renewWindowWeeks: new BigNumber('2.2301587301587302'), + downloadTBMonth: new BigNumber('0.785365448411428571428571428571'), + uploadTBMonth: new BigNumber('0.785714285714285714285714285714'), + storageTB: new BigNumber('1'), + allowRedundantIPs: false, + maxDowntimeHours: new BigNumber('1440'), + defragThreshold: new BigNumber('1000'), + defaultContractSet: 'myset', + uploadPackingEnabled: false, + hostBlockHeightLeeway: new BigNumber(4), + maxContractPrice: new BigNumber('20'), + maxDownloadPriceTB: new BigNumber('1004.31'), + maxRpcPriceMillion: new BigNumber('99970619'), + maxStoragePriceTBMonth: new BigNumber('909.494702'), + maxUploadPriceTB: new BigNumber('1000.232323'), + minAccountExpiryDays: new BigNumber(1), + minMaxCollateral: new BigNumber('10'), + minMaxEphemeralAccountBalance: new BigNumber('1'), + minPriceTableValidityMinutes: new BigNumber(5), + minShards: new BigNumber(10), + totalShards: new BigNumber(30), + includeRedundancyMaxStoragePrice: true, + includeRedundancyMaxUploadPrice: false, + }, + { + maxStoragePrice: '77777777777', + foobar: 'value', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + ) + ).toEqual({ + foobar: 'value', + hostBlockHeightLeeway: 4, + maxContractPrice: '20000000000000000000000000', + maxDownloadPrice: '1004310000000000000000000000', + maxRPCPrice: '99970619000000000000000000', + maxStoragePrice: '70177060340', + maxUploadPrice: '1000232323000000000000000000', + minAccountExpiry: 86400000000000, + minMaxCollateral: '10000000000000000000000000', + minMaxEphemeralAccountBalance: '1000000000000000000000000', + minPriceTableValidity: 300000000000, + }) + }) + + it('up redundancy', () => { + expect( + transformUpRedundancy( + { + minShards: new BigNumber(10), + totalShards: new BigNumber(30), + }, + { + minShards: 77, + foobar: 'value', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + ) + ).toEqual({ + foobar: 'value', + minShards: 10, + totalShards: 30, + }) + }) + }) + + describe('up down', () => { + it('converts ap download up down', () => { + const { + autopilot, + contractSet, + uploadPacking, + gouging, + redundancy, + display, + } = buildAllResponses() + let settings = transformDown( + { + ...autopilot, + contracts: { + ...autopilot.contracts, + download: 91085125718831, + period: 4244, + }, + }, + contractSet, + uploadPacking, + gouging, + redundancy, + display + ) + expect(settings.downloadTBMonth).toEqual(new BigNumber('92.72')) + // a little different due to rounding + expect( + transformUpAutopilot(settings, autopilot).contracts.download + ).toEqual(91088814814815) + + settings = transformDown( + { + ...autopilot, + contracts: { + ...autopilot.contracts, + download: 91088814814815, + period: 4244, + }, + }, + contractSet, + uploadPacking, + gouging, + redundancy, + display + ) + expect(settings.downloadTBMonth).toEqual(new BigNumber('92.72')) + // using the rounded value results in same value + expect( + transformUpAutopilot(settings, autopilot).contracts.download + ).toEqual(91088814814815) + }) + }) + + it('ap download', () => { + const valuePerPeriod = new BigNumber(91085125718831) + const periodBlocks = new BigNumber(4244) + const valuePerMonth = valuePerPeriodToPerMonth( + valuePerPeriod, + periodBlocks.toNumber() + ) + expect(valuePerMonth).toEqual( + new BigNumber('92716244841034.38265786993402450518378887952') + ) + const periodWeeks = new BigNumber(blocksToWeeks(periodBlocks.toNumber())) + expect( + valuePerMonthToPerPeriod(valuePerMonth, periodWeeks).toFixed(0) + ).toEqual(valuePerPeriod.toString()) + }) + + it('month -> period', () => { + const valuePerMonth = new BigNumber(87908469486735) + const periodWeeks = new BigNumber(30).div(7) + expect(valuePerMonthToPerPeriod(valuePerMonth, periodWeeks)).toEqual( + valuePerMonth + ) + }) + it('period <- month', () => { + const valuePerMonth = new BigNumber(30) + const periodWeeks = new BigNumber(30).div(7) + const valuePerPeriod = valuePerMonthToPerPeriod(valuePerMonth, periodWeeks) + + const periodBlocks = weeksToBlocks(periodWeeks.toNumber()) + expect( + valuePerPeriodToPerMonth(valuePerPeriod, periodBlocks).toFixed(0) + ).toEqual('30') + }) +}) + +function buildAllResponses() { + return { + autopilot: { + wallet: { + defragThreshold: 1000, + }, + hosts: { + allowRedundantIPs: false, + maxDowntimeHours: 1440, + scoreOverrides: null, + }, + contracts: { + set: 'autopilot', + amount: 51, + allowance: toHastings(500).toString(), + period: monthsToBlocks(1), + renewWindow: 2248, + download: 1099511627776, + upload: 1100000000000, + storage: 1000000000000, + }, + }, + contractSet: { default: 'myset' }, + uploadPacking: { enabled: true }, + gouging: { + hostBlockHeightLeeway: 4, + maxContractPrice: '20000000000000000000000000', + maxDownloadPrice: '1004310000000000000000000000', + maxRPCPrice: '99970619000000000000000000', + maxStoragePrice: '210531181019', + maxUploadPrice: '1000232323000000000000000000', + minAccountExpiry: 86400000000000, + minMaxCollateral: '10000000000000000000000000', + minMaxEphemeralAccountBalance: '1000000000000000000000000', + minPriceTableValidity: 300000000000, + }, + redundancy: { + minShards: 10, + totalShards: 30, + }, + display: { + includeRedundancyMaxStoragePrice: false, + includeRedundancyMaxUploadPrice: false, + }, + } +} diff --git a/apps/renterd/components/Config/transform.ts b/apps/renterd/contexts/config/transform.ts similarity index 50% rename from apps/renterd/components/Config/transform.ts rename to apps/renterd/contexts/config/transform.ts index 12d29353a..95eab430a 100644 --- a/apps/renterd/components/Config/transform.ts +++ b/apps/renterd/contexts/config/transform.ts @@ -1,47 +1,117 @@ import { + blocksToWeeks, + bytesToTB, + weeksToBlocks, daysInNanoseconds, minutesInNanoseconds, monthsToBlocks, nanosecondsInDays, nanosecondsInMinutes, TBToBytes, + toFixedMax, } from '@siafoundation/design-system' import { + AutopilotConfig, ContractSetSettings, GougingSettings, RedundancySettings, UploadPackingSettings, } from '@siafoundation/react-renterd' import { toHastings, toSiacoins } from '@siafoundation/sia-js' -import { ConfigDisplayOptions } from '../../hooks/useConfigDisplayOptions' import BigNumber from 'bignumber.js' import { + AutopilotData, + scDecimalPlaces, + SettingsData, + advancedDefaultAutopilot, ConfigAppData, ContractSetData, defaultConfigApp, defaultContractSet, GougingData, RedundancyData, - scDecimalPlaces, - SettingsData, UploadPackingData, -} from './fields' + defaultAutopilot, + advancedDefaultContractSet, +} from './types' +import { ConfigDisplayOptions } from '../../hooks/useConfigDisplayOptions' + +const filterUndefinedKeys = (obj: Record) => { + return Object.fromEntries( + Object.entries(obj).filter( + ([key, value]) => value !== undefined && value !== '' + ) + ) +} // up +export function transformUpAutopilot( + values: AutopilotData, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + existingValues: AutopilotConfig | undefined +): AutopilotConfig { + // merge suggestions with values, if advanced values are required they will + // be added before this function is called and will override suggestions + const v: AutopilotData = { + ...advancedDefaultAutopilot, + ...filterUndefinedKeys(values), + } + + return { + ...existingValues, + contracts: { + ...existingValues?.contracts, + set: v.autopilotContractSet, + amount: Math.round(v.amountHosts.toNumber()), + allowance: toHastings( + valuePerMonthToPerPeriod(v.allowanceMonth, v.periodWeeks) + ).toString(), + period: Math.round(weeksToBlocks(v.periodWeeks.toNumber())), + renewWindow: Math.round(weeksToBlocks(v.renewWindowWeeks.toNumber())), + download: Number( + valuePerMonthToPerPeriod( + TBToBytes(v.downloadTBMonth), + v.periodWeeks + ).toFixed(0) + ), + upload: Number( + valuePerMonthToPerPeriod( + TBToBytes(v.uploadTBMonth), + v.periodWeeks + ).toFixed(0) + ), + storage: TBToBytes(v.storageTB).toNumber(), + }, + hosts: { + ...existingValues?.hosts, + maxDowntimeHours: v.maxDowntimeHours.toNumber(), + allowRedundantIPs: v.allowRedundantIPs, + scoreOverrides: existingValues?.hosts.scoreOverrides || null, + }, + wallet: { + ...existingValues?.wallet, + defragThreshold: v.defragThreshold.toNumber(), + }, + } +} export function transformUpContractSet( values: ContractSetData, - existingValues: Record + existingValues: ContractSetSettings | undefined ): ContractSetSettings { + const _default = + values.defaultContractSet || + (existingValues?.default as string) || + advancedDefaultContractSet.defaultContractSet return { ...existingValues, - default: values.contractSet, + default: _default, } } export function transformUpUploadPacking( values: UploadPackingData, - existingValues: Record + existingValues: UploadPackingSettings ): UploadPackingSettings { return { ...existingValues, @@ -51,13 +121,15 @@ export function transformUpUploadPacking( export function transformUpGouging( values: SettingsData, - existingValues: Record + existingValues: GougingSettings ): GougingSettings { return { ...existingValues, - maxRPCPrice: toHastings(values.maxRpcPrice.div(1_000_000)).toString(), + maxRPCPrice: toHastings( + values.maxRpcPriceMillion.div(1_000_000) + ).toString(), maxStoragePrice: toHastings( - values.maxStoragePrice // TB/month + values.maxStoragePriceTBMonth // TB/month .div(monthsToBlocks(1)) // TB/block .div(TBToBytes(1)) .div( @@ -69,7 +141,7 @@ export function transformUpGouging( ) // bytes/block ).toString(), maxUploadPrice: toHastings( - values.maxUploadPrice.div( + values.maxUploadPriceTB.div( getRedundancyMultiplierIfIncluded( values.minShards, values.totalShards, @@ -77,15 +149,15 @@ export function transformUpGouging( ) ) ).toString(), - maxDownloadPrice: toHastings(values.maxDownloadPrice).toString(), + maxDownloadPrice: toHastings(values.maxDownloadPriceTB).toString(), maxContractPrice: toHastings(values.maxContractPrice).toString(), minMaxCollateral: toHastings(values.minMaxCollateral).toString(), hostBlockHeightLeeway: Math.round(values.hostBlockHeightLeeway.toNumber()), minPriceTableValidity: Math.round( - minutesInNanoseconds(values.minPriceTableValidity.toNumber()) + minutesInNanoseconds(values.minPriceTableValidityMinutes.toNumber()) ), minAccountExpiry: Math.round( - daysInNanoseconds(values.minAccountExpiry.toNumber()) + daysInNanoseconds(values.minAccountExpiryDays.toNumber()) ), minMaxEphemeralAccountBalance: toHastings( values.minMaxEphemeralAccountBalance @@ -95,7 +167,7 @@ export function transformUpGouging( export function transformUpRedundancy( values: RedundancyData, - existingValues: Record + existingValues: RedundancySettings ): RedundancySettings { return { ...existingValues, @@ -106,7 +178,7 @@ export function transformUpRedundancy( export function transformUpConfigApp( values: ConfigAppData, - existingValues: Record + existingValues: Record | undefined ): ConfigDisplayOptions { return { ...existingValues, @@ -116,6 +188,63 @@ export function transformUpConfigApp( } // down +export function transformDownAutopilot( + config?: AutopilotConfig +): AutopilotData { + if (!config) { + return defaultAutopilot + } + + const autopilotContractSet = config.contracts.set + const allowanceMonth = toSiacoins( + valuePerPeriodToPerMonth( + new BigNumber(config.contracts.allowance), + config.contracts.period + ), + scDecimalPlaces + ) + const amountHosts = new BigNumber(config.contracts.amount) + const periodWeeks = new BigNumber(blocksToWeeks(config.contracts.period)) + const renewWindowWeeks = new BigNumber( + blocksToWeeks(config.contracts.renewWindow) + ) + const downloadTBMonth = new BigNumber( + toFixedMax( + valuePerPeriodToPerMonth( + bytesToTB(config.contracts.download), + config.contracts.period + ), + 2 + ) + ) + const uploadTBMonth = new BigNumber( + toFixedMax( + valuePerPeriodToPerMonth( + bytesToTB(config.contracts.upload), + config.contracts.period + ), + 2 + ) + ) + const storageTB = bytesToTB(new BigNumber(config.contracts.storage)) + + return { + // contracts + autopilotContractSet, + allowanceMonth, + amountHosts, + periodWeeks, + renewWindowWeeks, + downloadTBMonth, + uploadTBMonth, + storageTB, + // hosts + allowRedundantIPs: config.hosts.allowRedundantIPs, + maxDowntimeHours: new BigNumber(config.hosts.maxDowntimeHours), + // wallet + defragThreshold: new BigNumber(config.wallet.defragThreshold), + } +} export function transformDownContractSet( c?: ContractSetSettings @@ -124,7 +253,7 @@ export function transformDownContractSet( return defaultContractSet } return { - contractSet: c.default, + defaultContractSet: c.default, } } @@ -154,7 +283,7 @@ export function transformDownGouging( ca: ConfigAppData ): GougingData { return { - maxStoragePrice: toSiacoins( + maxStoragePriceTBMonth: toSiacoins( new BigNumber(g.maxStoragePrice) // bytes/block .times(monthsToBlocks(1)) // bytes/month .times(TBToBytes(1)) // tb/month @@ -167,7 +296,7 @@ export function transformDownGouging( ), scDecimalPlaces ), // TB/month - maxUploadPrice: toSiacoins( + maxUploadPriceTB: toSiacoins( new BigNumber(g.maxUploadPrice).times( getRedundancyMultiplierIfIncluded( r.minShards, @@ -177,15 +306,17 @@ export function transformDownGouging( ), scDecimalPlaces ), - maxDownloadPrice: toSiacoins(g.maxDownloadPrice, scDecimalPlaces), + maxDownloadPriceTB: toSiacoins(g.maxDownloadPrice, scDecimalPlaces), maxContractPrice: toSiacoins(g.maxContractPrice, scDecimalPlaces), - maxRpcPrice: toSiacoins(g.maxRPCPrice, scDecimalPlaces).times(1_000_000), + maxRpcPriceMillion: toSiacoins(g.maxRPCPrice, scDecimalPlaces).times( + 1_000_000 + ), minMaxCollateral: toSiacoins(g.minMaxCollateral, scDecimalPlaces), hostBlockHeightLeeway: new BigNumber(g.hostBlockHeightLeeway), - minPriceTableValidity: new BigNumber( + minPriceTableValidityMinutes: new BigNumber( nanosecondsInMinutes(g.minPriceTableValidity) ), - minAccountExpiry: new BigNumber(nanosecondsInDays(g.minAccountExpiry)), + minAccountExpiryDays: new BigNumber(nanosecondsInDays(g.minAccountExpiry)), minMaxEphemeralAccountBalance: toSiacoins( g.minMaxEphemeralAccountBalance, scDecimalPlaces @@ -201,6 +332,7 @@ export function transformDownRedundancy(r: RedundancySettings): RedundancyData { } export function transformDown( + a: AutopilotConfig | undefined, c: ContractSetSettings | undefined, u: UploadPackingSettings, g: GougingSettings, @@ -210,6 +342,8 @@ export function transformDown( const configApp = transformDownConfigApp(ca) const redundancy = transformDownRedundancy(r) return { + // autopilot + ...transformDownAutopilot(a), // contractset ...transformDownContractSet(c), // uploadpacking @@ -248,3 +382,35 @@ export function getRedundancyMultiplierIfIncluded( const redundancyMult = getRedundancyMultiplier(minShards, totalShards) return includeRedundancy ? redundancyMult : new BigNumber(1) } + +export function storagePricePerMonthToPerBlock(value: BigNumber) { + return value // TB/month + .div(monthsToBlocks(1)) // TB/block + .div(TBToBytes(1)) // bytes/block +} +export function storagePricePerMonthToPerBlockWithRedundancy( + value: BigNumber, + minShards: BigNumber, + totalShards: BigNumber, + includeRedundancy: boolean +) { + return storagePricePerMonthToPerBlock(value).div( + getRedundancyMultiplierIfIncluded(minShards, totalShards, includeRedundancy) + ) +} + +export function valuePerMonthToPerPeriod( + valuePerMonth: BigNumber, + periodWeeks: BigNumber +) { + const periodBlocks = weeksToBlocks(periodWeeks.toNumber()) + return valuePerMonth.times(periodBlocks).div(monthsToBlocks(1)) +} + +export function valuePerPeriodToPerMonth( + valuePerPeriod: BigNumber, + periodBlocks: number +) { + const valuePerBlock = valuePerPeriod.div(periodBlocks) + return valuePerBlock.times(monthsToBlocks(1)) +} diff --git a/apps/renterd/contexts/config/types.ts b/apps/renterd/contexts/config/types.ts new file mode 100644 index 000000000..148316812 --- /dev/null +++ b/apps/renterd/contexts/config/types.ts @@ -0,0 +1,110 @@ +import BigNumber from 'bignumber.js' + +export const scDecimalPlaces = 6 + +// form defaults +export const defaultAutopilot = { + // contracts + autopilotContractSet: '', + amountHosts: undefined as BigNumber | undefined, + allowanceMonth: undefined as BigNumber | undefined, + periodWeeks: undefined as BigNumber | undefined, + renewWindowWeeks: undefined as BigNumber | undefined, + downloadTBMonth: undefined as BigNumber | undefined, + uploadTBMonth: undefined as BigNumber | undefined, + storageTB: undefined as BigNumber | undefined, + // hosts + allowRedundantIPs: false, + maxDowntimeHours: undefined as BigNumber | undefined, + // wallet + defragThreshold: undefined as BigNumber | undefined, +} + +export const defaultContractSet = { + defaultContractSet: '', +} + +export const defaultUploadPacking = { + uploadPackingEnabled: true, +} + +export const defaultConfigApp = { + includeRedundancyMaxStoragePrice: true, + includeRedundancyMaxUploadPrice: true, +} + +export const defaultGouging = { + maxRpcPriceMillion: undefined as BigNumber | undefined, + maxStoragePriceTBMonth: undefined as BigNumber | undefined, + maxContractPrice: undefined as BigNumber | undefined, + maxDownloadPriceTB: undefined as BigNumber | undefined, + maxUploadPriceTB: undefined as BigNumber | undefined, + minMaxCollateral: undefined as BigNumber | undefined, + hostBlockHeightLeeway: undefined as BigNumber | undefined, + minPriceTableValidityMinutes: undefined as BigNumber | undefined, + minAccountExpiryDays: undefined as BigNumber | undefined, + minMaxEphemeralAccountBalance: undefined as BigNumber | undefined, +} + +export const defaultRedundancy = { + minShards: undefined as BigNumber | undefined, + totalShards: undefined as BigNumber | undefined, +} + +export const defaultValues = { + // autopilot + ...defaultAutopilot, + // contract set + ...defaultContractSet, + // upload packing + ...defaultUploadPacking, + // gouging + ...defaultGouging, + // redundancy + ...defaultRedundancy, + // config app + ...defaultConfigApp, +} + +export type AutopilotData = typeof defaultAutopilot +export type ContractSetData = typeof defaultContractSet +export type UploadPackingData = typeof defaultUploadPacking +export type ConfigAppData = typeof defaultConfigApp +export type GougingData = typeof defaultGouging +export type RedundancyData = typeof defaultRedundancy +export type SettingsData = typeof defaultValues + +// advanced defaults +export const advancedDefaultAutopilot: AutopilotData = { + ...defaultAutopilot, + downloadTBMonth: new BigNumber(1), + uploadTBMonth: new BigNumber(1), + periodWeeks: new BigNumber(6), + renewWindowWeeks: new BigNumber(2), + amountHosts: new BigNumber(50), + autopilotContractSet: 'autopilot', + allowRedundantIPs: false, + maxDowntimeHours: new BigNumber(1440), + defragThreshold: new BigNumber(1000), +} + +export const advancedDefaultContractSet: ContractSetData = { + ...defaultContractSet, + defaultContractSet: 'autopilot', +} + +export const advancedDefaultConfigApp: ConfigAppData = { + ...defaultConfigApp, +} + +export const advancedDefaultUploadPacking: UploadPackingData = { + ...defaultUploadPacking, +} + +export const advancedDefaultGouging: GougingData = { + ...defaultGouging, +} + +export const advancedDefaultRedundancy: RedundancyData = { + ...defaultRedundancy, +} diff --git a/apps/renterd/components/Autopilot/useSyncContractSet.tsx b/apps/renterd/contexts/config/useSyncContractSet.tsx similarity index 93% rename from apps/renterd/components/Autopilot/useSyncContractSet.tsx rename to apps/renterd/contexts/config/useSyncContractSet.tsx index b983f5786..fad4a909c 100644 --- a/apps/renterd/components/Autopilot/useSyncContractSet.tsx +++ b/apps/renterd/contexts/config/useSyncContractSet.tsx @@ -7,7 +7,7 @@ import { import { useCallback } from 'react' import { useSettingUpdate } from '@siafoundation/react-renterd' import useLocalStorageState from 'use-local-storage-state' -import { transformUpContractSet } from '../Config/transform' +import { transformUpContractSet } from '../../contexts/config/transform' import { useContractSetSettings } from '../../hooks/useContractSetSettings' export function useSyncContractSet() { @@ -38,7 +38,7 @@ export function useSyncContractSet() { }, payload: transformUpContractSet( { - contractSet: autopilotContractSet, + defaultContractSet: autopilotContractSet, }, contractSet.data ), diff --git a/apps/renterd/contexts/contracts/index.tsx b/apps/renterd/contexts/contracts/index.tsx index 32776d48a..1c98b6426 100644 --- a/apps/renterd/contexts/contracts/index.tsx +++ b/apps/renterd/contexts/contracts/index.tsx @@ -22,6 +22,7 @@ import { } from './types' import { columns } from './columns' import { useSiaCentralHosts } from '@siafoundation/react-sia-central' +import { useSyncStatus } from '../../hooks/useSyncStatus' const defaultLimit = 50 @@ -141,6 +142,16 @@ function useContractsMain() { filters ) + const syncStatus = useSyncStatus() + const datasetConfirmedCount = useMemo(() => { + if (!dataset) { + return 0 + } + return dataset.filter( + (d) => d.contractHeightStart < syncStatus.nodeBlockHeight + ).length + }, [dataset, syncStatus.nodeBlockHeight]) + return { dataState, limit, @@ -149,6 +160,7 @@ function useContractsMain() { error: response.error, pageCount: datasetPage?.length || 0, datasetCount: datasetFiltered?.length || 0, + datasetConfirmedCount, columns: filteredTableColumns, dataset, datasetPage, diff --git a/apps/renterd/contexts/files/index.tsx b/apps/renterd/contexts/files/index.tsx index 319073d92..8d3c809a0 100644 --- a/apps/renterd/contexts/files/index.tsx +++ b/apps/renterd/contexts/files/index.tsx @@ -18,6 +18,7 @@ import { FullPath, FullPathSegments, pathSegmentsToPath } from './paths' import { useUploads } from './uploads' import { useDownloads } from './downloads' import { useDataset } from './dataset' +import { OnboardingBar } from '../../components/OnboardingBar' function useFilesMain() { const router = useRouter() @@ -186,6 +187,7 @@ export function FilesProvider({ children }: Props) { return ( {children} + ) diff --git a/apps/renterd/pages/autopilot/index.tsx b/apps/renterd/pages/autopilot/index.tsx deleted file mode 100644 index ecc3bb184..000000000 --- a/apps/renterd/pages/autopilot/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Autopilot } from '../../components/Autopilot' - -export default function AutopilotPage() { - return -} diff --git a/libs/design-system/src/app/AppNavbar.tsx b/libs/design-system/src/app/AppNavbar.tsx index 06c3c8763..985dd3d8a 100644 --- a/libs/design-system/src/app/AppNavbar.tsx +++ b/libs/design-system/src/app/AppNavbar.tsx @@ -35,7 +35,7 @@ export function AppNavbar({ title, nav, stats, actions }: Props) {
{stats && ( -
+
{stats}
)} diff --git a/libs/design-system/src/core/Heading.tsx b/libs/design-system/src/core/Heading.tsx index a0a2e27ed..34fde4329 100644 --- a/libs/design-system/src/core/Heading.tsx +++ b/libs/design-system/src/core/Heading.tsx @@ -60,10 +60,10 @@ export const Heading = React.forwardRef< : '') return ( -
-
-
-
+
+
diff --git a/libs/design-system/src/form/ConfigurationPanel.tsx b/libs/design-system/src/form/ConfigurationPanel.tsx index 131f4409a..83d6cf0e7 100644 --- a/libs/design-system/src/form/ConfigurationPanel.tsx +++ b/libs/design-system/src/form/ConfigurationPanel.tsx @@ -25,8 +25,13 @@ export function ConfigurationPanel< Object.entries(fields) as [Path, ConfigField][] ).filter( ([_, val]) => - val.category === category && (!val.show || val.show(form.getValues())) + val.category === category && + !val.hidden && + (!val.show || val.show(form.getValues())) ) + if (list.length === 0) { + return null + } return ( {list.map(([key, val], i) => ( diff --git a/libs/design-system/src/form/configurationFields.ts b/libs/design-system/src/form/configurationFields.ts index a0666ca8e..e57334645 100644 --- a/libs/design-system/src/form/configurationFields.ts +++ b/libs/design-system/src/form/configurationFields.ts @@ -18,6 +18,7 @@ export type ConfigField< type: 'number' | 'siacoin' | 'text' | 'password' | 'boolean' | 'select' title: string actions?: React.ReactNode + hidden?: boolean category?: Categories description?: React.ReactNode units?: string @@ -68,11 +69,13 @@ export function useRegisterForm< const value = form.watch(name) const error = form.formState.touchedFields[name] && !!form.formState.errors[name] + const { ref, onChange: _onChange, onBlur, } = form.register(name, field.validation) + const onChange = useCallback( (e: { target: unknown; type: unknown }) => { _onChange(e) diff --git a/libs/react-core/src/request.ts b/libs/react-core/src/request.ts index defa30d73..448c763b1 100644 --- a/libs/react-core/src/request.ts +++ b/libs/react-core/src/request.ts @@ -19,12 +19,14 @@ export type HookArgsSwr< > = Params extends void ? { api?: string + standalone?: string config?: RequestConfig disabled?: boolean } : { params: Params api?: string + standalone?: string config?: RequestConfig disabled?: boolean } @@ -45,11 +47,13 @@ export type HookArgsWithPayloadSwr< ? Payload extends void ? { api?: string + standalone?: string config?: RequestConfig disabled?: boolean } : { api?: string + standalone?: string payload: Payload config?: RequestConfig disabled?: boolean @@ -58,6 +62,7 @@ export type HookArgsWithPayloadSwr< ? { params: Params api?: string + standalone?: string config?: RequestConfig disabled?: boolean } @@ -65,6 +70,7 @@ export type HookArgsWithPayloadSwr< params: Params payload: Payload api?: string + standalone?: string config?: RequestConfig disabled?: boolean } diff --git a/libs/react-core/src/useGet.ts b/libs/react-core/src/useGet.ts index 1eb718002..90359e093 100644 --- a/libs/react-core/src/useGet.ts +++ b/libs/react-core/src/useGet.ts @@ -32,7 +32,7 @@ export function useGetSwr( ) return useSWR( keyOrNull( - reqRoute, + args.standalone ? `${args.standalone}/${reqRoute}` : reqRoute, hookArgs.disabled || (passwordProtectRequestHooks && !settings.password) ), async () => {