diff --git a/.changeset/breezy-knives-cover.md b/.changeset/breezy-knives-cover.md new file mode 100644 index 000000000..026db2a66 --- /dev/null +++ b/.changeset/breezy-knives-cover.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Allowance now has a field-specific option to auto-calculate its value. Closes https://github.com/SiaFoundation/web/issues/628 diff --git a/.changeset/brown-wolves-chew.md b/.changeset/brown-wolves-chew.md new file mode 100644 index 000000000..b80c1fa27 --- /dev/null +++ b/.changeset/brown-wolves-chew.md @@ -0,0 +1,5 @@ +--- +'renterd': patch +--- + +Fixed an issue where first-time configuration would not show the optimal recommendations. diff --git a/.changeset/chilly-mugs-exercise.md b/.changeset/chilly-mugs-exercise.md new file mode 100644 index 000000000..cfe26b956 --- /dev/null +++ b/.changeset/chilly-mugs-exercise.md @@ -0,0 +1,5 @@ +--- +'renterd': patch +--- + +Fixed a bug where the churn alert would display NaN for the percentage when the total size was zero. diff --git a/.changeset/lemon-timers-turn.md b/.changeset/lemon-timers-turn.md new file mode 100644 index 000000000..df7990e9f --- /dev/null +++ b/.changeset/lemon-timers-turn.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Fields now have better support and styling for readOnly. diff --git a/.changeset/popular-timers-tan.md b/.changeset/popular-timers-tan.md new file mode 100644 index 000000000..6732fbf9c --- /dev/null +++ b/.changeset/popular-timers-tan.md @@ -0,0 +1,5 @@ +--- +'renterd': patch +--- + +Fixed an issue where toggling between basic and advanced modes would sometimes not revalidate all configuration fields. diff --git a/.changeset/rich-houses-exist.md b/.changeset/rich-houses-exist.md new file mode 100644 index 000000000..b8c1f1adf --- /dev/null +++ b/.changeset/rich-houses-exist.md @@ -0,0 +1,5 @@ +--- +'hostd': minor +--- + +Max collateral now has a field-specific option to auto-calculate its value. Closes https://github.com/SiaFoundation/web/issues/628 diff --git a/.changeset/thirty-peaches-switch.md b/.changeset/thirty-peaches-switch.md new file mode 100644 index 000000000..6d29121d0 --- /dev/null +++ b/.changeset/thirty-peaches-switch.md @@ -0,0 +1,6 @@ +--- +'hostd': minor +'renterd': minor +--- + +The configuration page now has a view menu in the action bar that is consistent with all other feature pages. diff --git a/.changeset/weak-cherries-press.md b/.changeset/weak-cherries-press.md new file mode 100644 index 000000000..2c3134641 --- /dev/null +++ b/.changeset/weak-cherries-press.md @@ -0,0 +1,6 @@ +--- +'hostd': minor +'renterd': minor +--- + +Basic configuration mode no longer sets certain fields in the background. Closes https://github.com/SiaFoundation/web/issues/628 diff --git a/apps/hostd/components/CmdRoot/ConfigCmdGroup.tsx b/apps/hostd/components/CmdRoot/ConfigCmdGroup.tsx index 4881675fa..0c89224ec 100644 --- a/apps/hostd/components/CmdRoot/ConfigCmdGroup.tsx +++ b/apps/hostd/components/CmdRoot/ConfigCmdGroup.tsx @@ -18,7 +18,7 @@ type Props = { export function ConfigCmdGroup({ currentPage, parentPage, pushPage }: Props) { const router = useRouter() - const { showAdvanced } = useConfig() + const { configViewMode } = useConfig() const { closeDialog } = useDialog() return ( @@ -82,7 +82,7 @@ export function ConfigCmdGroup({ currentPage, parentPage, pushPage }: Props) { > Configure bandwidth - {showAdvanced && ( + {configViewMode === 'advanced' && ( <> + ) } diff --git a/apps/hostd/components/Config/ConfigNav.tsx b/apps/hostd/components/Config/ConfigNav.tsx index 4616b9889..338ffea94 100644 --- a/apps/hostd/components/Config/ConfigNav.tsx +++ b/apps/hostd/components/Config/ConfigNav.tsx @@ -1,22 +1,3 @@ -import { Text, Switch, Tooltip } from '@siafoundation/design-system' -import { useConfig } from '../../contexts/config' - export function ConfigNav() { - const { showAdvanced, setShowAdvanced } = useConfig() - - return ( -
- -
- setShowAdvanced(checked)} - /> - - Advanced - -
-
-
- ) + return
} diff --git a/apps/hostd/components/Config/ConfigViewDropdownMenu.tsx b/apps/hostd/components/Config/ConfigViewDropdownMenu.tsx new file mode 100644 index 000000000..67ab338d5 --- /dev/null +++ b/apps/hostd/components/Config/ConfigViewDropdownMenu.tsx @@ -0,0 +1,34 @@ +import { + Button, + Label, + Popover, + MenuItemRightSlot, + BaseMenuItem, +} from '@siafoundation/design-system' +import { CaretDown16, SettingsAdjust16 } from '@siafoundation/react-icons' +import { ViewModeToggle } from './ViewModeToggle' + +export function ConfigViewDropdownMenu() { + return ( + + + View + + + } + contentProps={{ + align: 'end', + className: 'max-w-[300px]', + }} + > + + + + + + + + ) +} diff --git a/apps/hostd/components/Config/ViewModeToggle.tsx b/apps/hostd/components/Config/ViewModeToggle.tsx new file mode 100644 index 000000000..3644f101b --- /dev/null +++ b/apps/hostd/components/Config/ViewModeToggle.tsx @@ -0,0 +1,28 @@ +import { Switch, Tooltip } from '@siafoundation/design-system' +import { useConfig } from '../../contexts/config' + +export function ViewModeToggle() { + const { configViewMode, setConfigViewMode } = useConfig() + + return ( +
+ +
+ + setConfigViewMode(checked ? 'advanced' : 'basic') + } + /> +
+
+
+ ) +} diff --git a/apps/hostd/components/Config/index.tsx b/apps/hostd/components/Config/index.tsx index 06f718947..e90224440 100644 --- a/apps/hostd/components/Config/index.tsx +++ b/apps/hostd/components/Config/index.tsx @@ -10,6 +10,8 @@ import { FieldError, ConfigurationPanelSetting, shouldShowField, + Tooltip, + Switch, } from '@siafoundation/design-system' import { Warning16, CheckmarkFilled16 } from '@siafoundation/react-icons' import { HostdSidenav } from '../HostdSidenav' @@ -23,8 +25,16 @@ import { ConfigActions } from './ConfigActions' export function Config() { const { openDialog } = useDialog() - const { fields, settings, dynDNSCheck, form, remoteError, configRef } = - useConfig() + const { + fields, + settings, + dynDNSCheck, + form, + remoteError, + configRef, + autoMaxCollateral, + setAutoMaxCollateral, + } = useConfig() const shouldPinStoragePrice = form.watch('shouldPinStoragePrice') const shouldPinEgressPrice = form.watch('shouldPinEgressPrice') const shouldPinIngressPrice = form.watch('shouldPinIngressPrice') @@ -104,29 +114,23 @@ export function Config() { fields, name: 'shouldPinStoragePrice', }) && ( -
- - Storage price - - - pinned - - } - /> -
+ +
+ + Pin + + +
+
)} {shouldShowField({ form, @@ -169,27 +173,23 @@ export function Config() { fields, name: 'shouldPinEgressPrice', }) && ( -
- - Egress price - - - pinned - - } - /> -
+ +
+ + Pin + + +
+
)} {shouldShowField({ form, @@ -232,29 +232,23 @@ export function Config() { fields, name: 'shouldPinIngressPrice', }) && ( -
- - Ingress price - - - pinned - - } - /> -
+ +
+ + Pin + + +
+
)} {shouldShowField({ form, @@ -309,29 +303,41 @@ export function Config() { fields, name: 'shouldPinMaxCollateral', }) && ( -
- - Max collateral - - - pinned - - } - /> -
+ +
+ + Pin + + +
+
+ )} + {!shouldPinMaxCollateral && ( + +
+ + Calculate + + +
+
)} {shouldShowField({ form, @@ -364,6 +370,20 @@ export function Config() { /> )} + {shouldShowField({ + form, + fields, + name: 'contractPrice', + }) && ( + <> + + + + )} {shouldShowField({ form, fields, diff --git a/apps/hostd/contexts/config/fields.tsx b/apps/hostd/contexts/config/fields.tsx index ec6f8af6e..2c09aebd1 100644 --- a/apps/hostd/contexts/config/fields.tsx +++ b/apps/hostd/contexts/config/fields.tsx @@ -2,7 +2,12 @@ import { blocksToMonths } from '@siafoundation/units' import { ConfigFields } from '@siafoundation/design-system' import BigNumber from 'bignumber.js' -import { SettingsData, dnsProviderOptions, scDecimalPlaces } from './types' +import { + ConfigViewMode, + SettingsData, + dnsProviderOptions, + scDecimalPlaces, +} from './types' import { SiaCentralExchangeRates } from '@siafoundation/sia-central-types' import { calculateMaxCollateral } from './transform' import { currencyOptions } from '@siafoundation/react-core' @@ -11,18 +16,24 @@ type Categories = 'host' | 'pricing' | 'DNS' | 'bandwidth' | 'RHP3' type GetFields = { pinningEnabled: boolean - showAdvanced: boolean + configViewMode: ConfigViewMode storageTBMonth?: BigNumber collateralMultiplier?: BigNumber rates?: SiaCentralExchangeRates + autoMaxCollateral?: boolean + validationContext: { + pinningEnabled: boolean + } } export function getFields({ pinningEnabled, - showAdvanced, + configViewMode, storageTBMonth, collateralMultiplier, + autoMaxCollateral, rates, + validationContext, }: GetFields): ConfigFields { return { // Host @@ -54,7 +65,7 @@ export function getFields({ description: ( <>The maximum contract duration that the host will accept. ), - hidden: !showAdvanced, + hidden: configViewMode === 'basic', validation: { required: 'required', validate: { @@ -97,20 +108,23 @@ export function getFields({ changing prices too often. ), - hidden: !pinningEnabled || !showAdvanced, - validation: pinningEnabled - ? { - required: 'required', - validate: { - max: (value) => - new BigNumber(value as BigNumber).lte(100) || - `must be at most 100%`, - min: (value) => - new BigNumber(value as BigNumber).gte(0) || - `must be at least 0%`, - }, - } - : {}, + hidden: !pinningEnabled || configViewMode === 'basic', + validation: { + validate: { + required: requiredIfPinningEnabled(validationContext), + max: requiredIfPinningEnabled( + validationContext, + (value) => + new BigNumber(value as BigNumber).lte(100) || + `must be at most 100%` + ), + min: requiredIfPinningEnabled( + validationContext, + (value) => + new BigNumber(value as BigNumber).gte(0) || `must be at least 0%` + ), + }, + }, }, shouldPinStoragePrice: { @@ -148,19 +162,23 @@ export function getFields({ type: 'fiat', category: 'pricing', hidden: !pinningEnabled, - validation: pinningEnabled - ? { - required: 'required', - validate: { - currency: (value, values) => - !!values.pinnedCurrency || 'must select a pinned currency', - range: (value: BigNumber, values) => - !values.shouldPinStoragePrice || - value?.gt(0) || - 'storage price must be greater than 0', - }, - } - : {}, + validation: { + validate: { + required: requiredIfPinningEnabled(validationContext), + currency: requiredIfPinningEnabled( + validationContext, + (_, values) => + !!values.pinnedCurrency || 'must select a pinned currency' + ), + range: requiredIfPinningEnabled( + validationContext, + (value: BigNumber, values) => + !values.shouldPinStoragePrice || + value?.gt(0) || + 'storage price must be greater than 0' + ), + }, + }, }, shouldPinEgressPrice: { @@ -199,19 +217,23 @@ export function getFields({ units: '/TB', category: 'pricing', hidden: !pinningEnabled, - validation: pinningEnabled - ? { - required: 'required', - validate: { - currency: (value, values) => - !!values.pinnedCurrency || 'must select a pinned currency', - range: (value: BigNumber, values) => - !values.shouldPinEgressPrice || - value?.gt(0) || - 'egress price must be greater than 0', - }, - } - : {}, + validation: { + validate: { + required: requiredIfPinningEnabled(validationContext), + currency: requiredIfPinningEnabled( + validationContext, + (_, values) => + !!values.pinnedCurrency || 'must select a pinned currency' + ), + range: requiredIfPinningEnabled( + validationContext, + (value: BigNumber, values) => + !values.shouldPinEgressPrice || + value?.gt(0) || + 'egress price must be greater than 0' + ), + }, + }, }, shouldPinIngressPrice: { @@ -249,19 +271,23 @@ export function getFields({ units: '/TB', category: 'pricing', hidden: !pinningEnabled, - validation: pinningEnabled - ? { - required: 'required', - validate: { - currency: (value, values) => - !!values.pinnedCurrency || 'must select a pinned currency', - range: (value: BigNumber, values) => - !values.shouldPinIngressPrice || - value?.gt(0) || - 'ingress price must be greater than 0', - }, - } - : {}, + validation: { + validate: { + required: requiredIfPinningEnabled(validationContext), + currency: requiredIfPinningEnabled( + validationContext, + (_, values) => + !!values.pinnedCurrency || 'must select a pinned currency' + ), + range: requiredIfPinningEnabled( + validationContext, + (value: BigNumber, values) => + !values.shouldPinIngressPrice || + value?.gt(0) || + 'ingress price must be greater than 0' + ), + }, + }, }, collateralMultiplier: { @@ -286,7 +312,7 @@ export function getFields({ description: '', type: 'boolean', category: 'pricing', - hidden: !pinningEnabled || !showAdvanced, + hidden: !pinningEnabled || configViewMode === 'basic', validation: {}, }, maxCollateral: { @@ -306,7 +332,7 @@ export function getFields({ ? calculateMaxCollateral(storageTBMonth, collateralMultiplier) : undefined, suggestionTip: 'The suggested maximum collateral.', - hidden: !showAdvanced, + readOnly: autoMaxCollateral, validation: { required: 'required', }, @@ -316,20 +342,24 @@ export function getFields({ description: '', type: 'fiat', category: 'pricing', - hidden: !pinningEnabled || !showAdvanced, - validation: pinningEnabled - ? { - required: 'required', - validate: { - currency: (value, values) => - !!values.pinnedCurrency || 'must select a pinned currency', - range: (value: BigNumber, values) => - !values.shouldPinMaxCollateral || - value?.gt(0) || - 'max collateral must be greater than 0', - }, - } - : {}, + hidden: !pinningEnabled || configViewMode === 'basic', + validation: { + validate: { + required: requiredIfPinningEnabled(validationContext), + currency: requiredIfPinningEnabled( + validationContext, + (_, values) => + !!values.pinnedCurrency || 'must select a pinned currency' + ), + range: requiredIfPinningEnabled( + validationContext, + (value: BigNumber, values) => + !values.shouldPinMaxCollateral || + value?.gt(0) || + 'max collateral must be greater than 0' + ), + }, + }, }, contractPrice: { title: 'Contract price', @@ -340,7 +370,7 @@ export function getFields({ tipsDecimalsLimitSc: 1, suggestion: new BigNumber(0.2), description: <>{`The host's contract price in siacoins.`}, - hidden: !showAdvanced, + hidden: configViewMode === 'basic', validation: { required: 'required', }, @@ -357,7 +387,7 @@ export function getFields({ description: ( <>{`The host's base RPC price in siacoins per million calls.`} ), - hidden: !showAdvanced, + hidden: configViewMode === 'basic', validation: { required: 'required', }, @@ -374,7 +404,7 @@ export function getFields({ description: ( <>{`The host's sector access price in siacoins per million sectors.`} ), - hidden: !showAdvanced, + hidden: configViewMode === 'basic', validation: { required: 'required', }, @@ -389,7 +419,7 @@ export function getFields({ description: ( <>{`How long a renter's registered price table remains valid.`} ), - hidden: !showAdvanced, + hidden: configViewMode === 'basic', validation: { required: 'required', }, @@ -406,7 +436,7 @@ export function getFields({ description: ( <>{`How long a renter's ephemeral accounts are inactive before the host prunes them and recovers the remaining funds.`} ), - hidden: !showAdvanced, + hidden: configViewMode === 'basic', validation: { required: 'required', validate: { @@ -425,7 +455,7 @@ export function getFields({ description: ( <>{`Maximum balance a renter's ephemeral account can have. When the limit is reached, deposits are rejected until some of the funds have been spent.`} ), - hidden: !showAdvanced, + hidden: configViewMode === 'basic', validation: { required: 'required', validate: { @@ -612,3 +642,20 @@ function usdInScRoundedToNearestTen( ).times(10) : undefined } + +function requiredIfPinningEnabled( + context: { + pinningEnabled: boolean + }, + method?: (value: unknown, values: Values) => string | boolean +) { + return (value: unknown, values: Values) => { + if (context.pinningEnabled) { + if (method) { + return method(value, values) + } + return !!value || 'required' + } + return true + } +} diff --git a/apps/hostd/contexts/config/index.tsx b/apps/hostd/contexts/config/index.tsx index 7f84671dc..94324407f 100644 --- a/apps/hostd/contexts/config/index.tsx +++ b/apps/hostd/contexts/config/index.tsx @@ -22,7 +22,14 @@ import { useStateHost } from '@siafoundation/hostd-react' export function useConfigMain() { const { settings, settingsPinned, dynDNSCheck } = useResources() - const { form, fields, setShowAdvanced, showAdvanced } = useForm() + const { + form, + fields, + configViewMode, + setConfigViewMode, + autoMaxCollateral, + setAutoMaxCollateral, + } = useForm() // Resources required to intialize form. const resources: Resources = useMemo( @@ -85,7 +92,6 @@ export function useConfigMain() { const onValid = useOnValid({ resources, - showAdvanced, revalidateAndResetForm, }) @@ -117,12 +123,14 @@ export function useConfigMain() { revalidateAndResetForm, form, onSubmit, - showAdvanced, - setShowAdvanced, + setConfigViewMode, + configViewMode, remoteError, takeScreenshot, configRef, pinningEnabled, + autoMaxCollateral, + setAutoMaxCollateral, } } diff --git a/apps/hostd/contexts/config/transform.ts b/apps/hostd/contexts/config/transform.ts index 1eede04f6..fc8b8ea65 100644 --- a/apps/hostd/contexts/config/transform.ts +++ b/apps/hostd/contexts/config/transform.ts @@ -127,6 +127,25 @@ export function transformUpSettings( } } +export function getCalculatedValues({ + storagePrice, + collateralMultiplier, + autoMaxCollateral, +}: { + storagePrice: BigNumber + collateralMultiplier: BigNumber + autoMaxCollateral: boolean +}) { + const calculatedValues: Partial = {} + if (autoMaxCollateral && storagePrice && collateralMultiplier) { + calculatedValues.maxCollateral = calculateMaxCollateral( + storagePrice, + collateralMultiplier + ) + } + return calculatedValues +} + export function transformUpSettingsPinned( values: SettingsData, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/hostd/contexts/config/types.ts b/apps/hostd/contexts/config/types.ts index 7af2e9b71..cea56f7ee 100644 --- a/apps/hostd/contexts/config/types.ts +++ b/apps/hostd/contexts/config/types.ts @@ -2,6 +2,8 @@ import { DNSProvider } from '@siafoundation/hostd-types' import { SiaCentralCurrency } from '@siafoundation/sia-central-types' import BigNumber from 'bignumber.js' +export type ConfigViewMode = 'basic' | 'advanced' + export const scDecimalPlaces = 6 export const dnsProviderOptions: { value: DNSProvider; label: string }[] = [ { diff --git a/apps/hostd/contexts/config/useAutoCalculatedFields.tsx b/apps/hostd/contexts/config/useAutoCalculatedFields.tsx new file mode 100644 index 000000000..07d3a3b16 --- /dev/null +++ b/apps/hostd/contexts/config/useAutoCalculatedFields.tsx @@ -0,0 +1,47 @@ +import { useEffect, useMemo } from 'react' +import { SettingsData } from './types' +import { UseFormReturn } from 'react-hook-form' +import useLocalStorageState from 'use-local-storage-state' +import { getCalculatedValues } from './transform' +import BigNumber from 'bignumber.js' + +export function useAutoCalculatedFields({ + form, + storagePrice, + collateralMultiplier, +}: { + form: UseFormReturn + storagePrice: BigNumber + collateralMultiplier: BigNumber +}) { + const [autoMaxCollateral, setAutoMaxCollateral] = + useLocalStorageState('v0/config/auto/maxCollateral', { + defaultValue: true, + }) + + // Sync calculated values if applicable. + const calculatedValues = useMemo( + () => + getCalculatedValues({ + storagePrice, + collateralMultiplier, + autoMaxCollateral, + }), + [storagePrice, collateralMultiplier, autoMaxCollateral] + ) + + useEffect(() => { + for (const [key, value] of Object.entries(calculatedValues)) { + form.setValue(key as keyof SettingsData, value, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + } + }, [form, calculatedValues]) + + return { + autoMaxCollateral, + setAutoMaxCollateral, + } +} diff --git a/apps/hostd/contexts/config/useForm.tsx b/apps/hostd/contexts/config/useForm.tsx index 41a233b14..e6930e631 100644 --- a/apps/hostd/contexts/config/useForm.tsx +++ b/apps/hostd/contexts/config/useForm.tsx @@ -1,47 +1,71 @@ import { useForm as useHookForm } from 'react-hook-form' -import { defaultValues } from './types' -import { useMemo } from 'react' +import { ConfigViewMode, defaultValues } from './types' +import { useEffect, useMemo, useRef } from 'react' import { getFields } from './fields' import useLocalStorageState from 'use-local-storage-state' import { useSiaCentralExchangeRates } from '@siafoundation/sia-central-react' import { useStateHost } from '@siafoundation/hostd-react' +import { useAutoCalculatedFields } from './useAutoCalculatedFields' export function useForm() { const form = useHookForm({ mode: 'all', defaultValues, }) - const storageTBMonth = form.watch('storagePrice') + const storagePrice = form.watch('storagePrice') const collateralMultiplier = form.watch('collateralMultiplier') - const [showAdvanced, setShowAdvanced] = useLocalStorageState( - 'v0/config/showAdvanced', - { - defaultValue: false, - } - ) - // const mode: 'simple' | 'advanced' = showAdvanced ? 'advanced' : 'simple' + const [configViewMode, setConfigViewMode] = + useLocalStorageState('v0/config/mode', { + defaultValue: 'basic', + }) + + const { autoMaxCollateral, setAutoMaxCollateral } = useAutoCalculatedFields({ + form, + storagePrice, + collateralMultiplier, + }) const rates = useSiaCentralExchangeRates() const state = useStateHost() + const pinningEnabled = state.data?.explorer.enabled + // Field validation is only re-applied on re-mount, + // so we pass a ref with latest data that can be used interally. + const validationContext = useRef({ + pinningEnabled: pinningEnabled, + }) + useEffect(() => { + validationContext.current.pinningEnabled = pinningEnabled + }, [pinningEnabled]) const fields = useMemo( () => getFields({ pinningEnabled: state.data?.explorer.enabled, - showAdvanced, - storageTBMonth, + configViewMode, + storageTBMonth: storagePrice, collateralMultiplier, rates: rates.data?.rates, + autoMaxCollateral, + validationContext: validationContext.current, }), - [showAdvanced, storageTBMonth, collateralMultiplier, rates.data, state.data] + [ + configViewMode, + storagePrice, + collateralMultiplier, + rates.data, + state.data, + autoMaxCollateral, + ] ) return { form, fields, - storageTBMonth, + storageTBMonth: storagePrice, collateralMultiplier, - showAdvanced, - setShowAdvanced, + configViewMode, + setConfigViewMode, + autoMaxCollateral, + setAutoMaxCollateral, } } diff --git a/apps/hostd/contexts/config/useOnValid.tsx b/apps/hostd/contexts/config/useOnValid.tsx index 5e40056d5..6250f8e29 100644 --- a/apps/hostd/contexts/config/useOnValid.tsx +++ b/apps/hostd/contexts/config/useOnValid.tsx @@ -5,11 +5,7 @@ import { } from '@siafoundation/design-system' import { useCallback } from 'react' import { SettingsData } from './types' -import { - calculateMaxCollateral, - transformUpSettings, - transformUpSettingsPinned, -} from './transform' +import { transformUpSettings, transformUpSettingsPinned } from './transform' import { Resources } from './resources' import { useSettingsPinnedUpdate, @@ -19,11 +15,9 @@ import { export function useOnValid({ resources, - showAdvanced, revalidateAndResetForm, }: { resources: Resources - showAdvanced: boolean revalidateAndResetForm: () => Promise }) { const state = useStateHost() @@ -42,21 +36,10 @@ export function useOnValid({ return } try { - const calculatedValues: Partial = {} - if (!showAdvanced) { - calculatedValues.maxCollateral = calculateMaxCollateral( - values.storagePrice, - values.collateralMultiplier - ) - } - - const finalValues = { - ...values, - ...calculatedValues, - } + const payload = transformUpSettings(values, resources.settings.data) const settings = await settingsUpdate.patch({ - payload: transformUpSettings(finalValues, resources.settings.data), + payload, }) if (settings.error) { @@ -66,7 +49,7 @@ export function useOnValid({ if (state.data?.explorer.enabled) { const settingsPinned = await settingsPinnedUpdate.put({ payload: transformUpSettingsPinned( - finalValues, + values, resources.settingsPinned.data ), }) @@ -99,7 +82,6 @@ export function useOnValid({ } }, [ - showAdvanced, resources, settingsUpdate, settingsPinnedUpdate, diff --git a/apps/renterd/components/CmdRoot/ConfigCmdGroup.tsx b/apps/renterd/components/CmdRoot/ConfigCmdGroup.tsx index 37aaf79b6..09d8d2aad 100644 --- a/apps/renterd/components/CmdRoot/ConfigCmdGroup.tsx +++ b/apps/renterd/components/CmdRoot/ConfigCmdGroup.tsx @@ -19,7 +19,7 @@ type Props = { export function ConfigCmdGroup({ currentPage, parentPage, pushPage }: Props) { const router = useRouter() - const { showAdvanced } = useConfig() + const { configViewMode } = useConfig() const { closeDialog } = useDialog() const { autopilot } = useApp() return ( @@ -66,7 +66,7 @@ export function ConfigCmdGroup({ currentPage, parentPage, pushPage }: Props) { > Configure pricing
- {showAdvanced && ( + {configViewMode === 'advanced' && ( <> {autopilot.status === 'on' && ( <> diff --git a/apps/renterd/components/Config/ConfigActions.tsx b/apps/renterd/components/Config/ConfigActions.tsx index 43d3c66d6..61c9b7bc0 100644 --- a/apps/renterd/components/Config/ConfigActions.tsx +++ b/apps/renterd/components/Config/ConfigActions.tsx @@ -10,6 +10,7 @@ import { import { Reset16, Save16, Settings16 } from '@siafoundation/react-icons' import { useConfig } from '../../contexts/config' import { ConfigContextMenu } from './ConfigContextMenu' +import { ConfigViewDropdownMenu } from './ConfigViewDropdownMenu' export function ConfigActions() { const { @@ -74,6 +75,7 @@ export function ConfigActions() { + ) } diff --git a/apps/renterd/components/Config/ConfigNav.tsx b/apps/renterd/components/Config/ConfigNav.tsx index 4616b9889..338ffea94 100644 --- a/apps/renterd/components/Config/ConfigNav.tsx +++ b/apps/renterd/components/Config/ConfigNav.tsx @@ -1,22 +1,3 @@ -import { Text, Switch, Tooltip } from '@siafoundation/design-system' -import { useConfig } from '../../contexts/config' - export function ConfigNav() { - const { showAdvanced, setShowAdvanced } = useConfig() - - return ( -
- -
- setShowAdvanced(checked)} - /> - - Advanced - -
-
-
- ) + return
} diff --git a/apps/renterd/components/Config/ConfigStats.tsx b/apps/renterd/components/Config/ConfigStats.tsx index d90890837..104b336fb 100644 --- a/apps/renterd/components/Config/ConfigStats.tsx +++ b/apps/renterd/components/Config/ConfigStats.tsx @@ -11,7 +11,7 @@ import { useApp } from '../../contexts/app' export function ConfigStats() { const { autopilot } = useApp() - const { estimates, redundancyMultiplier, storageTB, showAdvanced } = + const { estimates, redundancyMultiplier, storageTB, configViewMode } = useConfig() const { canEstimate, estimatedSpendingPerMonth, estimatedSpendingPerTB } = @@ -25,7 +25,7 @@ export function ConfigStats() { return !canEstimate ? ( - {showAdvanced + {configViewMode === 'advanced' ? 'Enter expected storage, period, and allowance values to estimate monthly spending.' : 'Enter expected storage and max price to estimate monthly spending.'} diff --git a/apps/renterd/components/Config/ConfigViewDropdownMenu.tsx b/apps/renterd/components/Config/ConfigViewDropdownMenu.tsx new file mode 100644 index 000000000..67ab338d5 --- /dev/null +++ b/apps/renterd/components/Config/ConfigViewDropdownMenu.tsx @@ -0,0 +1,34 @@ +import { + Button, + Label, + Popover, + MenuItemRightSlot, + BaseMenuItem, +} from '@siafoundation/design-system' +import { CaretDown16, SettingsAdjust16 } from '@siafoundation/react-icons' +import { ViewModeToggle } from './ViewModeToggle' + +export function ConfigViewDropdownMenu() { + return ( + + + View + + + } + contentProps={{ + align: 'end', + className: 'max-w-[300px]', + }} + > + + + + + + + + ) +} diff --git a/apps/renterd/components/Config/Recommendations.tsx b/apps/renterd/components/Config/Recommendations.tsx index ed3822d81..9208eff3d 100644 --- a/apps/renterd/components/Config/Recommendations.tsx +++ b/apps/renterd/components/Config/Recommendations.tsx @@ -80,7 +80,7 @@ export function Recommendations() { {usableHostsCurrent} hosts - {needsRecommendations && ( + {needsRecommendations && foundRecommendation ? ( <>
@@ -91,26 +91,27 @@ export function Recommendations() {
- {foundRecommendation ? ( - usableHostsAfterRecommendation < hostTarget50 ? ( - - The system found recommendations that would increase the number - of usable hosts from {usableHostsCurrent} to{' '} - {usableHostsAfterRecommendation} of the ideal {hostTarget50}. - - ) : ( - - Follow these recommendations to match with{' '} - {usableHostsAfterRecommendation} hosts. - - ) + {usableHostsAfterRecommendation < hostTarget50 ? ( + + The system found recommendations that would increase the number of + usable hosts from {usableHostsCurrent} to{' '} + {usableHostsAfterRecommendation} of the ideal {hostTarget50}. + ) : ( - The system could not find recommendations that would increase the - usable host count. + Follow these recommendations to match with{' '} + {usableHostsAfterRecommendation} hosts. )} + ) : ( + <> + + + The system could not find recommendations that would increase the + usable host count. + + )} ) diff --git a/apps/renterd/components/Config/ViewModeToggle.tsx b/apps/renterd/components/Config/ViewModeToggle.tsx new file mode 100644 index 000000000..3644f101b --- /dev/null +++ b/apps/renterd/components/Config/ViewModeToggle.tsx @@ -0,0 +1,28 @@ +import { Switch, Tooltip } from '@siafoundation/design-system' +import { useConfig } from '../../contexts/config' + +export function ViewModeToggle() { + const { configViewMode, setConfigViewMode } = useConfig() + + return ( +
+ +
+ + setConfigViewMode(checked ? 'advanced' : 'basic') + } + /> +
+
+
+ ) +} diff --git a/apps/renterd/contexts/alerts/SetChange.tsx b/apps/renterd/contexts/alerts/SetChange.tsx index 5ff90ca58..23c5cdb93 100644 --- a/apps/renterd/contexts/alerts/SetChange.tsx +++ b/apps/renterd/contexts/alerts/SetChange.tsx @@ -102,7 +102,7 @@ export function SetChangesField({ [removals] ) const churn = useMemo( - () => (removedSize / totalSize) * 100, + () => (totalSize > 0 ? (removedSize / totalSize) * 100 : 0), [removedSize, totalSize] ) diff --git a/apps/renterd/contexts/config/fields.tsx b/apps/renterd/contexts/config/fields.tsx index b2353267b..e61c7edee 100644 --- a/apps/renterd/contexts/config/fields.tsx +++ b/apps/renterd/contexts/config/fields.tsx @@ -3,6 +3,7 @@ import { Code, ConfigFields, Separator, + Switch, Text, Tooltip, hoursInDays, @@ -11,7 +12,7 @@ import { } from '@siafoundation/design-system' import BigNumber from 'bignumber.js' import React from 'react' -import { RecommendationItem, SettingsData } from './types' +import { ConfigViewMode, RecommendationItem, SettingsData } from './types' import { humanSiacoin, toHastings } from '@siafoundation/units' import { Information16 } from '@siafoundation/react-icons' @@ -27,9 +28,7 @@ type Categories = | 'redundancy' type GetFields = { - isAutopilotEnabled: boolean advancedDefaults?: SettingsData - showAdvanced: boolean maxStoragePriceTBMonth: BigNumber maxUploadPriceTB: BigNumber minShards: BigNumber @@ -39,15 +38,21 @@ type GetFields = { uploadAverage?: BigNumber downloadAverage?: BigNumber contractAverage?: BigNumber + isAutopilotEnabled: boolean + configViewMode: ConfigViewMode recommendations: Partial> + autoAllowance: boolean + setAutoAllowance: (value: boolean) => void + validationContext: { + isAutopilotEnabled: boolean + configViewMode: ConfigViewMode + } } export type Fields = ReturnType export function getFields({ - isAutopilotEnabled, advancedDefaults, - showAdvanced, maxStoragePriceTBMonth, maxUploadPriceTB, minShards, @@ -58,6 +63,11 @@ export function getFields({ downloadAverage, contractAverage, recommendations, + isAutopilotEnabled, + configViewMode, + validationContext, + autoAllowance, + setAutoAllowance, }: GetFields): ConfigFields { return { // storage @@ -68,11 +78,11 @@ export function getFields({ description: <>The amount of storage you would like to rent in TB., units: 'TB', hidden: !isAutopilotEnabled, - validation: isAutopilotEnabled - ? { - required: 'required', - } - : {}, + validation: { + validate: { + required: requiredIfAutopilot(validationContext), + }, + }, }, uploadTBMonth: { type: 'number', @@ -83,11 +93,11 @@ export function getFields({ ), units: 'TB/month', hidden: !isAutopilotEnabled, - validation: isAutopilotEnabled - ? { - required: 'required', - } - : {}, + validation: { + validate: { + required: requiredIfAutopilot(validationContext), + }, + }, }, downloadTBMonth: { type: 'number', @@ -98,11 +108,11 @@ export function getFields({ ), units: 'TB/month', hidden: !isAutopilotEnabled, - validation: isAutopilotEnabled - ? { - required: 'required', - } - : {}, + validation: { + validate: { + required: requiredIfAutopilot(validationContext), + }, + }, }, allowanceMonth: { type: 'siacoin', @@ -111,15 +121,35 @@ export function getFields({ description: ( <>The amount of Siacoin you would like to spend per month. ), + before: () => ( +
+ +
+ + Calculate + + +
+
+
+ ), + readOnly: autoAllowance, units: 'SC/month', decimalsLimitSc: scDecimalPlaces, - hidden: !isAutopilotEnabled || !showAdvanced, - validation: - isAutopilotEnabled && showAdvanced - ? { - required: 'required', - } - : {}, + hidden: !isAutopilotEnabled, + validation: { + validate: { + required: requiredIfAutopilotAndAdvanced(validationContext), + }, + }, }, periodWeeks: { type: 'number', @@ -129,13 +159,12 @@ export function getFields({ units: 'weeks', suggestion: advancedDefaults?.periodWeeks, suggestionTip: `Typically ${advancedDefaults?.periodWeeks} weeks.`, - hidden: !isAutopilotEnabled || !showAdvanced, - validation: - isAutopilotEnabled && showAdvanced - ? { - required: 'required', - } - : {}, + hidden: !isAutopilotEnabled || configViewMode === 'basic', + validation: { + validate: { + required: requiredIfAutopilotAndAdvanced(validationContext), + }, + }, }, renewWindowWeeks: { type: 'number', @@ -151,13 +180,12 @@ export function getFields({ decimalsLimit: 6, suggestion: advancedDefaults?.renewWindowWeeks, suggestionTip: `Typically ${advancedDefaults?.renewWindowWeeks} weeks.`, - hidden: !isAutopilotEnabled || !showAdvanced, - validation: - isAutopilotEnabled && showAdvanced - ? { - required: 'required', - } - : {}, + hidden: !isAutopilotEnabled || configViewMode === 'basic', + validation: { + validate: { + required: requiredIfAutopilotAndAdvanced(validationContext), + }, + }, }, amountHosts: { type: 'number', @@ -168,13 +196,12 @@ export function getFields({ decimalsLimit: 0, suggestion: advancedDefaults?.amountHosts, suggestionTip: `Typically ${advancedDefaults?.amountHosts} hosts.`, - hidden: !isAutopilotEnabled || !showAdvanced, - validation: - isAutopilotEnabled && showAdvanced - ? { - required: 'required', - } - : {}, + hidden: !isAutopilotEnabled || configViewMode === 'basic', + validation: { + validate: { + required: requiredIfAutopilotAndAdvanced(validationContext), + }, + }, }, autopilotContractSet: { type: 'text', @@ -194,13 +221,12 @@ export function getFields({ {advancedDefaults?.autopilotContractSet}. ), - hidden: !isAutopilotEnabled || !showAdvanced, - validation: - isAutopilotEnabled && showAdvanced - ? { - required: 'required', - } - : {}, + hidden: !isAutopilotEnabled || configViewMode === 'basic', + validation: { + validate: { + required: requiredIfAutopilotAndAdvanced(validationContext), + }, + }, }, prune: { type: 'boolean', @@ -222,7 +248,7 @@ export function getFields({ The default value is {advancedDefaults?.prune}. ), - hidden: !isAutopilotEnabled || !showAdvanced, + hidden: !isAutopilotEnabled || configViewMode === 'basic', validation: {}, }, @@ -241,7 +267,7 @@ export function getFields({ suggestionTip: `Defaults to ${ advancedDefaults?.allowRedundantIPs ? 'on' : 'off' }.`, - hidden: !isAutopilotEnabled || !showAdvanced, + hidden: !isAutopilotEnabled || configViewMode === 'basic', validation: {}, }, maxDowntimeHours: { @@ -264,13 +290,12 @@ export function getFields({ ), 1 )} days.`, - hidden: !isAutopilotEnabled || !showAdvanced, - validation: - isAutopilotEnabled && showAdvanced - ? { - required: 'required', - } - : {}, + hidden: !isAutopilotEnabled || configViewMode === 'basic', + validation: { + validate: { + required: requiredIfAutopilotAndAdvanced(validationContext), + }, + }, }, minRecentScanFailures: { type: 'number', @@ -286,13 +311,12 @@ export function getFields({ decimalsLimit: 0, suggestion: advancedDefaults?.minRecentScanFailures, suggestionTip: `Defaults to ${advancedDefaults?.minRecentScanFailures.toNumber()}.`, - hidden: !isAutopilotEnabled || !showAdvanced, - validation: - isAutopilotEnabled && showAdvanced - ? { - required: 'required', - } - : {}, + hidden: !isAutopilotEnabled || configViewMode === 'basic', + validation: { + validate: { + required: requiredIfAutopilotAndAdvanced(validationContext), + }, + }, }, minProtocolVersion: { type: 'text', @@ -306,19 +330,19 @@ export function getFields({ ), suggestion: advancedDefaults?.minProtocolVersion, suggestionTip: `Defaults to ${advancedDefaults?.minProtocolVersion}.`, - hidden: !isAutopilotEnabled || !showAdvanced, - validation: - isAutopilotEnabled && showAdvanced - ? { - required: 'required', - validate: { - version: (value: string) => { - const regex = /^\d+\.\d+\.\d+$/ - return regex.test(value) || 'must be a valid version number' - }, - }, + hidden: !isAutopilotEnabled || configViewMode === 'basic', + validation: { + validate: { + required: requiredIfAutopilotAndAdvanced(validationContext), + version: requiredIfAutopilotAndAdvanced( + validationContext, + (value: string) => { + const regex = /^\d+\.\d+\.\d+$/ + return regex.test(value) || 'must be a valid version number' } - : {}, + ), + }, + }, }, // contract @@ -337,12 +361,12 @@ export function getFields({ description: ( <>The default contract set is where data is uploaded to by default. ), - hidden: !showAdvanced, - validation: showAdvanced - ? { - required: 'required', - } - : {}, + hidden: configViewMode === 'basic', + validation: { + validate: { + required: requiredIfAdvanced(validationContext), + }, + }, }, uploadPackingEnabled: { category: 'uploadpacking', @@ -363,7 +387,7 @@ export function getFields({ they must be considered when backing up your renterd data. ), - hidden: !showAdvanced, + hidden: configViewMode === 'basic', validation: {}, }, @@ -505,14 +529,14 @@ export function getFields({ average: contractAverage, decimalsLimitSc: scDecimalPlaces, tipsDecimalsLimitSc: 3, - hidden: !showAdvanced, + hidden: configViewMode === 'basic', suggestion: recommendations.maxContractPrice?.targetValue, suggestionTip: 'This value will help you match with more hosts.', - validation: showAdvanced - ? { - required: 'required', - } - : {}, + validation: { + validate: { + required: requiredIfAdvanced(validationContext), + }, + }, }, maxRpcPriceMillion: { category: 'gouging', @@ -523,14 +547,14 @@ export function getFields({ ), units: 'SC/million', decimalsLimitSc: scDecimalPlaces, - hidden: !showAdvanced, + hidden: configViewMode === 'basic', suggestion: recommendations.maxRpcPriceMillion?.targetValue, suggestionTip: 'This value will help you match with more hosts.', - validation: showAdvanced - ? { - required: 'required', - } - : {}, + validation: { + validate: { + required: requiredIfAdvanced(validationContext), + }, + }, }, hostBlockHeightLeeway: { category: 'gouging', @@ -553,17 +577,18 @@ export function getFields({ suggestion: advancedDefaults?.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', - }, - } - : {}, + hidden: configViewMode === 'basic', + validation: { + validate: { + required: requiredIfAdvanced(validationContext), + min: requiredIfAdvanced(validationContext, (value) => { + return ( + new BigNumber(value as BigNumber).gte(3) || + 'must be at least 3 blocks' + ) + }), + }, + }, }, minPriceTableValidityMinutes: { category: 'gouging', @@ -573,19 +598,20 @@ export function getFields({ description: ( <>The min accepted value for `Validity` in the host's price settings. ), - hidden: !showAdvanced, + hidden: configViewMode === 'basic', suggestion: recommendations.minPriceTableValidityMinutes?.targetValue, suggestionTip: 'This value will help you match with more hosts.', - validation: showAdvanced - ? { - required: 'required', - validate: { - min: (value) => - new BigNumber(value as BigNumber).gte(secondsInMinutes(10)) || - 'must be at least 10 seconds', - }, - } - : {}, + validation: { + validate: { + required: requiredIfAdvanced(validationContext), + min: requiredIfAdvanced(validationContext, (value) => { + return ( + new BigNumber(value as BigNumber).gte(secondsInMinutes(10)) || + 'must be at least 10 seconds' + ) + }), + }, + }, }, minAccountExpiryDays: { category: 'gouging', @@ -598,19 +624,20 @@ export function getFields({ settings. ), - hidden: !showAdvanced, + hidden: configViewMode === 'basic', suggestion: recommendations.minAccountExpiryDays?.targetValue, suggestionTip: 'This value will help you match with more hosts.', - validation: showAdvanced - ? { - required: 'required', - validate: { - min: (value) => - new BigNumber(value as BigNumber).gte(hoursInDays(1)) || - 'must be at least 1 hour', - }, - } - : {}, + validation: { + validate: { + required: requiredIfAdvanced(validationContext), + min: requiredIfAdvanced(validationContext, (value) => { + return ( + new BigNumber(value as BigNumber).gte(hoursInDays(1)) || + 'must be at least 1 hour' + ) + }), + }, + }, }, minMaxEphemeralAccountBalance: { category: 'gouging', @@ -623,19 +650,20 @@ export function getFields({ ), decimalsLimitSc: scDecimalPlaces, - hidden: !showAdvanced, + hidden: configViewMode === 'basic', suggestion: recommendations.minMaxEphemeralAccountBalance?.targetValue, suggestionTip: 'This value will help you match with more hosts.', - validation: showAdvanced - ? { - required: 'required', - validate: { - min: (value) => - new BigNumber(value as BigNumber).gte(1) || - 'must be at least 1 SC', - }, - } - : {}, + validation: { + validate: { + required: requiredIfAdvanced(validationContext), + min: requiredIfAdvanced(validationContext, (value) => { + return ( + new BigNumber(value as BigNumber).gte(1) || + 'must be at least 1 SC' + ) + }), + }, + }, }, migrationSurchargeMultiplier: { category: 'gouging', @@ -664,12 +692,12 @@ export function getFields({ suggestion: new BigNumber(10), suggestionTip: 'The default multiplier is 10x the download price.', }), - hidden: !showAdvanced, - validation: showAdvanced - ? { - required: 'required', - } - : {}, + hidden: configViewMode === 'basic', + validation: { + validate: { + required: requiredIfAdvanced(validationContext), + }, + }, }, // Redundancy @@ -681,17 +709,18 @@ export function getFields({ suggestion: advancedDefaults?.minShards, suggestionTip: `Typically ${advancedDefaults?.minShards} shards.`, units: 'shards', - hidden: !showAdvanced, - validation: showAdvanced - ? { - required: 'required', - validate: { - min: (value) => - new BigNumber(value as BigNumber).gt(0) || - 'must be greater than 0', - }, - } - : {}, + hidden: configViewMode === 'basic', + validation: { + validate: { + required: requiredIfAdvanced(validationContext), + min: requiredIfAdvanced(validationContext, (value) => { + return ( + new BigNumber(value as BigNumber).gt(0) || + 'must be greater than 0' + ) + }), + }, + }, trigger: ['totalShards'], }, totalShards: { @@ -702,20 +731,72 @@ export function getFields({ suggestion: advancedDefaults?.totalShards, suggestionTip: `Typically ${advancedDefaults?.totalShards} shards.`, 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: configViewMode === 'basic', + validation: { + validate: { + required: requiredIfAdvanced(validationContext), + gteMinShards: requiredIfAdvanced( + validationContext, + (value, values) => + new BigNumber(value as BigNumber).gte(values.minShards) || + 'must be at least equal to min shards' + ), + max: requiredIfAdvanced( + validationContext, + (value) => + new BigNumber(value as BigNumber).lt(256) || + 'must be less than 256' + ), + }, + }, }, } } + +function requiredIfAdvanced( + context: { configViewMode: ConfigViewMode }, + method?: (value: unknown, values: Values) => string | boolean +) { + return (value: unknown, values: Values) => { + if (context.configViewMode === 'advanced') { + if (method) { + return method(value, values) + } + return !!value || 'required' + } + return true + } +} + +function requiredIfAutopilot( + context: { isAutopilotEnabled: boolean }, + method?: (value: unknown, values: Values) => string | boolean +) { + return (value: unknown, values: Values) => { + if (context.isAutopilotEnabled) { + if (method) { + return method(value, values) + } + return !!value || 'required' + } + return true + } +} + +function requiredIfAutopilotAndAdvanced( + context: { + isAutopilotEnabled: boolean + configViewMode: ConfigViewMode + }, + method?: (value: unknown, values: Values) => string | boolean +) { + return (value: unknown, values: Values) => { + if (context.isAutopilotEnabled && context.configViewMode === 'advanced') { + if (method) { + return method(value, values) + } + return !!value || 'required' + } + return true + } +} diff --git a/apps/renterd/contexts/config/index.tsx b/apps/renterd/contexts/config/index.tsx index 2edb2780d..abd32ad93 100644 --- a/apps/renterd/contexts/config/index.tsx +++ b/apps/renterd/contexts/config/index.tsx @@ -96,8 +96,10 @@ export function useConfigMain() { evaluation, redundancyMultiplier, fields, - showAdvanced, - setShowAdvanced, + configViewMode, + setConfigViewMode, + autoAllowance, + setAutoAllowance, } = useForm({ resources }) const remoteValues: SettingsData = useMemo(() => { @@ -168,7 +170,6 @@ export function useConfigMain() { const onValid = useOnValid({ resources, estimatedSpendingPerMonth: estimates.estimatedSpendingPerMonth, - showAdvanced, isAutopilotEnabled, revalidateAndResetForm, }) @@ -204,12 +205,14 @@ export function useConfigMain() { storageTB, shouldSyncDefaultContractSet, setShouldSyncDefaultContractSet, - showAdvanced, - setShowAdvanced, + configViewMode, + setConfigViewMode, remoteError, configRef, takeScreenshot, evaluation, + autoAllowance, + setAutoAllowance, } } diff --git a/apps/renterd/contexts/config/transformUp.ts b/apps/renterd/contexts/config/transformUp.ts index d1bc95060..fe5b34c85 100644 --- a/apps/renterd/contexts/config/transformUp.ts +++ b/apps/renterd/contexts/config/transformUp.ts @@ -153,51 +153,31 @@ export function transformUp({ resources, renterdState, isAutopilotEnabled, - showAdvanced, - estimatedSpendingPerMonth, values, }: { resources: Resources renterdState: BusStateResponse isAutopilotEnabled: boolean - showAdvanced: boolean estimatedSpendingPerMonth: BigNumber values: SettingsData }) { - const calculatedValues: Partial = {} - if (isAutopilotEnabled && !showAdvanced) { - calculatedValues.allowanceMonth = estimatedSpendingPerMonth - } - - const finalValues = { - ...values, - ...calculatedValues, - } - const autopilot = isAutopilotEnabled ? transformUpAutopilot( renterdState.network, - finalValues, + values, resources.autopilot.data ) : undefined - const contractSet = transformUpContractSet( - finalValues, - resources.contractSet.data - ) + const contractSet = transformUpContractSet(values, resources.contractSet.data) const uploadPacking = transformUpUploadPacking( - finalValues, + values, resources.uploadPacking.data ) - const gouging = transformUpGouging(finalValues, resources.gouging.data) - const redundancy = transformUpRedundancy( - finalValues, - resources.redundancy.data - ) + const gouging = transformUpGouging(values, resources.gouging.data) + const redundancy = transformUpRedundancy(values, resources.redundancy.data) return { - finalValues, payloads: { autopilot, contractSet, @@ -215,3 +195,19 @@ function filterUndefinedKeys(obj: Record) { ) ) } + +export function getCalculatedValues({ + estimatedSpendingPerMonth, + isAutopilotEnabled, + autoAllowance, +}: { + estimatedSpendingPerMonth: BigNumber + isAutopilotEnabled: boolean + autoAllowance: boolean +}) { + const calculatedValues: Partial = {} + if (isAutopilotEnabled && autoAllowance && estimatedSpendingPerMonth?.gt(0)) { + calculatedValues.allowanceMonth = estimatedSpendingPerMonth + } + return calculatedValues +} diff --git a/apps/renterd/contexts/config/types.ts b/apps/renterd/contexts/config/types.ts index 0578626d4..99886f6c0 100644 --- a/apps/renterd/contexts/config/types.ts +++ b/apps/renterd/contexts/config/types.ts @@ -1,5 +1,7 @@ import BigNumber from 'bignumber.js' +export type ConfigViewMode = 'basic' | 'advanced' + export const scDecimalPlaces = 6 // form defaults diff --git a/apps/renterd/contexts/config/useAutoCalculatedFields.tsx b/apps/renterd/contexts/config/useAutoCalculatedFields.tsx new file mode 100644 index 000000000..a03804546 --- /dev/null +++ b/apps/renterd/contexts/config/useAutoCalculatedFields.tsx @@ -0,0 +1,49 @@ +import { useEffect, useMemo } from 'react' +import { SettingsData } from './types' +import { UseFormReturn } from 'react-hook-form' +import useLocalStorageState from 'use-local-storage-state' +import { getCalculatedValues } from './transformUp' +import BigNumber from 'bignumber.js' + +export function useAutoCalculatedFields({ + form, + estimatedSpendingPerMonth, + isAutopilotEnabled, +}: { + form: UseFormReturn + estimatedSpendingPerMonth: BigNumber + isAutopilotEnabled: boolean +}) { + const [autoAllowance, setAutoAllowance] = useLocalStorageState( + 'v0/config/auto/allowance', + { + defaultValue: true, + } + ) + + // Sync calculated values if applicable. + const calculatedValues = useMemo( + () => + getCalculatedValues({ + estimatedSpendingPerMonth, + isAutopilotEnabled, + autoAllowance, + }), + [estimatedSpendingPerMonth, isAutopilotEnabled, autoAllowance] + ) + + useEffect(() => { + for (const [key, value] of Object.entries(calculatedValues)) { + form.setValue(key as keyof SettingsData, value, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + } + }, [form, calculatedValues]) + + return { + autoAllowance, + setAutoAllowance, + } +} diff --git a/apps/renterd/contexts/config/useAutopilotEvaluations.tsx b/apps/renterd/contexts/config/useAutopilotEvaluations.tsx index 725086e0a..c71ed376b 100644 --- a/apps/renterd/contexts/config/useAutopilotEvaluations.tsx +++ b/apps/renterd/contexts/config/useAutopilotEvaluations.tsx @@ -5,7 +5,12 @@ import { import { transformUp } from './transformUp' import { Resources, checkIfAllResourcesLoaded } from './resources' import BigNumber from 'bignumber.js' -import { RecommendationItem, SettingsData } from './types' +import { + ConfigViewMode, + RecommendationItem, + SettingsData, + getAdvancedDefaults, +} from './types' import { UseFormReturn } from 'react-hook-form' import { useMemo } from 'react' import { transformDownGouging } from './transformDown' @@ -18,13 +23,13 @@ export function useAutopilotEvaluations({ form, resources, isAutopilotEnabled, - showAdvanced, + configViewMode, estimatedSpendingPerMonth, }: { form: UseFormReturn resources: Resources isAutopilotEnabled: boolean - showAdvanced: boolean + configViewMode: ConfigViewMode estimatedSpendingPerMonth: BigNumber }) { const values = form.watch() @@ -41,15 +46,19 @@ export function useAutopilotEvaluations({ return false } return true - }, [form, resources, renterdState]) + }, [form.formState.isValid, resources, renterdState.data]) // We need to pass valid settings data into transformUp to get the payloads. - // The form can be invalid so we need to merge in valid data to make sure - // numbers are not undefined in the transformUp calculations that assume - // all data is valid and present. - const currentValuesWithZeroDefaults = useMemo( - () => mergeIfDefined(valuesZeroDefaults, values), - [values] + // The form can be invalid or have empty fields depending on the mode, so we + // need to merge in default data to make sure numbers are not undefined in + // the transformUp calculations that assume all data is valid and present. + const currentValuesWithDefaults = useMemo( + () => + mergeValuesWithDefaultsOrZeroValues( + values, + resources.autopilotState.data?.network + ), + [values, resources.autopilotState.data?.network] ) const payloads = useMemo(() => { @@ -61,17 +70,15 @@ export function useAutopilotEvaluations({ resources, renterdState: renterdState.data, isAutopilotEnabled, - showAdvanced, estimatedSpendingPerMonth, - values: currentValuesWithZeroDefaults, + values: currentValuesWithDefaults, }) return payloads }, [ - currentValuesWithZeroDefaults, + currentValuesWithDefaults, resources, renterdState, isAutopilotEnabled, - showAdvanced, estimatedSpendingPerMonth, hasDataToEvaluate, ]) @@ -204,7 +211,7 @@ export function useAutopilotEvaluations({ const rec = getRecommendationItem({ key: remoteToLocalFields[key], recommendationDown, - values: currentValuesWithZeroDefaults, + values: currentValuesWithDefaults, }) if (rec) { recs.push(rec) @@ -216,7 +223,7 @@ export function useAutopilotEvaluations({ recommendedGougingSettings, resources, payloads, - currentValuesWithZeroDefaults, + currentValuesWithDefaults, ]) const foundRecommendation = !!recommendations.length @@ -284,13 +291,19 @@ function getRecommendationItem({ // We just need some of the static metadata so pass in dummy values. const fields = getFields({ + validationContext: { + isAutopilotEnabled: true, + configViewMode: 'basic', + }, isAutopilotEnabled: true, - showAdvanced: true, + configViewMode: 'basic', maxStoragePriceTBMonth: new BigNumber(0), maxUploadPriceTB: new BigNumber(0), minShards: new BigNumber(0), totalShards: new BigNumber(0), redundancyMultiplier: new BigNumber(0), + autoAllowance: false, + setAutoAllowance: () => null, recommendations: {}, }) @@ -337,11 +350,23 @@ export const valuesZeroDefaults: SettingsData = { totalShards: new BigNumber(0), } -export function mergeIfDefined(defaults: SettingsData, values: SettingsData) { - const merged: SettingsData = { ...defaults } - Object.keys(values).forEach((key) => { - if (values[key] !== undefined) { - merged[key] = values[key] +// current value, otherwise advanced default if there is one, otherwise zero value. +export function mergeValuesWithDefaultsOrZeroValues( + values: SettingsData, + network: 'Mainnet' | 'Zen Testnet' +) { + const merged: SettingsData = getAdvancedDefaults(network) + // advanced defaults include undefined values, for keys without defaults. + // Set these to zero values. + Object.entries(valuesZeroDefaults).forEach(([key, value]) => { + if (merged[key] === undefined) { + merged[key] = value + } + }) + // Apply any non-zero user values. + Object.entries(values).forEach(([key, value]) => { + if (value !== undefined) { + merged[key] = value } }) return merged diff --git a/apps/renterd/contexts/config/useForm.tsx b/apps/renterd/contexts/config/useForm.tsx index 107aa4e05..6a19a65a9 100644 --- a/apps/renterd/contexts/config/useForm.tsx +++ b/apps/renterd/contexts/config/useForm.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo } from 'react' -import { defaultValues, getAdvancedDefaults } from './types' +import { useEffect, useMemo, useRef } from 'react' +import { ConfigViewMode, defaultValues, getAdvancedDefaults } from './types' import { getRedundancyMultiplier } from './utils' import { useForm as useHookForm } from 'react-hook-form' import { useAverages } from './useAverages' @@ -10,6 +10,7 @@ import useLocalStorageState from 'use-local-storage-state' import { useAutopilotEvaluations } from './useAutopilotEvaluations' import { useEstimates } from './useEstimates' import { Resources } from './resources' +import { useAutoCalculatedFields } from './useAutoCalculatedFields' export function useForm({ resources }: { resources: Resources }) { const form = useHookForm({ @@ -39,19 +40,17 @@ export function useForm({ resources }: { resources: Resources }) { const app = useApp() const isAutopilotEnabled = app.autopilot.status === 'on' - const [showAdvanced, setShowAdvanced] = useLocalStorageState( - 'v0/config/showAdvanced', - { - defaultValue: false, - } - ) + const [configViewMode, setConfigViewMode] = + useLocalStorageState('v0/config/mode', { + defaultValue: 'basic', + }) - // Trigger input validation on showAdvanced change because many field validation + // Trigger input validation on configViewMode change because many field validation // objects switch from required to not required. useEffect(() => { form.trigger() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [showAdvanced]) + }, [configViewMode]) const estimates = useEstimates({ isAutopilotEnabled, @@ -69,11 +68,29 @@ export function useForm({ resources }: { resources: Resources }) { form, resources, isAutopilotEnabled, - showAdvanced, + configViewMode, + estimatedSpendingPerMonth, + }) + + const { autoAllowance, setAutoAllowance } = useAutoCalculatedFields({ + form, estimatedSpendingPerMonth, + isAutopilotEnabled, }) const renterdState = useBusState() + + // Field validation is only re-applied on re-mount, + // so we pass a ref with latest data that can be used interally. + const validationContext = useRef({ + isAutopilotEnabled, + configViewMode, + }) + useEffect(() => { + validationContext.current.isAutopilotEnabled = isAutopilotEnabled + validationContext.current.configViewMode = configViewMode + }, [isAutopilotEnabled, configViewMode]) + const fields = useMemo(() => { const advancedDefaults = renterdState.data ? getAdvancedDefaults(renterdState.data.network) @@ -86,9 +103,10 @@ export function useForm({ resources }: { resources: Resources }) { }, {}) if (averages.data) { return getFields({ - advancedDefaults, + validationContext: validationContext.current, isAutopilotEnabled, - showAdvanced, + configViewMode, + advancedDefaults, maxStoragePriceTBMonth, maxUploadPriceTB, redundancyMultiplier, @@ -99,23 +117,28 @@ export function useForm({ resources }: { resources: Resources }) { minShards, totalShards, recommendations, + autoAllowance, + setAutoAllowance, }) } return getFields({ - advancedDefaults, + validationContext: validationContext.current, isAutopilotEnabled, - showAdvanced, + configViewMode, + advancedDefaults, maxStoragePriceTBMonth, maxUploadPriceTB, redundancyMultiplier, minShards, totalShards, recommendations, + autoAllowance, + setAutoAllowance, }) }, [ - renterdState.data, isAutopilotEnabled, - showAdvanced, + configViewMode, + renterdState.data, averages.data, storageAverage, uploadAverage, @@ -127,6 +150,8 @@ export function useForm({ resources }: { resources: Resources }) { minShards, totalShards, evaluation, + autoAllowance, + setAutoAllowance, ]) return { @@ -143,7 +168,9 @@ export function useForm({ resources }: { resources: Resources }) { minShards, totalShards, redundancyMultiplier, - showAdvanced, - setShowAdvanced, + configViewMode, + setConfigViewMode, + autoAllowance, + setAutoAllowance, } } diff --git a/apps/renterd/contexts/config/useOnValid.tsx b/apps/renterd/contexts/config/useOnValid.tsx index b60eda926..bfb4f1977 100644 --- a/apps/renterd/contexts/config/useOnValid.tsx +++ b/apps/renterd/contexts/config/useOnValid.tsx @@ -21,20 +21,18 @@ export function useOnValid({ resources, estimatedSpendingPerMonth, isAutopilotEnabled, - showAdvanced, revalidateAndResetForm, }: { resources: Resources estimatedSpendingPerMonth: BigNumber isAutopilotEnabled: boolean - showAdvanced: boolean revalidateAndResetForm: () => Promise }) { const autopilotTrigger = useAutopilotTrigger() const autopilotUpdate = useAutopilotConfigUpdate() const settingUpdate = useSettingUpdate() const renterdState = useBusState() - const { syncDefaultContractSet } = useSyncContractSet() + const { maybeSyncDefaultContractSet } = useSyncContractSet() const mutate = useMutate() const onValid = useCallback( async (values: typeof defaultValues) => { @@ -48,11 +46,10 @@ export function useOnValid({ const firstTimeSettingConfig = isAutopilotEnabled && !resources.autopilot.data try { - const { finalValues, payloads } = transformUp({ + const { payloads } = transformUp({ resources, renterdState: renterdState.data, isAutopilotEnabled, - showAdvanced, estimatedSpendingPerMonth, values, }) @@ -112,13 +109,8 @@ export function useOnValid({ } if (isAutopilotEnabled) { - // Sync default contract set if necessary. Only syncs if the setting - // is enabled in case the user changes in advanced mode and then - // goes back to simple mode. - // Might be simpler nice to just override in simple mode without a - // special setting since this is how other settings like allowance - // behave - but leaving for now. - syncDefaultContractSet(finalValues.autopilotContractSet) + // Sync default contract set if the setting is enabled. + maybeSyncDefaultContractSet(values.autopilotContractSet) // Trigger the autopilot loop with new settings applied. autopilotTrigger.post({ @@ -154,11 +146,10 @@ export function useOnValid({ [ renterdState.data, estimatedSpendingPerMonth, - showAdvanced, isAutopilotEnabled, autopilotUpdate, revalidateAndResetForm, - syncDefaultContractSet, + maybeSyncDefaultContractSet, mutate, settingUpdate, resources, diff --git a/apps/renterd/contexts/config/useResources.tsx b/apps/renterd/contexts/config/useResources.tsx index f9e3da7bc..5b0f60d8b 100644 --- a/apps/renterd/contexts/config/useResources.tsx +++ b/apps/renterd/contexts/config/useResources.tsx @@ -63,7 +63,7 @@ export function useResources() { const { shouldSyncDefaultContractSet, setShouldSyncDefaultContractSet, - syncDefaultContractSet, + maybeSyncDefaultContractSet, } = useSyncContractSet() const appSettings = useAppSettings() @@ -78,7 +78,7 @@ export function useResources() { averages, shouldSyncDefaultContractSet, setShouldSyncDefaultContractSet, - syncDefaultContractSet, + maybeSyncDefaultContractSet, appSettings, isAutopilotEnabled, } diff --git a/apps/renterd/contexts/config/useSyncContractSet.tsx b/apps/renterd/contexts/config/useSyncContractSet.tsx index 014e89879..f7e978cc4 100644 --- a/apps/renterd/contexts/config/useSyncContractSet.tsx +++ b/apps/renterd/contexts/config/useSyncContractSet.tsx @@ -25,7 +25,7 @@ export function useSyncContractSet() { }) const settingUpdate = useSettingUpdate() - const syncDefaultContractSet = useCallback( + const maybeSyncDefaultContractSet = useCallback( async (autopilotContractSet: string) => { const csd = contractSet.data || { default: '' } try { @@ -70,6 +70,6 @@ export function useSyncContractSet() { return { shouldSyncDefaultContractSet, setShouldSyncDefaultContractSet, - syncDefaultContractSet, + maybeSyncDefaultContractSet, } } diff --git a/apps/walletd-e2e/src/fixtures/sendSiacoinDialog.ts b/apps/walletd-e2e/src/fixtures/sendSiacoinDialog.ts index 2c334e07c..7d011ff41 100644 --- a/apps/walletd-e2e/src/fixtures/sendSiacoinDialog.ts +++ b/apps/walletd-e2e/src/fixtures/sendSiacoinDialog.ts @@ -11,9 +11,12 @@ export async function fillComposeTransactionSiacoin({ changeAddress: string amount: string }) { + await page.locator('input[name=receiveAddress]').click() await page.locator('input[name=receiveAddress]').fill(receiveAddress) await page.getByLabel('customChangeAddress').click() + await page.locator('input[name=changeAddress]').click() await page.locator('input[name=changeAddress]').fill(changeAddress) + await page.locator('input[name=siacoin]').click() await page.locator('input[name=siacoin]').fill(amount) await page.getByRole('button', { name: 'Generate transaction' }).click() } diff --git a/apps/walletd-e2e/src/specs/seedGenerateAddresses.spec.ts b/apps/walletd-e2e/src/specs/seedGenerateAddresses.spec.ts index df7ad7207..f427b0e0a 100644 --- a/apps/walletd-e2e/src/specs/seedGenerateAddresses.spec.ts +++ b/apps/walletd-e2e/src/specs/seedGenerateAddresses.spec.ts @@ -7,7 +7,7 @@ import { mockApiDefaults, getMockRescanResponse, } from '@siafoundation/walletd-mock' -import { WalletAddressesResponse } from '@siafoundation/walletd-react' +import { WalletAddressesResponse } from '@siafoundation/walletd-types' function getMockWalletAddressesResponse(): WalletAddressesResponse { return [] diff --git a/libs/design-system/src/app/AppNavbar.tsx b/libs/design-system/src/app/AppNavbar.tsx index 070c1c17e..0645ce43d 100644 --- a/libs/design-system/src/app/AppNavbar.tsx +++ b/libs/design-system/src/app/AppNavbar.tsx @@ -13,7 +13,10 @@ export const navbarAppHeight = 60 export function AppNavbar({ title, nav, stats, actions, after }: Props) { return ( <> -
+