diff --git a/.changeset/chatty-jobs-knock.md b/.changeset/chatty-jobs-knock.md new file mode 100644 index 000000000..e2876bd07 --- /dev/null +++ b/.changeset/chatty-jobs-knock.md @@ -0,0 +1,5 @@ +--- +'hostd': minor +--- + +The configuration now has an advanced mode that allows the user to view and change all settings. diff --git a/.changeset/honest-hotels-relate.md b/.changeset/honest-hotels-relate.md new file mode 100644 index 000000000..c23d8cd0a --- /dev/null +++ b/.changeset/honest-hotels-relate.md @@ -0,0 +1,5 @@ +--- +'hostd': 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/late-nails-type.md b/.changeset/late-nails-type.md new file mode 100644 index 000000000..ee5ffac3d --- /dev/null +++ b/.changeset/late-nails-type.md @@ -0,0 +1,5 @@ +--- +'hostd': minor +--- + +The command palette now includes navigation to configuration sections. diff --git a/.changeset/seven-eagles-pull.md b/.changeset/seven-eagles-pull.md new file mode 100644 index 000000000..edd50f82d --- /dev/null +++ b/.changeset/seven-eagles-pull.md @@ -0,0 +1,5 @@ +--- +'hostd': minor +--- + +The configuration is now much simpler by default, only requiring the user to set essential settings. diff --git a/apps/hostd/components/CmdRoot/ConfigCmdGroup.tsx b/apps/hostd/components/CmdRoot/ConfigCmdGroup.tsx index bc5cc39b0..4881675fa 100644 --- a/apps/hostd/components/CmdRoot/ConfigCmdGroup.tsx +++ b/apps/hostd/components/CmdRoot/ConfigCmdGroup.tsx @@ -3,6 +3,7 @@ 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' const commandPage = { namespace: 'configuration', @@ -17,6 +18,7 @@ type Props = { export function ConfigCmdGroup({ currentPage, parentPage, pushPage }: Props) { const router = useRouter() + const { showAdvanced } = useConfig() const { closeDialog } = useDialog() return ( @@ -40,26 +42,70 @@ export function ConfigCmdGroup({ currentPage, parentPage, pushPage }: Props) { > Open configuration - {/* { + router.push(routes.config.host) + closeDialog() + }} + > + Configure host + + { - router.push(routes.config.gouging) + router.push(routes.config.pricing) closeDialog() }} > - Configure gouging + Configure pricing { - router.push(routes.config.redundancy) + router.push(routes.config.dns) closeDialog() }} > - Configure redundancy - */} + Configure DNS + + { + router.push(routes.config.bandwidth) + closeDialog() + }} + > + Configure bandwidth + + {showAdvanced && ( + <> + { + router.push(routes.config.registry) + closeDialog() + }} + > + Configure registry + + { + router.push(routes.config.accounts) + closeDialog() + }} + > + Configure accounts + + + )} ) } diff --git a/apps/hostd/components/Config/ConfigNav.tsx b/apps/hostd/components/Config/ConfigNav.tsx new file mode 100644 index 000000000..4616b9889 --- /dev/null +++ b/apps/hostd/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/hostd/components/Config/fields.tsx b/apps/hostd/components/Config/fields.tsx deleted file mode 100644 index fea7c1ca0..000000000 --- a/apps/hostd/components/Config/fields.tsx +++ /dev/null @@ -1,454 +0,0 @@ -import { blocksToMonths, ConfigFields } from '@siafoundation/design-system' -import { DNSProvider } from '@siafoundation/react-hostd' -import BigNumber from 'bignumber.js' - -export const scDecimalPlaces = 6 -const dnsProviderOptions: { value: DNSProvider; label: string }[] = [ - { - value: '', - label: 'Off', - }, - { - value: 'route53', - label: 'Route 53', - }, - { - value: 'noip', - label: 'No-IP', - }, - { - value: 'duckdns', - label: 'Duck DNS', - }, - { - value: 'cloudflare', - label: 'Cloudflare', - }, -] - -export const initialValues = { - // Host settings - acceptingContracts: false, - netAddress: '', - maxContractDuration: undefined as BigNumber | undefined, - - // Pricing - contractPrice: undefined as BigNumber | undefined, - baseRPCPrice: undefined as BigNumber | undefined, - sectorAccessPrice: undefined as BigNumber | undefined, - - collateralMultiplier: undefined as BigNumber | undefined, - maxCollateral: undefined as BigNumber | undefined, - - storagePrice: undefined as BigNumber | undefined, - egressPrice: undefined as BigNumber | undefined, - ingressPrice: undefined as BigNumber | undefined, - - priceTableValidity: undefined as BigNumber | undefined, - - // Registry settings - maxRegistryEntries: undefined as BigNumber | undefined, - - // RHP3 settings - accountExpiry: undefined as BigNumber | undefined, - maxAccountBalance: undefined as BigNumber | undefined, - - // Bandwidth limiter settings - ingressLimit: undefined as BigNumber | undefined, - egressLimit: undefined as BigNumber | undefined, - - // DNS settings - dnsProvider: '' as DNSProvider, - dnsIpv4: false, - dnsIpv6: false, - // dnsOptions: {} as Record, - - // DNS DuckDNS - dnsDuckDnsToken: '', - - // DNS No-IP - dnsNoIpEmail: '', - dnsNoIpPassword: '', - - // DNS AWS - dnsAwsId: '', - dnsAwsSecret: '', - dnsAwsZoneId: '', - - // DNS Cloudflare - dnsCloudflareToken: '', - dnsCloudflareZoneId: '', -} - -export type SettingsData = typeof initialValues - -type Categories = 'host' | 'pricing' | 'DNS' | 'bandwidth' | 'registry' | 'RHP3' - -export const fields: ConfigFields = { - // Host - acceptingContracts: { - type: 'boolean', - category: 'host', - title: 'Accepting contracts', - description: <>Whether or not the host is accepting contracts., - validation: {}, - }, - netAddress: { - type: 'text', - category: 'host', - title: 'Address', - description: <>The network address of the host., - placeholder: 'my.host.com:9882', - validation: { - required: 'required', - }, - }, - maxContractDuration: { - type: 'number', - category: 'host', - title: 'Maximum contract duration', - units: 'months', - decimalsLimit: 2, - suggestion: new BigNumber(6), - suggestionTip: 'The default maximum duration is 6 months.', - - description: <>The maximum contract duration that the host will accept., - validation: { - required: 'required', - validate: { - min: (value) => - new BigNumber(value as BigNumber).gte(blocksToMonths(4320)) || - `must be at least 1 month`, - }, - }, - }, - - // Pricing - storagePrice: { - title: 'Storage price', - type: 'siacoin', - category: 'pricing', - units: 'SC/TB/month', - decimalsLimitSc: scDecimalPlaces, - - description: ( - <>{`The host's storage price in siacoins per TB per month.`} - ), - validation: { - required: 'required', - }, - }, - egressPrice: { - title: 'Egress price', - type: 'siacoin', - category: 'pricing', - units: 'SC/TB', - decimalsLimitSc: scDecimalPlaces, - - description: <>{`The host's egress price in siacoins per TB.`}, - validation: { - required: 'required', - }, - }, - ingressPrice: { - title: 'Ingress price', - type: 'siacoin', - category: 'pricing', - units: 'SC/TB', - decimalsLimitSc: scDecimalPlaces, - - description: <>{`The host's ingress price in siacoins per TB.`}, - validation: { - required: 'required', - }, - }, - collateralMultiplier: { - title: 'Collateral multiplier', - type: 'number', - category: 'pricing', - units: '* storage price', - placeholder: '2', - decimalsLimit: 1, - description: ( - <>{`The host's target collateral as a multiple of storage price.`} - ), - suggestion: new BigNumber(2), - suggestionTip: 'The default multiplier is 2x the storage price.', - validation: { - required: 'required', - }, - }, - maxCollateral: { - title: 'Maximum collateral', - type: 'siacoin', - category: 'pricing', - decimalsLimitSc: scDecimalPlaces, - - description: <>{`The host's maximum collateral in siacoins.`}, - validation: { - required: 'required', - }, - }, - contractPrice: { - title: 'Contract price', - type: 'siacoin', - category: 'pricing', - decimalsLimitSc: scDecimalPlaces, - description: <>{`The host's contract price in siacoins.`}, - validation: { - required: 'required', - }, - }, - baseRPCPrice: { - title: 'Base RPC price', - type: 'siacoin', - category: 'pricing', - units: 'SC/million', - decimalsLimitSc: scDecimalPlaces, - description: ( - <>{`The host's base RPC price in siacoins per million calls.`} - ), - validation: { - required: 'required', - }, - }, - sectorAccessPrice: { - title: 'Sector access price', - type: 'siacoin', - category: 'pricing', - units: 'SC/million', - decimalsLimitSc: scDecimalPlaces, - - description: ( - <>{`The host's sector access price in siacoins million sectors.`} - ), - validation: { - required: 'required', - }, - }, - priceTableValidity: { - title: 'Price table validity', - type: 'number', - category: 'pricing', - units: 'minutes', - description: ( - <>{`How long a renter's registered price table remains valid.`} - ), - validation: { - required: 'required', - }, - }, - - // Registry - maxRegistryEntries: { - title: 'Maximum registry size', - type: 'number', - category: 'registry', - units: 'entries', - decimalsLimit: 0, - description: ( - <>{`The maximum number of registry entries that the host will store. Each registry entry is up to 113 bytes.`} - ), - validation: { - required: 'required', - }, - }, - - // RHP3 settings - accountExpiry: { - title: 'Expiry', - type: 'number', - category: 'RHP3', - units: 'days', - description: ( - <>{`How long a renter's ephemeral accounts are inactive before the host prunes them and recovers the remaining funds.`} - ), - validation: { - required: 'required', - validate: { - min: (value) => - new BigNumber(value as BigNumber).gte(7) || `must be at least 1 week`, - }, - }, - }, - maxAccountBalance: { - title: 'Maximum balance', - type: 'siacoin', - category: 'RHP3', - 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.`} - ), - validation: { - required: 'required', - validate: { - min: (value) => - new BigNumber(value as BigNumber).gte(1) || `must be at least 1 SC`, - }, - }, - }, - - // Bandwidth - ingressLimit: { - title: 'Ingress limit', - type: 'number', - category: 'bandwidth', - units: 'MB/second', - description: ( - <>The maximum amount of ingress bandwidth traffic in MB per second. - ), - validation: { - required: 'required', - }, - }, - egressLimit: { - title: 'Egress limit', - type: 'number', - category: 'bandwidth', - units: 'MB/second', - description: ( - <>The maximum amount of egress bandwidth traffic in MB per second. - ), - validation: { - required: 'required', - }, - }, - - // DNS - dnsProvider: { - title: 'Dynamic DNS Provider', - type: 'select', - category: 'DNS', - options: dnsProviderOptions, - - description: <>Enable dynamic DNS with one of the supported providers., - validation: { - validate: (value) => - !!dnsProviderOptions.find((o) => o.value === value) || - 'must be one of supported providers', - }, - }, - dnsIpv4: { - title: 'IPv4', - type: 'boolean', - category: 'DNS', - description: <>Whether IPv4 is enabled., - show: (values) => !!values.dnsProvider, - validation: { - validate: (value, values) => - !values.dnsProvider || - !!(value || values.dnsIpv6) || - 'at least one of IPv4 and IPv6 must be enabled', - }, - trigger: ['dnsIpv6'], - }, - dnsIpv6: { - type: 'boolean', - title: 'IPv6', - category: 'DNS', - description: <>Whether IPv6 is enabled., - show: (values) => !!values.dnsProvider, - validation: { - validate: (value, values) => - !values.dnsProvider || - !!(value || values.dnsIpv4) || - 'at least one of IPv4 and IPv6 must be enabled', - }, - trigger: ['dnsIpv4'], - }, - - // DNS DuckDNS - dnsDuckDnsToken: { - type: 'text', - title: 'Token', - category: 'DNS', - description: <>DuckDNS token., - show: (values) => values.dnsProvider === 'duckdns', - validation: { - validate: (value, values) => - values.dnsProvider !== 'duckdns' || !!value || 'required', - }, - }, - - // DNS No-IP - dnsNoIpEmail: { - type: 'text', - title: 'Email', - category: 'DNS', - description: <>No-IP email., - show: (values) => values.dnsProvider === 'noip', - validation: { - validate: (value, values) => - values.dnsProvider !== 'noip' || !!value || 'required', - }, - }, - dnsNoIpPassword: { - type: 'password', - title: 'Password', - category: 'DNS', - description: <>No-IP password., - show: (values) => values.dnsProvider === 'noip', - validation: { - validate: (value, values) => - values.dnsProvider !== 'noip' || !!value || 'required', - }, - }, - - // DNS AWS Route53 - dnsAwsId: { - type: 'text', - title: 'ID', - category: 'DNS', - description: <>AWS Route53 ID., - show: (values) => values.dnsProvider === 'route53', - validation: { - validate: (value, values) => - values.dnsProvider !== 'route53' || !!value || 'required', - }, - }, - dnsAwsSecret: { - type: 'password', - title: 'Secret', - category: 'DNS', - description: <>AWS Route53 secret., - show: (values) => values.dnsProvider === 'route53', - validation: { - validate: (value, values) => - values.dnsProvider !== 'route53' || !!value || 'required', - }, - }, - dnsAwsZoneId: { - type: 'text', - title: 'Zone ID', - category: 'DNS', - description: <>AWS Route53 zone ID., - show: (values) => values.dnsProvider === 'route53', - validation: { - validate: (value, values) => - values.dnsProvider !== 'route53' || !!value || 'required', - }, - }, - - // DNS Cloudflare - dnsCloudflareToken: { - type: 'text', - title: 'Token', - category: 'DNS', - description: <>Cloudflare token., - show: (values) => values.dnsProvider === 'cloudflare', - validation: { - validate: (value, values) => - values.dnsProvider !== 'cloudflare' || !!value || 'required', - }, - }, - dnsCloudflareZoneId: { - type: 'text', - title: 'Zone ID', - category: 'DNS', - description: <>Cloudflare zone ID., - show: (values) => values.dnsProvider === 'cloudflare', - validation: { - validate: (value, values) => - values.dnsProvider !== 'cloudflare' || !!value || 'required', - }, - }, -} diff --git a/apps/hostd/components/Config/index.tsx b/apps/hostd/components/Config/index.tsx index cf150f3a9..f2f8b20e6 100644 --- a/apps/hostd/components/Config/index.tsx +++ b/apps/hostd/components/Config/index.tsx @@ -1,135 +1,34 @@ -import { - Text, - Button, - triggerSuccessToast, - triggerErrorToast, - ConfigurationPanel, - useOnInvalid, -} from '@siafoundation/design-system' +import { Text, Button, ConfigurationPanel } from '@siafoundation/design-system' import { Reset16, Save16, Warning16, CheckmarkFilled16, } from '@siafoundation/react-icons' -import { useCallback, useEffect, useMemo, useState } from 'react' import { HostdSidenav } from '../HostdSidenav' import { routes } from '../../config/routes' import { useDialog } from '../../contexts/dialog' import { HostdAuthedLayout } from '../../components/HostdAuthedLayout' -import { - HostSettings, - useSettings, - useSettingsDdns, - useSettingsUpdate, -} from '@siafoundation/react-hostd' -import { fields, initialValues } from './fields' -import { transformDown, transformUp } from './transform' -import { useForm } from 'react-hook-form' import { AnnounceButton } from './AnnounceButton' +import { useConfig } from '../../contexts/config' +import { ConfigNav } from './ConfigNav' export function Config() { const { openDialog } = useDialog() - const settings = useSettings({ - config: { - swr: { - // Do not automatically refetch - revalidateOnFocus: false, - }, - }, - }) - const settingsUpdate = useSettingsUpdate() - const dynDNSCheck = useSettingsDdns({ - disabled: !settings.data || !settings.data.ddns.provider, - config: { - swr: { - revalidateOnFocus: false, - errorRetryCount: 0, - }, - }, - }) - - const form = useForm({ - mode: 'all', - defaultValues: initialValues, - }) - - const resetFormData = useCallback( - (data: HostSettings) => { - form.reset(transformDown(data)) - }, - [form] - ) - - // init - when new config is fetched, set the form - const [hasInit, setHasInit] = useState(false) - useEffect(() => { - if (settings.data && !hasInit) { - resetFormData(settings.data) - setHasInit(true) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings.data]) - - const reset = useCallback(async () => { - const data = await settings.mutate() - if (!data) { - triggerErrorToast('Error fetching settings.') - } else { - resetFormData(data) - // also recheck dynamic dns - await dynDNSCheck.mutate() - } - }, [settings, resetFormData, dynDNSCheck]) - - const onValid = useCallback( - async (values: typeof initialValues) => { - if (!settings.data) { - return - } - try { - const response = await settingsUpdate.patch({ - payload: transformUp(values, settings.data), - }) - if (response.error) { - throw Error(response.error) - } - if (form.formState.dirtyFields.netAddress) { - triggerSuccessToast( - 'Settings have been saved. Address has changed, make sure to re-announce the host.', - { - duration: 20_000, - } - ) - } else { - triggerSuccessToast('Settings have been saved.') - } - resetFormData(response.data) - // also recheck dynamic dns - await dynDNSCheck.mutate() - } catch (e) { - triggerErrorToast((e as Error).message) - console.log(e) - } - }, - [form, settings, settingsUpdate, resetFormData, dynDNSCheck] - ) - - 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 { + fields, + settings, + dynDNSCheck, + changeCount, + revalidateAndResetFormData, + form, + onSubmit, + } = useConfig() return ( } sidenav={} stats={ settings.data?.ddns.provider && !dynDNSCheck.isValidating ? ( @@ -164,7 +63,7 @@ export function Config() { tip="Reset all changes" icon="contrast" disabled={!changeCount} - onClick={reset} + onClick={revalidateAndResetFormData} > diff --git a/apps/hostd/config/providers.tsx b/apps/hostd/config/providers.tsx index 223fd9a0a..163c7c401 100644 --- a/apps/hostd/config/providers.tsx +++ b/apps/hostd/config/providers.tsx @@ -2,6 +2,7 @@ import { ContractsProvider } from '../contexts/contracts' import { MetricsProvider } from '../contexts/metrics' import { DialogProvider, Dialogs } from '../contexts/dialog' import { VolumesProvider } from '../contexts/volumes' +import { ConfigProvider } from '../contexts/config' type Props = { children: React.ReactNode @@ -10,16 +11,18 @@ 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/hostd/config/routes.ts b/apps/hostd/config/routes.ts index a9e36f402..d23048d3d 100644 --- a/apps/hostd/config/routes.ts +++ b/apps/hostd/config/routes.ts @@ -10,6 +10,12 @@ export const routes = { }, config: { index: '/config', + host: '/config#host', + pricing: '/config#pricing', + dns: '/config#dns', + bandwidth: '/config#bandwidth', + registry: '/config#registry', + accounts: '/config#accounts', }, wallet: { view: '/wallet', diff --git a/apps/hostd/contexts/config/fields.tsx b/apps/hostd/contexts/config/fields.tsx new file mode 100644 index 000000000..3eb94a6ab --- /dev/null +++ b/apps/hostd/contexts/config/fields.tsx @@ -0,0 +1,394 @@ +import { blocksToMonths, ConfigFields } from '@siafoundation/design-system' +import BigNumber from 'bignumber.js' +import { dnsProviderOptions, initialValues, scDecimalPlaces } from './types' + +type Categories = 'host' | 'pricing' | 'DNS' | 'bandwidth' | 'registry' | 'RHP3' + +type GetFields = { + showAdvanced: boolean +} + +export function getFields({ + showAdvanced, +}: GetFields): ConfigFields { + return { + // Host + acceptingContracts: { + type: 'boolean', + category: 'host', + title: 'Accepting contracts', + description: <>Whether or not the host is accepting contracts., + validation: {}, + }, + netAddress: { + type: 'text', + category: 'host', + title: 'Address', + description: <>The network address of the host., + placeholder: 'my.host.com:9882', + validation: { + required: 'required', + }, + }, + maxContractDuration: { + type: 'number', + category: 'host', + title: 'Maximum contract duration', + units: 'months', + decimalsLimit: 2, + suggestion: new BigNumber(6), + suggestionTip: 'The default maximum duration is 6 months.', + + description: ( + <>The maximum contract duration that the host will accept. + ), + hidden: !showAdvanced, + validation: { + required: 'required', + validate: { + min: (value) => + new BigNumber(value as BigNumber).gte(blocksToMonths(4320)) || + `must be at least 1 month`, + }, + }, + }, + + // Pricing + storagePrice: { + title: 'Storage price', + type: 'siacoin', + category: 'pricing', + units: 'SC/TB/month', + decimalsLimitSc: scDecimalPlaces, + + description: ( + <>{`The host's storage price in siacoins per TB per month.`} + ), + validation: { + required: 'required', + }, + }, + egressPrice: { + title: 'Egress price', + type: 'siacoin', + category: 'pricing', + units: 'SC/TB', + decimalsLimitSc: scDecimalPlaces, + + description: <>{`The host's egress price in siacoins per TB.`}, + validation: { + required: 'required', + }, + }, + ingressPrice: { + title: 'Ingress price', + type: 'siacoin', + category: 'pricing', + units: 'SC/TB', + decimalsLimitSc: scDecimalPlaces, + + description: <>{`The host's ingress price in siacoins per TB.`}, + validation: { + required: 'required', + }, + }, + collateralMultiplier: { + title: 'Collateral multiplier', + type: 'number', + category: 'pricing', + units: '* storage price', + placeholder: '2', + decimalsLimit: 1, + description: ( + <>{`The host's target collateral as a multiple of storage price.`} + ), + suggestion: new BigNumber(2), + suggestionTip: 'The default multiplier is 2x the storage price.', + validation: { + required: 'required', + }, + }, + maxCollateral: { + title: 'Maximum collateral', + type: 'siacoin', + category: 'pricing', + decimalsLimitSc: scDecimalPlaces, + + description: <>{`The host's maximum collateral in siacoins.`}, + hidden: !showAdvanced, + validation: { + required: 'required', + }, + }, + contractPrice: { + title: 'Contract price', + type: 'siacoin', + category: 'pricing', + decimalsLimitSc: scDecimalPlaces, + description: <>{`The host's contract price in siacoins.`}, + hidden: !showAdvanced, + validation: { + required: 'required', + }, + }, + baseRPCPrice: { + title: 'Base RPC price', + type: 'siacoin', + category: 'pricing', + units: 'SC/million', + decimalsLimitSc: scDecimalPlaces, + description: ( + <>{`The host's base RPC price in siacoins per million calls.`} + ), + hidden: !showAdvanced, + validation: { + required: 'required', + }, + }, + sectorAccessPrice: { + title: 'Sector access price', + type: 'siacoin', + category: 'pricing', + units: 'SC/million', + decimalsLimitSc: scDecimalPlaces, + + description: ( + <>{`The host's sector access price in siacoins million sectors.`} + ), + hidden: !showAdvanced, + validation: { + required: 'required', + }, + }, + priceTableValidity: { + title: 'Price table validity', + type: 'number', + category: 'pricing', + units: 'minutes', + description: ( + <>{`How long a renter's registered price table remains valid.`} + ), + hidden: !showAdvanced, + validation: { + required: 'required', + }, + }, + + // Registry + maxRegistryEntries: { + title: 'Maximum registry size', + type: 'number', + category: 'registry', + units: 'entries', + decimalsLimit: 0, + description: ( + <>{`The maximum number of registry entries that the host will store. Each registry entry is up to 113 bytes.`} + ), + hidden: !showAdvanced, + validation: { + required: 'required', + }, + }, + + // RHP3 settings + accountExpiry: { + title: 'Expiry', + type: 'number', + category: 'RHP3', + units: 'days', + description: ( + <>{`How long a renter's ephemeral accounts are inactive before the host prunes them and recovers the remaining funds.`} + ), + hidden: !showAdvanced, + validation: { + required: 'required', + validate: { + min: (value) => + new BigNumber(value as BigNumber).gte(7) || + `must be at least 1 week`, + }, + }, + }, + maxAccountBalance: { + title: 'Maximum balance', + type: 'siacoin', + category: 'RHP3', + 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, + validation: { + required: 'required', + validate: { + min: (value) => + new BigNumber(value as BigNumber).gte(1) || `must be at least 1 SC`, + }, + }, + }, + + // Bandwidth + ingressLimit: { + title: 'Ingress limit', + type: 'number', + category: 'bandwidth', + units: 'MB/second', + description: ( + <>The maximum amount of ingress bandwidth traffic in MB per second. + ), + validation: { + required: 'required', + }, + }, + egressLimit: { + title: 'Egress limit', + type: 'number', + category: 'bandwidth', + units: 'MB/second', + description: ( + <>The maximum amount of egress bandwidth traffic in MB per second. + ), + validation: { + required: 'required', + }, + }, + + // DNS + dnsProvider: { + title: 'Dynamic DNS Provider', + type: 'select', + category: 'DNS', + options: dnsProviderOptions, + + description: <>Enable dynamic DNS with one of the supported providers., + validation: { + validate: (value) => + !!dnsProviderOptions.find((o) => o.value === value) || + 'must be one of supported providers', + }, + }, + dnsIpv4: { + title: 'IPv4', + type: 'boolean', + category: 'DNS', + description: <>Whether IPv4 is enabled., + show: (values) => !!values.dnsProvider, + validation: { + validate: (value, values) => + !values.dnsProvider || + !!(value || values.dnsIpv6) || + 'at least one of IPv4 and IPv6 must be enabled', + }, + trigger: ['dnsIpv6'], + }, + dnsIpv6: { + type: 'boolean', + title: 'IPv6', + category: 'DNS', + description: <>Whether IPv6 is enabled., + show: (values) => !!values.dnsProvider, + validation: { + validate: (value, values) => + !values.dnsProvider || + !!(value || values.dnsIpv4) || + 'at least one of IPv4 and IPv6 must be enabled', + }, + trigger: ['dnsIpv4'], + }, + + // DNS DuckDNS + dnsDuckDnsToken: { + type: 'text', + title: 'Token', + category: 'DNS', + description: <>DuckDNS token., + show: (values) => values.dnsProvider === 'duckdns', + validation: { + validate: (value, values) => + values.dnsProvider !== 'duckdns' || !!value || 'required', + }, + }, + + // DNS No-IP + dnsNoIpEmail: { + type: 'text', + title: 'Email', + category: 'DNS', + description: <>No-IP email., + show: (values) => values.dnsProvider === 'noip', + validation: { + validate: (value, values) => + values.dnsProvider !== 'noip' || !!value || 'required', + }, + }, + dnsNoIpPassword: { + type: 'password', + title: 'Password', + category: 'DNS', + description: <>No-IP password., + show: (values) => values.dnsProvider === 'noip', + validation: { + validate: (value, values) => + values.dnsProvider !== 'noip' || !!value || 'required', + }, + }, + + // DNS AWS Route53 + dnsAwsId: { + type: 'text', + title: 'ID', + category: 'DNS', + description: <>AWS Route53 ID., + show: (values) => values.dnsProvider === 'route53', + validation: { + validate: (value, values) => + values.dnsProvider !== 'route53' || !!value || 'required', + }, + }, + dnsAwsSecret: { + type: 'password', + title: 'Secret', + category: 'DNS', + description: <>AWS Route53 secret., + show: (values) => values.dnsProvider === 'route53', + validation: { + validate: (value, values) => + values.dnsProvider !== 'route53' || !!value || 'required', + }, + }, + dnsAwsZoneId: { + type: 'text', + title: 'Zone ID', + category: 'DNS', + description: <>AWS Route53 zone ID., + show: (values) => values.dnsProvider === 'route53', + validation: { + validate: (value, values) => + values.dnsProvider !== 'route53' || !!value || 'required', + }, + }, + + // DNS Cloudflare + dnsCloudflareToken: { + type: 'text', + title: 'Token', + category: 'DNS', + description: <>Cloudflare token., + show: (values) => values.dnsProvider === 'cloudflare', + validation: { + validate: (value, values) => + values.dnsProvider !== 'cloudflare' || !!value || 'required', + }, + }, + dnsCloudflareZoneId: { + type: 'text', + title: 'Zone ID', + category: 'DNS', + description: <>Cloudflare zone ID., + show: (values) => values.dnsProvider === 'cloudflare', + validation: { + validate: (value, values) => + values.dnsProvider !== 'cloudflare' || !!value || 'required', + }, + }, + } +} diff --git a/apps/hostd/contexts/config/index.tsx b/apps/hostd/contexts/config/index.tsx new file mode 100644 index 000000000..984769e0f --- /dev/null +++ b/apps/hostd/contexts/config/index.tsx @@ -0,0 +1,208 @@ +import { createContext, useContext } from 'react' +import { + triggerSuccessToast, + triggerErrorToast, + useOnInvalid, +} from '@siafoundation/design-system' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + HostSettings, + useSettings, + useSettingsDdns, + useSettingsUpdate, +} from '@siafoundation/react-hostd' +import { SettingsData, initialValues } from './types' +import { getFields } from './fields' +import { calculateMaxCollateral, transformDown, transformUp } from './transform' +import { useForm } from 'react-hook-form' +import useLocalStorageState from 'use-local-storage-state' + +export function useConfigMain() { + const settings = useSettings({ + standalone: 'configSettingsForm', + }) + const settingsUpdate = useSettingsUpdate() + const dynDNSCheck = useSettingsDdns({ + disabled: !settings.data || !settings.data.ddns.provider, + config: { + swr: { + revalidateOnFocus: false, + errorRetryCount: 0, + }, + }, + }) + const [showAdvanced, setShowAdvanced] = useLocalStorageState( + 'v0/config/showAdvanced', + { + defaultValue: false, + } + ) + + const form = useForm({ + mode: 'all', + defaultValues: initialValues, + }) + + const resetFormData = useCallback( + (data: HostSettings) => { + const settingsData = transformDown(data) + form.reset(settingsData) + return settingsData + }, + [form] + ) + + const didDataRevalidate = useMemo(() => [settings.data], [settings.data]) + + const resetFormDataIfAllDataFetched = useCallback((): SettingsData | null => { + if (settings.data) { + return resetFormData(settings.data) + } + return null + }, [resetFormData, settings.data]) + + // init - when new config is fetched, set the form + const [hasInit, setHasInit] = useState(false) + useEffect(() => { + if (!hasInit) { + const didReset = resetFormDataIfAllDataFetched() + if (didReset) { + setHasInit(true) + } + } + }, [hasInit, resetFormDataIfAllDataFetched]) + + const revalidateAndResetFormData = useCallback(async () => { + const data = await settings.mutate() + if (!data) { + triggerErrorToast('Error fetching settings.') + } else { + resetFormData(data) + // also recheck dynamic dns + await dynDNSCheck.mutate() + } + }, [settings, resetFormData, dynDNSCheck]) + + const onValid = useCallback( + async (values: typeof initialValues) => { + if (!settings.data) { + return + } + try { + const response = await settingsUpdate.patch({ + payload: transformUp(values, settings.data), + }) + if (response.error) { + throw Error(response.error) + } + if (form.formState.dirtyFields.netAddress) { + triggerSuccessToast( + 'Settings have been saved. Address has changed, make sure to re-announce the host.', + { + duration: 20_000, + } + ) + } else { + triggerSuccessToast('Settings have been saved.') + } + await revalidateAndResetFormData() + } catch (e) { + triggerErrorToast((e as Error).message) + console.log(e) + } + }, + [form, settings, settingsUpdate, revalidateAndResetFormData] + ) + + const fields = useMemo(() => getFields({ showAdvanced }), [showAdvanced]) + + const onInvalid = useOnInvalid(fields) + + const onSubmit = useMemo( + () => form.handleSubmit(onValid, onInvalid), + [form, onValid, onInvalid] + ) + + const storage = form.watch('storagePrice') + const collateralMultiplier = form.watch('collateralMultiplier') + + // if simple mode, then calculate and set max collateral + useEffect(() => { + if ( + !showAdvanced && + storage?.isGreaterThan(0) && + collateralMultiplier?.isGreaterThan(0) + ) { + form.setValue( + 'maxCollateral', + calculateMaxCollateral(storage, collateralMultiplier), + { + shouldDirty: true, + } + ) + } + }, [form, showAdvanced, storage, collateralMultiplier]) + + // 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() + if (!serverFormValues) { + return + } + 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 { + fields, + settings, + dynDNSCheck, + changeCount, + revalidateAndResetFormData, + form, + onSubmit, + 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/hostd/components/Config/transform.spec.ts b/apps/hostd/contexts/config/transform.spec.ts similarity index 94% rename from apps/hostd/components/Config/transform.spec.ts rename to apps/hostd/contexts/config/transform.spec.ts index 8e25e101d..820b4c4c2 100644 --- a/apps/hostd/components/Config/transform.spec.ts +++ b/apps/hostd/contexts/config/transform.spec.ts @@ -1,5 +1,5 @@ import BigNumber from 'bignumber.js' -import { transformDown, transformUp } from './transform' +import { calculateMaxCollateral, transformDown, transformUp } from './transform' describe('data transforms', () => { it('down', () => { @@ -135,4 +135,10 @@ describe('data transforms', () => { }, }) }) + + it('max collateral', () => { + expect( + calculateMaxCollateral(new BigNumber('400'), new BigNumber(2)) + ).toEqual(new BigNumber('2400')) + }) }) diff --git a/apps/hostd/components/Config/transform.ts b/apps/hostd/contexts/config/transform.ts similarity index 95% rename from apps/hostd/components/Config/transform.ts rename to apps/hostd/contexts/config/transform.ts index f5295993c..fece51fa4 100644 --- a/apps/hostd/components/Config/transform.ts +++ b/apps/hostd/contexts/config/transform.ts @@ -19,7 +19,7 @@ import { humanStoragePrice, } from '../../lib/humanUnits' import BigNumber from 'bignumber.js' -import { scDecimalPlaces, SettingsData } from './fields' +import { scDecimalPlaces, SettingsData } from './types' export function transformUp( values: SettingsData, @@ -216,3 +216,13 @@ export function transformDown(s: HostSettings): SettingsData { ...dnsOptions, } } + +export function calculateMaxCollateral( + storage: BigNumber, + collateralMultiplier: BigNumber +) { + return new BigNumber(12960) + .times(storage) + .div(monthsToBlocks(1)) + .times(collateralMultiplier) +} diff --git a/apps/hostd/contexts/config/types.ts b/apps/hostd/contexts/config/types.ts new file mode 100644 index 000000000..82789a636 --- /dev/null +++ b/apps/hostd/contexts/config/types.ts @@ -0,0 +1,82 @@ +import { DNSProvider } from '@siafoundation/react-hostd' +import BigNumber from 'bignumber.js' + +export const scDecimalPlaces = 6 +export const dnsProviderOptions: { value: DNSProvider; label: string }[] = [ + { + value: '', + label: 'Off', + }, + { + value: 'route53', + label: 'Route 53', + }, + { + value: 'noip', + label: 'No-IP', + }, + { + value: 'duckdns', + label: 'Duck DNS', + }, + { + value: 'cloudflare', + label: 'Cloudflare', + }, +] + +export const initialValues = { + // Host settings + acceptingContracts: false, + netAddress: '', + maxContractDuration: undefined as BigNumber | undefined, + + // Pricing + contractPrice: undefined as BigNumber | undefined, + baseRPCPrice: undefined as BigNumber | undefined, + sectorAccessPrice: undefined as BigNumber | undefined, + + collateralMultiplier: undefined as BigNumber | undefined, + maxCollateral: undefined as BigNumber | undefined, + + storagePrice: undefined as BigNumber | undefined, + egressPrice: undefined as BigNumber | undefined, + ingressPrice: undefined as BigNumber | undefined, + + priceTableValidity: undefined as BigNumber | undefined, + + // Registry settings + maxRegistryEntries: undefined as BigNumber | undefined, + + // RHP3 settings + accountExpiry: undefined as BigNumber | undefined, + maxAccountBalance: undefined as BigNumber | undefined, + + // Bandwidth limiter settings + ingressLimit: undefined as BigNumber | undefined, + egressLimit: undefined as BigNumber | undefined, + + // DNS settings + dnsProvider: '' as DNSProvider, + dnsIpv4: false, + dnsIpv6: false, + // dnsOptions: {} as Record, + + // DNS DuckDNS + dnsDuckDnsToken: '', + + // DNS No-IP + dnsNoIpEmail: '', + dnsNoIpPassword: '', + + // DNS AWS + dnsAwsId: '', + dnsAwsSecret: '', + dnsAwsZoneId: '', + + // DNS Cloudflare + dnsCloudflareToken: '', + dnsCloudflareZoneId: '', +} + +export type SettingsData = typeof initialValues diff --git a/apps/renterd/components/OnboardingBar.tsx b/apps/renterd/components/OnboardingBar.tsx index 0f17df6ea..f1eb1c9eb 100644 --- a/apps/renterd/components/OnboardingBar.tsx +++ b/apps/renterd/components/OnboardingBar.tsx @@ -174,11 +174,13 @@ export function OnboardingBar() { ) : ( <> - - {syncStatus.walletScanPercent}% - + {!syncStatus.isWalletSynced && ( + + {syncStatus.walletScanPercent}% + + )} openDialog('addressDetails')} diff --git a/apps/renterd/contexts/config/fields.tsx b/apps/renterd/contexts/config/fields.tsx index c0a62f76b..f3a6f41d4 100644 --- a/apps/renterd/contexts/config/fields.tsx +++ b/apps/renterd/contexts/config/fields.tsx @@ -104,8 +104,9 @@ export function getFields({ category: 'storage', title: 'Allowance', description: ( - <>The amount of Siacoin you would like to spend for the period. + <>The amount of Siacoin you would like to spend per month. ), + units: 'SC/month', decimalsLimitSc: scDecimalPlaces, hidden: !isAutopilotEnabled || !showAdvanced, // always required, but set in background unless advanced mode diff --git a/apps/renterd/contexts/config/index.tsx b/apps/renterd/contexts/config/index.tsx index 79a3384c7..54018e641 100644 --- a/apps/renterd/contexts/config/index.tsx +++ b/apps/renterd/contexts/config/index.tsx @@ -480,6 +480,9 @@ export function useConfigMain() { const resetWithUserChanges = useCallback(() => { const currentFormValues = form.getValues() const serverFormValues = resetFormDataIfAllDataFetched() + if (!serverFormValues) { + return + } form.reset(serverFormValues) for (const [key, value] of Object.entries(currentFormValues)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/server/Caddyfile-dev b/server/Caddyfile-dev index aeb823c9e..00addb352 100644 --- a/server/Caddyfile-dev +++ b/server/Caddyfile-dev @@ -5,6 +5,7 @@ # 127.0.0.1 design.local # 127.0.0.1 zen.local # 127.0.0.1 host.local +# 127.0.0.1 hostd.local # 127.0.0.1 renter.local # 127.0.0.1 renterd.local # 127.0.0.1 renterd.zen.local @@ -112,6 +113,11 @@ renterd.local { reverse_proxy localhost:9980 } +hostd.local { + import cors + reverse_proxy localhost:9980 +} + walletd.local { import cors reverse_proxy localhost:9980