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