diff --git a/.changeset/four-dolls-jam.md b/.changeset/four-dolls-jam.md
new file mode 100644
index 000000000..4fc5a5512
--- /dev/null
+++ b/.changeset/four-dolls-jam.md
@@ -0,0 +1,5 @@
+---
+'@siafoundation/design-system': minor
+---
+
+Table now supports an active row with colored border.
diff --git a/.changeset/hungry-baboons-check.md b/.changeset/hungry-baboons-check.md
new file mode 100644
index 000000000..d66990d5a
--- /dev/null
+++ b/.changeset/hungry-baboons-check.md
@@ -0,0 +1,5 @@
+---
+'@siafoundation/design-system': minor
+---
+
+AppAuthedLayout now has a scroll prop.
diff --git a/.changeset/nervous-dogs-rhyme.md b/.changeset/nervous-dogs-rhyme.md
new file mode 100644
index 000000000..c7203388c
--- /dev/null
+++ b/.changeset/nervous-dogs-rhyme.md
@@ -0,0 +1,5 @@
+---
+'@siafoundation/react-core': minor
+---
+
+Added useTryUntil.
diff --git a/.changeset/new-geckos-occur.md b/.changeset/new-geckos-occur.md
new file mode 100644
index 000000000..3efc5418d
--- /dev/null
+++ b/.changeset/new-geckos-occur.md
@@ -0,0 +1,5 @@
+---
+'renterd': minor
+---
+
+The host explorer now has an interactive map. Filtered hosts can be selected via the list or map, and hosts on the map are colored based on whether renterd is actively contracting with the host. For hosts with active contracts the size of the hosts on the map is based on renterd's used storage, for hosts without active contracts the size is based on the hosts remaining storage.
diff --git a/.changeset/red-dolls-bow.md b/.changeset/red-dolls-bow.md
new file mode 100644
index 000000000..e2dc2b374
--- /dev/null
+++ b/.changeset/red-dolls-bow.md
@@ -0,0 +1,5 @@
+---
+'@siafoundation/react-core': minor
+---
+
+Add useSiaCentralHosts.
diff --git a/.changeset/slimy-files-scream.md b/.changeset/slimy-files-scream.md
new file mode 100644
index 000000000..b72fdad5c
--- /dev/null
+++ b/.changeset/slimy-files-scream.md
@@ -0,0 +1,5 @@
+---
+'@siafoundation/design-system': minor
+---
+
+Fixed border issues with table.
diff --git a/.changeset/thirty-countries-doubt.md b/.changeset/thirty-countries-doubt.md
new file mode 100644
index 000000000..64ce302c9
--- /dev/null
+++ b/.changeset/thirty-countries-doubt.md
@@ -0,0 +1,5 @@
+---
+'@siafoundation/design-system': minor
+---
+
+Add box-shadow based border classes.
diff --git a/apps/renterd/assets/earth-dark-contrast.png b/apps/renterd/assets/earth-dark-contrast.png
new file mode 100644
index 000000000..16056c7a7
Binary files /dev/null and b/apps/renterd/assets/earth-dark-contrast.png differ
diff --git a/apps/renterd/assets/earth-dark.jpeg b/apps/renterd/assets/earth-dark.jpeg
new file mode 100644
index 000000000..222bd939d
Binary files /dev/null and b/apps/renterd/assets/earth-dark.jpeg differ
diff --git a/apps/renterd/assets/earth-day.jpeg b/apps/renterd/assets/earth-day.jpeg
new file mode 100644
index 000000000..cc68dd8f3
Binary files /dev/null and b/apps/renterd/assets/earth-day.jpeg differ
diff --git a/apps/renterd/assets/earth-night.jpeg b/apps/renterd/assets/earth-night.jpeg
new file mode 100644
index 000000000..688918088
Binary files /dev/null and b/apps/renterd/assets/earth-night.jpeg differ
diff --git a/apps/renterd/assets/earth-topology.png b/apps/renterd/assets/earth-topology.png
new file mode 100644
index 000000000..666be0091
Binary files /dev/null and b/apps/renterd/assets/earth-topology.png differ
diff --git a/apps/renterd/assets/map-2.jpg b/apps/renterd/assets/map-2.jpg
new file mode 100644
index 000000000..f5242d698
Binary files /dev/null and b/apps/renterd/assets/map-2.jpg differ
diff --git a/apps/renterd/assets/night-sky.png b/apps/renterd/assets/night-sky.png
new file mode 100644
index 000000000..f5d4f2beb
Binary files /dev/null and b/apps/renterd/assets/night-sky.png differ
diff --git a/apps/renterd/components/Hosts/HostContextMenu.tsx b/apps/renterd/components/Hosts/HostContextMenu.tsx
index 98bcb4f10..8b4fe702d 100644
--- a/apps/renterd/components/Hosts/HostContextMenu.tsx
+++ b/apps/renterd/components/Hosts/HostContextMenu.tsx
@@ -54,7 +54,13 @@ export function HostContextMenu({
}
- contentProps={{ align: 'start', ...contentProps }}
+ contentProps={{
+ align: 'start',
+ ...contentProps,
+ onClick: (e) => {
+ e.stopPropagation()
+ },
+ }}
>
diff --git a/apps/renterd/components/Hosts/HostMap/Globe.tsx b/apps/renterd/components/Hosts/HostMap/Globe.tsx
new file mode 100644
index 000000000..9371fb4eb
--- /dev/null
+++ b/apps/renterd/components/Hosts/HostMap/Globe.tsx
@@ -0,0 +1,224 @@
+import { useEffect, useRef, useCallback, useMemo } from 'react'
+import { GlobeMethods } from 'react-globe.gl'
+import { getHostLabel } from './utils'
+import { useElementSize } from 'usehooks-ts'
+import {
+ useSiaCentralMarketExchangeRate,
+ useTryUntil,
+} from '@siafoundation/react-core'
+import earthDarkContrast from '../../../assets/earth-dark-contrast.png'
+import earthTopology from '../../../assets/earth-topology.png'
+import { GlobeDyn } from './GlobeDyn'
+import { HostDataWithLocation } from '../../../contexts/hosts/types'
+import BigNumber from 'bignumber.js'
+import { getHostStatus } from '../../../contexts/hosts/status'
+
+export type Commands = {
+ moveToLocation: (
+ location: [number, number] | undefined,
+ altitude?: number
+ ) => void
+}
+
+export const emptyCommands: Commands = {
+ moveToLocation: (location: [number, number], altitude?: number) => null,
+}
+
+type Props = {
+ activeHost?: HostDataWithLocation
+ hosts?: HostDataWithLocation[]
+ onHostClick: (publicKey: string, location: [number, number]) => void
+ onHostHover: (publicKey: string, location: [number, number]) => void
+ onMount?: (cmd: Commands) => void
+}
+
+type Route = {
+ distance: number
+ src: HostDataWithLocation
+ dst: HostDataWithLocation
+}
+
+export function Globe({
+ activeHost,
+ hosts,
+ onMount,
+ onHostClick,
+ onHostHover,
+}: Props) {
+ const rates = useSiaCentralMarketExchangeRate({
+ config: {
+ swr: {
+ revalidateOnFocus: false,
+ },
+ },
+ })
+ const globeEl = useRef(null)
+ const cmdRef = useRef(emptyCommands)
+ const moveToLocation = useCallback(
+ (location: [number, number] | undefined, altitude?: number) => {
+ if (!location) {
+ return
+ }
+ globeEl.current?.pointOfView(
+ {
+ lat: location[0] - 8,
+ lng: location[1],
+ altitude: altitude || 1.5,
+ },
+ 700
+ )
+ },
+ []
+ )
+
+ useEffect(() => {
+ cmdRef.current.moveToLocation = moveToLocation
+ }, [moveToLocation])
+
+ useTryUntil(() => {
+ if (!globeEl.current) {
+ return false
+ }
+
+ moveToLocation(activeHost?.location || [48.8323, 2.4075], 1.5)
+
+ const directionalLight = globeEl.current
+ ?.scene()
+ .children.find((obj3d) => obj3d.type === 'DirectionalLight')
+ if (directionalLight) {
+ // directionalLight.position.set(1, 1, 1)
+ directionalLight.intensity = 10
+ }
+ return true
+ })
+
+ useEffect(() => {
+ if (onMount) {
+ onMount(cmdRef.current)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ // const routes = useDecRoutes({ hosts, activeHost })
+ const routes = []
+
+ const [containerRef, { height, width }] = useElementSize()
+
+ const points = useMemo(() => hosts || [], [hosts])
+
+ return (
+
+
+ getHostLabel({ host: r.dst, rates: rates.data?.rates.sc })
+ }
+ arcStartLat={(r: Route) => +r.src.location[0]}
+ arcStartLng={(r: Route) => +r.src.location[1]}
+ arcEndLat={(r: Route) => +r.dst.location[0]}
+ arcEndLng={(r: Route) => +r.dst.location[1]}
+ arcDashLength={0.75}
+ arcAltitude={0}
+ arcDashGap={0.1}
+ arcDashInitialGap={() => Math.random()}
+ arcDashAnimateTime={5000}
+ // arcDashAnimateTime={(route: Route) =>
+ // doesIncludeActiveHost(route, activeHost) ? 5000 : 0
+ // }
+ arcColor={(r: Route) =>
+ doesIncludeActiveHost(r, activeHost)
+ ? [`rgba(187, 229, 201, 0.25)`, `rgba(187, 229, 201, 0.25)`]
+ : [`rgba(187, 229, 201, 0.10)`, `rgba(187, 229, 201, 0.10)`]
+ }
+ // onArcClick={(r: Route) => {
+ // selectActiveHost(r.dst.publicKey)
+ // }}
+ arcsTransitionDuration={0}
+ pointsData={points}
+ pointLat={(h: HostDataWithLocation) => h.location[0]}
+ pointLng={(h: HostDataWithLocation) => h.location[1]}
+ pointLabel={(h: HostDataWithLocation) =>
+ getHostLabel({ host: h, rates: rates.data?.rates.sc })
+ }
+ // pointAltitude={
+ // (h: HostDataWithLocation) => h.settings.remainingstorage / 1e13 / 100
+ // // h.publicKey === activeHost.publicKey ? 0.6 : 0.2
+ // }
+ pointAltitude={(h: HostDataWithLocation) => {
+ if (activeHost) {
+ return h.publicKey === activeHost?.publicKey
+ ? 0.1
+ : h.activeContractsCount.gt(0)
+ ? 0.1
+ : 0.1
+ }
+ return h.activeContractsCount.gt(0) ? 0.1 : 0.1
+ }}
+ pointsTransitionDuration={0}
+ pointColor={(h: HostDataWithLocation) => {
+ const { color } = getHostStatus(h)
+ if (!activeHost || h.publicKey === activeHost?.publicKey) {
+ return color
+ }
+ return colorWithOpacity(color, 0.2)
+ }}
+ pointRadius={(h: HostDataWithLocation) => {
+ let radius = 0
+ if (h.activeContractsCount.gt(0)) {
+ radius = h.activeContracts
+ .reduce((acc, c) => acc.plus(c.size), new BigNumber(0))
+ .div(1e12)
+ .toNumber()
+ }
+ radius = h.settings.remainingstorage / 1e13 / 3
+
+ return Math.max(radius, 0.1)
+ }}
+ onPointHover={(h: HostDataWithLocation) => {
+ if (!h) {
+ return
+ }
+ onHostHover?.(h.publicKey, h.location)
+ }}
+ onPointClick={(h: HostDataWithLocation) => {
+ if (!h) {
+ return
+ }
+ onHostClick?.(h.publicKey, h.location)
+ }}
+ pointsMerge={false}
+ />
+
+ )
+}
+
+function doesIncludeActiveHost(
+ route: Route,
+ activeHost?: HostDataWithLocation
+) {
+ if (!activeHost) {
+ return false
+ }
+ return (
+ route.dst.publicKey === activeHost.publicKey ||
+ route.src.publicKey === activeHost.publicKey
+ )
+}
+
+function colorWithOpacity(hexColor: string, opacity: number) {
+ const r = parseInt(hexColor.slice(1, 3), 16)
+ const g = parseInt(hexColor.slice(3, 5), 16)
+ const b = parseInt(hexColor.slice(5, 7), 16)
+
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`
+}
diff --git a/apps/renterd/components/Hosts/HostMap/GlobeDyn.tsx b/apps/renterd/components/Hosts/HostMap/GlobeDyn.tsx
new file mode 100644
index 000000000..7c4214a05
--- /dev/null
+++ b/apps/renterd/components/Hosts/HostMap/GlobeDyn.tsx
@@ -0,0 +1,14 @@
+import { forwardRef, MutableRefObject } from 'react'
+import dynamic from 'next/dynamic'
+import { GlobeMethods } from 'react-globe.gl'
+
+const GlobeGl = dynamic(() => import('./GlobeImp'), {
+ ssr: false,
+})
+
+export const GlobeDyn = forwardRef(function ReactGlobe(
+ props: Omit, 'forwardRef'>,
+ ref: MutableRefObject
+) {
+ return
+})
diff --git a/apps/renterd/components/Hosts/HostMap/GlobeImp.tsx b/apps/renterd/components/Hosts/HostMap/GlobeImp.tsx
new file mode 100644
index 000000000..af0372b6c
--- /dev/null
+++ b/apps/renterd/components/Hosts/HostMap/GlobeImp.tsx
@@ -0,0 +1,11 @@
+import { MutableRefObject } from 'react'
+import GlobeTmpl, { GlobeMethods } from 'react-globe.gl'
+
+const GlobeImp = ({
+ forwardRef,
+ ...otherProps
+}: React.ComponentProps & {
+ forwardRef: MutableRefObject
+}) =>
+
+export default GlobeImp
diff --git a/apps/renterd/components/Hosts/HostMap/HostItem.tsx b/apps/renterd/components/Hosts/HostMap/HostItem.tsx
new file mode 100644
index 000000000..c4c8e2c7c
--- /dev/null
+++ b/apps/renterd/components/Hosts/HostMap/HostItem.tsx
@@ -0,0 +1,162 @@
+import { useMemo } from 'react'
+import {
+ Button,
+ monthsToBlocks,
+ TBToBytes,
+ Text,
+ Tooltip,
+ countryCodeEmoji,
+} from '@siafoundation/design-system'
+import { humanBytes, humanSiacoin, humanSpeed } from '@siafoundation/sia-js'
+import { cx } from 'class-variance-authority'
+import BigNumber from 'bignumber.js'
+import { SiaCentralHost } from '@siafoundation/react-core'
+
+type Host = SiaCentralHost
+
+type Props = {
+ host: Host
+ activeHost: Host
+ setRef?: (el: HTMLButtonElement) => void
+ selectActiveHost: (public_key: string) => void
+ rates: {
+ usd: string
+ }
+}
+
+export function HostItem({
+ host,
+ activeHost,
+ selectActiveHost,
+ setRef,
+ rates,
+}: Props) {
+ const storageCost = useMemo(
+ () =>
+ rates
+ ? `$${new BigNumber(host.settings.storage_price)
+ .times(TBToBytes(1))
+ .times(monthsToBlocks(1))
+ .div(1e24)
+ .times(rates?.usd || 1)
+ .toFixed(2)}/TB`
+ : `${humanSiacoin(
+ new BigNumber(host.settings.storage_price)
+ .times(TBToBytes(1))
+ .times(monthsToBlocks(1)),
+ { fixed: 0 }
+ )}/TB`,
+ [rates, host]
+ )
+
+ const downloadCost = useMemo(
+ () =>
+ rates
+ ? `$${new BigNumber(host.settings.download_price)
+ .times(TBToBytes(1))
+ .div(1e24)
+ .times(rates?.usd || 1)
+ .toFixed(2)}/TB`
+ : `${humanSiacoin(
+ new BigNumber(host.settings.download_price).times(TBToBytes(1)),
+ { fixed: 0 }
+ )}/TB`,
+ [rates, host]
+ )
+
+ const uploadCost = useMemo(
+ () =>
+ rates
+ ? `$${new BigNumber(host.settings.upload_price)
+ .times(TBToBytes(1))
+ .div(1e24)
+ .times(rates?.usd || 1)
+ .toFixed(2)}/TB`
+ : `${humanSiacoin(
+ new BigNumber(host.settings.upload_price).times(TBToBytes(1)),
+ { fixed: 0 }
+ )}/TB`,
+ [rates, host]
+ )
+
+ return (
+
+
+ {countryCodeEmoji(host.country_code)} {host.country_code}
+
+
+
+ storage
+ download
+ upload
+
+
+
+ {humanBytes(host.settings.total_storage)}
+
+
+ {humanSpeed(
+ (host.benchmark.data_size * 8) /
+ (host.benchmark.download_time / 1000)
+ )}{' '}
+
+
+ {humanSpeed(
+ (host.benchmark.data_size * 8) /
+ (host.benchmark.upload_time / 1000)
+ )}{' '}
+
+
+
+ {storageCost}
+ {downloadCost}
+ {uploadCost}
+
+
+
+ }
+ key={host.public_key}
+ >
+ {
+ if (setRef) {
+ setRef(el)
+ }
+ }}
+ onClick={() => {
+ selectActiveHost(host.public_key)
+ }}
+ className={cx(
+ 'flex gap-1',
+ host.public_key === activeHost?.public_key
+ ? 'opacity-100'
+ : 'opacity-50',
+ 'hover:opacity-100'
+ )}
+ >
+
+ {countryCodeEmoji(host.country_code)}
+
+
+ {humanBytes(host.settings.total_storage)} ·{' '}
+ {humanSpeed(
+ (host.benchmark.data_size * 8) /
+ (host.benchmark.download_time / 1000)
+ )}{' '}
+ · {storageCost}
+
+
+
+ )
+}
diff --git a/apps/renterd/components/Hosts/HostMap/index.tsx b/apps/renterd/components/Hosts/HostMap/index.tsx
new file mode 100644
index 000000000..4974172e1
--- /dev/null
+++ b/apps/renterd/components/Hosts/HostMap/index.tsx
@@ -0,0 +1,42 @@
+import { useAppSettings } from '@siafoundation/react-core'
+import { Globe } from './Globe'
+import { useHosts } from '../../../contexts/hosts'
+import { HostDataWithLocation } from '../../../contexts/hosts/types'
+import { DataLabel } from '@siafoundation/design-system'
+
+export function HostMap() {
+ const { gpu } = useAppSettings()
+ const {
+ setCmd,
+ activeHost,
+ onHostMapClick: onHostSelect,
+ onHostMapHover: onHostHover,
+ hostsWithLocation,
+ } = useHosts()
+ if (!(gpu.hasCheckedGpu && gpu.shouldRender)) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
{
+ setCmd(cmd)
+ }}
+ />
+
+ )
+}
diff --git a/apps/renterd/components/Hosts/HostMap/useRoutes.tsx b/apps/renterd/components/Hosts/HostMap/useRoutes.tsx
new file mode 100644
index 000000000..19a280c4b
--- /dev/null
+++ b/apps/renterd/components/Hosts/HostMap/useRoutes.tsx
@@ -0,0 +1,92 @@
+import { useMemo } from 'react'
+import { random, sortBy } from 'lodash'
+import { HostDataWithLocation } from '../../../contexts/hosts/types'
+
+type Props = {
+ activeHost?: HostDataWithLocation
+ hosts?: HostDataWithLocation[]
+}
+
+type Route = {
+ distance: number
+ src: HostDataWithLocation
+ dst: HostDataWithLocation
+}
+
+export function useDecRoutes({ activeHost, hosts }: Props) {
+ const backgroundRoutes = useMemo(() => {
+ const routes: Route[] = []
+ if (!hosts) {
+ return routes
+ }
+ for (let i = 0; i < hosts.length; i++) {
+ const host1 = hosts[i]
+ let hostRoutes: Route[] = []
+ for (let j = 0; j < hosts.length; j++) {
+ if (i === j) {
+ continue
+ }
+ const host2 = hosts[j]
+ const distance = distanceBetweenHosts(host1, host2)
+ hostRoutes.push({
+ distance,
+ src: host1,
+ dst: host2,
+ })
+ }
+ hostRoutes = sortBy(hostRoutes, 'distance')
+ const closest = hostRoutes.slice(4, 6)
+ routes.push(...closest)
+
+ const addExtra = Math.random() < 0.1
+ if (addExtra) {
+ const randomDistantIndex = random(
+ // Math.round((hostRoutes.length - 1) / 2),
+ 0,
+ hostRoutes.length - 1
+ )
+ const extra = hostRoutes[randomDistantIndex]
+ routes.push(extra)
+ }
+ }
+ return routes
+ }, [hosts])
+
+ const activeRoutes = useMemo(() => {
+ let routes: Route[] = []
+ if (!hosts || !activeHost) {
+ return routes
+ }
+ for (let i = 0; i < hosts.length; i++) {
+ const host = hosts[i]
+ if (activeHost.publicKey === host.publicKey) {
+ continue
+ }
+ const distance = distanceBetweenHosts(activeHost, host)
+ routes.push({
+ distance,
+ src: activeHost,
+ dst: host,
+ })
+ }
+ routes = sortBy(routes, 'distance')
+ return routes.slice(0, 5)
+ }, [activeHost, hosts])
+
+ const routes = useMemo(
+ () => [...backgroundRoutes, ...activeRoutes],
+ [backgroundRoutes, activeRoutes]
+ )
+
+ return routes
+}
+
+function distanceBetweenHosts(
+ h1: HostDataWithLocation,
+ h2: HostDataWithLocation
+) {
+ return Math.sqrt(
+ Math.pow(h1.location[0] - h2.location[0], 2) +
+ Math.pow(h1.location[1] - h2.location[1], 2)
+ )
+}
diff --git a/apps/renterd/components/Hosts/HostMap/utils.ts b/apps/renterd/components/Hosts/HostMap/utils.ts
new file mode 100644
index 000000000..c9ab3d422
--- /dev/null
+++ b/apps/renterd/components/Hosts/HostMap/utils.ts
@@ -0,0 +1,46 @@
+import {
+ monthsToBlocks,
+ TBToBytes,
+ countryCodeEmoji,
+} from '@siafoundation/design-system'
+import { humanBytes, humanSiacoin } from '@siafoundation/sia-js'
+import { HostDataWithLocation } from '../../../contexts/hosts/types'
+import BigNumber from 'bignumber.js'
+
+export function getHostLabel({
+ host,
+ rates,
+}: {
+ host: HostDataWithLocation
+ rates?: {
+ usd: string
+ }
+}) {
+ const storageCost = rates
+ ? `$${new BigNumber(host.settings.storageprice)
+ .times(TBToBytes(1))
+ .times(monthsToBlocks(1))
+ .div(1e24)
+ .times(rates?.usd || 1)
+ .toFixed(2)}/TB`
+ : `${humanSiacoin(
+ new BigNumber(host.settings.storageprice)
+ .times(TBToBytes(1))
+ .times(monthsToBlocks(1)),
+ { fixed: 0 }
+ )}/TB`
+
+ const usedStorage = `${humanBytes(
+ host.activeContracts
+ .reduce((acc, c) => acc.plus(c.size), new BigNumber(0))
+ .toNumber()
+ )} utilized`
+
+ const availableStorage = `${humanBytes(
+ host.settings.remainingstorage
+ )} / ${humanBytes(host.settings.totalstorage)} available`
+
+ return `${countryCodeEmoji(
+ host.countryCode
+ )} · ${storageCost} · ${usedStorage} · ${availableStorage}`
+}
diff --git a/apps/renterd/components/Hosts/HostsActionsMenu.tsx b/apps/renterd/components/Hosts/HostsActionsMenu.tsx
index 7c78c867a..103b85a82 100644
--- a/apps/renterd/components/Hosts/HostsActionsMenu.tsx
+++ b/apps/renterd/components/Hosts/HostsActionsMenu.tsx
@@ -1,9 +1,13 @@
-import { Button, ListChecked16 } from '@siafoundation/design-system'
+import { Button, Earth16, ListChecked16 } from '@siafoundation/design-system'
import { HostsViewDropdownMenu } from './HostsViewDropdownMenu'
import { useDialog } from '../../contexts/dialog'
+import { useHosts } from '../../contexts/hosts'
+import { useAppSettings } from '@siafoundation/react-core'
export function HostsActionsMenu() {
const { openDialog } = useDialog()
+ const { viewMode, setViewMode } = useHosts()
+ const { gpu } = useAppSettings()
return (
+ Manage lists
+ {gpu.canGpuRender && (
+ setViewMode(viewMode === 'map' ? 'list' : 'map')}
+ tip="Toggle interactive map"
+ >
+
+
+ )}
)
diff --git a/apps/renterd/components/Hosts/HostsCmd/HostsFilterCmd/HostsFilterCmdGroups/Contracts.tsx b/apps/renterd/components/Hosts/HostsCmd/HostsFilterCmd/HostsFilterCmdGroups/Contracts.tsx
index c236931d9..f058c92be 100644
--- a/apps/renterd/components/Hosts/HostsCmd/HostsFilterCmd/HostsFilterCmdGroups/Contracts.tsx
+++ b/apps/renterd/components/Hosts/HostsCmd/HostsFilterCmd/HostsFilterCmdGroups/Contracts.tsx
@@ -7,8 +7,8 @@ export const hostsFilterContractsPage = {
label: 'Hosts filter by contracts',
}
-const o = {
- id: 'activeContracts',
+export const hostsFilterByHasActiveContracts = {
+ id: 'hasActiveContracts',
bool: true,
label: 'has active contracts',
}
@@ -29,10 +29,10 @@ export function ContractsCmdGroup({
currentPage={currentPage}
commandPage={hostsFilterContractsPage}
onSelect={() => {
- select(o)
+ select(hostsFilterByHasActiveContracts)
}}
>
- {o.label}
+ {hostsFilterByHasActiveContracts.label}
)
diff --git a/apps/renterd/components/Hosts/HostsCmd/HostsFilterCmd/HostsFilterCmdGroups/Usable.tsx b/apps/renterd/components/Hosts/HostsCmd/HostsFilterCmd/HostsFilterCmdGroups/Usable.tsx
index 5035fa57c..48c56f9a1 100644
--- a/apps/renterd/components/Hosts/HostsCmd/HostsFilterCmd/HostsFilterCmdGroups/Usable.tsx
+++ b/apps/renterd/components/Hosts/HostsCmd/HostsFilterCmd/HostsFilterCmdGroups/Usable.tsx
@@ -7,18 +7,18 @@ export const hostsFilterUsablePage = {
label: 'Hosts filter by usable',
}
-const options = [
- {
- id: 'usabilityMode',
- value: 'usable',
- label: 'usable',
- },
- {
- id: 'usabilityMode',
- value: 'unusable',
- label: 'unusable',
- },
-]
+export const hostsFilterByUsable = {
+ id: 'usabilityMode',
+ value: 'usable',
+ label: 'usable',
+}
+export const hostsFilterByUnusable = {
+ id: 'usabilityMode',
+ value: 'unusable',
+ label: 'unusable',
+}
+
+const options = [hostsFilterByUsable, hostsFilterByUnusable]
export function UsableCmdGroup({
select,
diff --git a/apps/renterd/components/Hosts/index.tsx b/apps/renterd/components/Hosts/index.tsx
index acc20b6f6..de6534220 100644
--- a/apps/renterd/components/Hosts/index.tsx
+++ b/apps/renterd/components/Hosts/index.tsx
@@ -1,16 +1,26 @@
import { RenterdSidenav } from '../RenterdSidenav'
import { routes } from '../../config/routes'
-import { Table } from '@siafoundation/design-system'
+import { ScrollArea, Table } from '@siafoundation/design-system'
import { useDialog } from '../../contexts/dialog'
import { useHosts } from '../../contexts/hosts'
import { RenterdAuthedLayout } from '../RenterdAuthedLayout'
import { StateEmpty } from './StateEmpty'
import { HostsActionsMenu } from './HostsActionsMenu'
import { HostsFilterBar } from './HostsFilterBar'
+import { HostMap } from './HostMap'
+import { cx } from 'class-variance-authority'
export function Hosts() {
const { openDialog } = useDialog()
- const { dataset, columns, limit, dataState, tableContext } = useHosts()
+ const {
+ dataset,
+ activeHost,
+ columns,
+ limit,
+ dataState,
+ tableContext,
+ viewMode,
+ } = useHosts()
return (
}
stats={ }
+ scroll={false}
>
-
-
}
- context={tableContext}
- pageSize={limit}
- data={dataset}
- columns={columns}
- rowSize="default"
- // sortField={sortField}
- // sortDirection={sortDirection}
- // toggleSort={toggleSort}
- />
+
+
+
+
+
+
+
+
}
+ context={tableContext}
+ pageSize={limit}
+ data={dataset}
+ columns={columns}
+ rowSize="default"
+ />
+
+
+
)
diff --git a/apps/renterd/contexts/contracts/index.tsx b/apps/renterd/contexts/contracts/index.tsx
index 75ba159d8..3e2f56243 100644
--- a/apps/renterd/contexts/contracts/index.tsx
+++ b/apps/renterd/contexts/contracts/index.tsx
@@ -13,6 +13,7 @@ import {
useEstimatedNetworkBlockHeight,
} from '@siafoundation/react-renterd'
import { createContext, useContext, useMemo } from 'react'
+import { useSiaCentralHosts } from '@siafoundation/react-core'
import BigNumber from 'bignumber.js'
import {
ContractData,
@@ -29,6 +30,8 @@ function useContractsMain() {
const limit = Number(router.query.limit || defaultLimit)
const offset = Number(router.query.offset || 0)
const response = useContractsData()
+ const geo = useSiaCentralHosts()
+ const geoHosts = useMemo(() => geo.data?.hosts || [], [geo.data])
const estimatedNetworkHeight = useEstimatedNetworkBlockHeight()
const network = useConsensusState({
@@ -59,6 +62,7 @@ function useContractsMain() {
contractId: c.id,
hostIp: c.hostIP,
hostKey: c.hostKey,
+ location: geoHosts.find((h) => h.public_key === c.hostKey)?.location,
timeline: startTime,
startTime,
endTime,
@@ -78,7 +82,7 @@ function useContractsMain() {
}
}) || []
return data
- }, [response.data, currentHeight])
+ }, [response.data, geoHosts, currentHeight])
const { filters, setFilter, removeFilter, removeLastFilter, resetFilters } =
useClientFilters
()
diff --git a/apps/renterd/contexts/contracts/types.ts b/apps/renterd/contexts/contracts/types.ts
index af615c221..653fd80fb 100644
--- a/apps/renterd/contexts/contracts/types.ts
+++ b/apps/renterd/contexts/contracts/types.ts
@@ -4,6 +4,7 @@ export type ContractData = {
id: string
hostIp: string
hostKey: string
+ location?: [number, number]
isRenewed: boolean
renewedFrom: string
timeline: number
diff --git a/apps/renterd/contexts/hosts/columns.tsx b/apps/renterd/contexts/hosts/columns.tsx
index 9344753c5..0f7cf8d54 100644
--- a/apps/renterd/contexts/hosts/columns.tsx
+++ b/apps/renterd/contexts/hosts/columns.tsx
@@ -278,7 +278,7 @@ export const columns: HostsTableColumn[] = (
category: 'general',
contentClassName: 'w-[50px]',
render: ({ data }) => {
- const hasContract = data.activeContracts.gt(0)
+ const hasContract = data.activeContractsCount.gt(0)
return (
(
humanNumber(v.toNumber())}
/>
diff --git a/apps/renterd/contexts/hosts/dataset.ts b/apps/renterd/contexts/hosts/dataset.ts
index 5bc7cd610..ad08adc55 100644
--- a/apps/renterd/contexts/hosts/dataset.ts
+++ b/apps/renterd/contexts/hosts/dataset.ts
@@ -10,6 +10,7 @@ import {
} from '@siafoundation/react-renterd'
import { ContractData } from '../contracts/types'
import { useApp } from '../app'
+import { SiaCentralHost } from '@siafoundation/react-core'
export function useDataset({
autopilotState,
@@ -19,6 +20,8 @@ export function useDataset({
allowlist,
blocklist,
isAllowlistActive,
+ geoHosts,
+ onHostSelect,
}: {
autopilotState: ReturnType['autopilot']['state']
regularResponse: ReturnType
@@ -27,12 +30,16 @@ export function useDataset({
allowlist: ReturnType
blocklist: ReturnType
isAllowlistActive: boolean
+ geoHosts: SiaCentralHost[]
+ onHostSelect: (publicKey: string, location?: [number, number]) => void
}) {
return useMemo(() => {
if (autopilotState === 'off') {
return (
regularResponse.data?.map((host) => {
+ const sch = geoHosts.find((gh) => gh.public_key === host.publicKey)
return {
+ onClick: () => onHostSelect(host.publicKey, sch?.location),
...getHostFields(host, allContracts),
...getAllowedFields({
host,
@@ -41,13 +48,17 @@ export function useDataset({
isAllowlistActive,
}),
...getAutopilotFields(),
+ location: sch?.location,
+ countryCode: sch?.country_code,
}
}) || null
)
} else if (autopilotState === 'on') {
return (
autopilotResponse.data?.map((ah) => {
+ const sch = geoHosts.find((gh) => gh.public_key === ah.host.publicKey)
return {
+ onClick: () => onHostSelect(ah.host.publicKey, sch?.location),
...getHostFields(ah.host, allContracts),
...getAllowedFields({
host: ah.host,
@@ -56,12 +67,15 @@ export function useDataset({
isAllowlistActive,
}),
...getAutopilotFields(ah.checks),
+ location: sch?.location,
+ countryCode: sch?.country_code,
}
}) || null
)
}
return null
}, [
+ onHostSelect,
autopilotState,
regularResponse.data,
autopilotResponse.data,
@@ -69,6 +83,7 @@ export function useDataset({
allowlist.data,
blocklist.data,
isAllowlistActive,
+ geoHosts,
])
}
@@ -93,9 +108,11 @@ function getHostFields(host: Host, allContracts: ContractData[]) {
host.interactions.FailedInteractions || 0
),
totalScans: new BigNumber(host.interactions.TotalScans || 0),
- activeContracts: new BigNumber(
+ activeContractsCount: new BigNumber(
allContracts?.filter((c) => c.hostKey === host.publicKey).length || 0
),
+ activeContracts:
+ allContracts?.filter((c) => c.hostKey === host.publicKey) || [],
priceTable: host.priceTable,
settings: host.settings,
}
diff --git a/apps/renterd/contexts/hosts/index.tsx b/apps/renterd/contexts/hosts/index.tsx
index b426f6a90..8f9088edf 100644
--- a/apps/renterd/contexts/hosts/index.tsx
+++ b/apps/renterd/contexts/hosts/index.tsx
@@ -2,6 +2,8 @@ import {
useTableState,
useDatasetEmptyState,
useServerFilters,
+ triggerErrorToast,
+ truncate,
} from '@siafoundation/design-system'
import {
HostsSearchFilterMode,
@@ -11,13 +13,27 @@ import {
useHostsBlocklist,
useHostsSearch,
} from '@siafoundation/react-renterd'
-import { createContext, useContext, useMemo } from 'react'
-import { TableColumnId, columnsDefaultVisible } from './types'
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import {
+ TableColumnId,
+ columnsDefaultVisible,
+ ViewMode,
+ HostDataWithLocation,
+} from './types'
import { useRouter } from 'next/router'
import { columns } from './columns'
import { useContracts } from '../contracts'
import { useDataset } from './dataset'
import { useApp } from '../app'
+import { useSiaCentralHosts } from '@siafoundation/react-core'
+import { Commands, emptyCommands } from '../../components/Hosts/HostMap/Globe'
const defaultLimit = 50
@@ -33,7 +49,7 @@ function useHostsMain() {
const keyIn = useMemo(() => {
let keyIn: string[] = []
- if (filters.find((f) => f.id === 'activeContracts') && allContracts) {
+ if (filters.find((f) => f.id === 'hasActiveContracts') && allContracts) {
keyIn = allContracts.map((c) => c.hostKey)
}
if (filters.find((f) => f.id === 'publicKeyContains')) {
@@ -45,7 +61,7 @@ function useHostsMain() {
const autopilotResponse = useAutopilotHostsSearch({
disabled:
// prevents an extra fetch when allContracts is null
- (filters.find((f) => f.id === 'activeContracts') && !allContracts) ||
+ (filters.find((f) => f.id === 'hasActiveContracts') && !allContracts) ||
autopilot.state !== 'on',
payload: {
limit,
@@ -74,7 +90,7 @@ function useHostsMain() {
'all') as HostsSearchFilterMode,
addressContains: filters.find((f) => f.id === 'addressContains')?.value,
keyIn:
- filters.find((f) => f.id === 'activeContracts') && allContracts
+ filters.find((f) => f.id === 'hasActiveContracts') && allContracts
? allContracts.map((c) => c.hostKey)
: undefined,
},
@@ -84,6 +100,80 @@ function useHostsMain() {
const blocklist = useHostsBlocklist()
const isAllowlistActive = !!allowlist.data?.length
+ const geo = useSiaCentralHosts({
+ config: {
+ swr: {
+ revalidateOnFocus: false,
+ },
+ },
+ })
+ const geoHosts = useMemo(() => geo.data?.hosts || [], [geo.data])
+
+ const cmdRef = useRef(emptyCommands)
+
+ const setCmd = useCallback(
+ (cmd: Commands) => {
+ cmdRef.current = cmd
+ },
+ [cmdRef]
+ )
+
+ const [activeHostPublicKey, setActiveHostPublicKey] = useState()
+
+ const scrollToHost = useCallback((publicKey: string) => {
+ // move table to host, select via data id data-table
+ const rowEl = document.getElementById(publicKey)
+ const scrollEl = document.getElementById('scroll-hosts')
+ if (!rowEl || !scrollEl) {
+ return
+ }
+ scrollEl.scroll({
+ top: rowEl.offsetTop - 50,
+ behavior: 'smooth',
+ })
+ }, [])
+
+ const [viewMode, setViewMode] = useState('list')
+
+ const onHostMapClick = useCallback(
+ (publicKey: string, location?: [number, number]) => {
+ if (activeHostPublicKey === publicKey) {
+ setActiveHostPublicKey(undefined)
+ return
+ }
+ setActiveHostPublicKey(publicKey)
+ if (location) {
+ cmdRef.current.moveToLocation(location)
+ }
+ scrollToHost(publicKey)
+ },
+ [setActiveHostPublicKey, cmdRef, activeHostPublicKey, scrollToHost]
+ )
+
+ const onHostListClick = useCallback(
+ (publicKey: string, location?: [number, number]) => {
+ if (activeHostPublicKey === publicKey) {
+ setActiveHostPublicKey(undefined)
+ return
+ }
+ setActiveHostPublicKey(publicKey)
+ if (location) {
+ cmdRef.current.moveToLocation(location)
+ } else {
+ triggerErrorToast(
+ `Geographic location is unknown for host ${truncate(publicKey, 20)}`
+ )
+ }
+ scrollToHost(publicKey)
+ },
+ [setActiveHostPublicKey, cmdRef, activeHostPublicKey, scrollToHost]
+ )
+
+ const onHostMapHover = useCallback(
+ (publicKey: string, location?: [number, number]) => null,
+ []
+ )
+
const dataset = useDataset({
autopilotState: autopilot.state,
autopilotResponse,
@@ -92,6 +182,8 @@ function useHostsMain() {
allowlist,
blocklist,
isAllowlistActive,
+ geoHosts,
+ onHostSelect: onHostListClick,
})
const disabledCategories = useMemo(
@@ -138,7 +230,24 @@ function useHostsMain() {
[isAutopilotConfigured]
)
+ const hostsWithLocation = useMemo(
+ () => dataset?.filter((h) => h.location) as HostDataWithLocation[],
+ [dataset]
+ )
+
+ const activeHost = useMemo(
+ () => dataset?.find((d) => d.publicKey === activeHostPublicKey),
+ [dataset, activeHostPublicKey]
+ )
+
return {
+ setCmd,
+ viewMode,
+ activeHost,
+ onHostMapHover,
+ onHostMapClick,
+ setViewMode,
+ hostsWithLocation,
error,
dataState,
offset,
diff --git a/apps/renterd/contexts/hosts/status.ts b/apps/renterd/contexts/hosts/status.ts
new file mode 100644
index 000000000..1e43e49c9
--- /dev/null
+++ b/apps/renterd/contexts/hosts/status.ts
@@ -0,0 +1,32 @@
+import { colors } from '@siafoundation/design-system'
+import { HostData } from './types'
+
+export function getHostStatus(h: HostData) {
+ // red
+ if (h.activeContractsCount.gt(0) && !h.usable) {
+ return {
+ status: 'activeAndUnusable',
+ color: colors.red[600],
+ }
+ }
+ // blue
+ if (h.activeContractsCount.gt(0)) {
+ return {
+ status: 'activeAndUsable',
+ color: colors.blue[600],
+ }
+ }
+ // green
+ return {
+ status: 'potentialHost',
+ color: colors.green[600],
+ }
+}
+
+export function colorWithOpacity(hexColor: string, opacity: number) {
+ const r = parseInt(hexColor.slice(1, 3), 16)
+ const g = parseInt(hexColor.slice(3, 5), 16)
+ const b = parseInt(hexColor.slice(5, 7), 16)
+
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`
+}
diff --git a/apps/renterd/contexts/hosts/types.tsx b/apps/renterd/contexts/hosts/types.tsx
index 9750b906b..67d425b84 100644
--- a/apps/renterd/contexts/hosts/types.tsx
+++ b/apps/renterd/contexts/hosts/types.tsx
@@ -1,5 +1,6 @@
import { AutopilotHost } from '@siafoundation/react-renterd'
import BigNumber from 'bignumber.js'
+import { ContractData } from '../contracts/types'
export type HostData = {
id: string
@@ -47,7 +48,12 @@ export type HostData = {
settings?: AutopilotHost['host']['settings']
gouging: boolean
usable: boolean
- activeContracts: BigNumber
+ activeContractsCount: BigNumber
+ activeContracts: ContractData[]
+ // merged in from sia central API
+
+ location?: [number, number]
+ countryCode?: string
}
const generalColumns = [
@@ -173,3 +179,9 @@ export const columnsDefaultVisible: TableColumnId[] = [
// export const sortOptions: { id: SortField; label: string; category: string }[] =
// []
+
+export type ViewMode = 'list' | 'map'
+
+export type HostDataWithLocation = HostData & {
+ location: [number, number]
+}
diff --git a/apps/website/components/HostMap/HostItem.tsx b/apps/website/components/HostMap/HostItem.tsx
index 845e42fc9..d5b79f9d4 100644
--- a/apps/website/components/HostMap/HostItem.tsx
+++ b/apps/website/components/HostMap/HostItem.tsx
@@ -5,9 +5,9 @@ import {
TBToBytes,
Text,
Tooltip,
+ countryCodeEmoji,
} from '@siafoundation/design-system'
import { humanBytes, humanSiacoin, humanSpeed } from '@siafoundation/sia-js'
-import countryCodeEmoji from '../../lib/countryEmoji'
import { cx } from 'class-variance-authority'
import BigNumber from 'bignumber.js'
import { Host } from '../../content/geoHosts'
diff --git a/apps/website/components/HostMap/utils.ts b/apps/website/components/HostMap/utils.ts
index be01e485d..f7f539814 100644
--- a/apps/website/components/HostMap/utils.ts
+++ b/apps/website/components/HostMap/utils.ts
@@ -1,6 +1,9 @@
-import { monthsToBlocks, TBToBytes } from '@siafoundation/design-system'
+import {
+ monthsToBlocks,
+ TBToBytes,
+ countryCodeEmoji,
+} from '@siafoundation/design-system'
import { humanBytes, humanSiacoin, humanSpeed } from '@siafoundation/sia-js'
-import countryCodeEmoji from '../../lib/countryEmoji'
import BigNumber from 'bignumber.js'
import { Host } from '../../content/geoHosts'
diff --git a/libs/data-sources/src/lib/notion/articles.ts b/libs/data-sources/src/lib/notion/articles.ts
index 000584145..37cb77cfd 100644
--- a/libs/data-sources/src/lib/notion/articles.ts
+++ b/libs/data-sources/src/lib/notion/articles.ts
@@ -1,9 +1,9 @@
-import { Notion } from './notion'
+import { fetchAllPages } from './notion'
const featuredArticlesDatabaseId = '4cc7f2c2d6e94da2aacf44627efbd6be'
export async function fetchArticlesByTag(tag: string) {
- const response = await Notion.databases.query({
+ const results = await fetchAllPages('title', {
database_id: featuredArticlesDatabaseId,
sorts: [
{
@@ -19,7 +19,7 @@ export async function fetchArticlesByTag(tag: string) {
},
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- return response.results.map((page: any) => {
+ return results.map((page: any) => {
return {
title: page.properties.title.title?.[0]?.plain_text,
icon: page.properties.icon?.rich_text[0]?.plain_text || null,
diff --git a/libs/design-system/src/app/AppAuthedLayout/index.tsx b/libs/design-system/src/app/AppAuthedLayout/index.tsx
index 6e7372625..2c293ba28 100644
--- a/libs/design-system/src/app/AppAuthedLayout/index.tsx
+++ b/libs/design-system/src/app/AppAuthedLayout/index.tsx
@@ -26,6 +26,7 @@ type Props = {
isSynced: boolean
showWallet?: boolean
walletBalance?: BigNumber
+ scroll?: boolean
routes: {
login: string
home: string
@@ -56,6 +57,7 @@ export function AppAuthedLayout({
showWallet,
walletBalance,
routes,
+ scroll = true,
openSettings,
}: Props) {
const { lock, settings } = useAppSettings()
@@ -96,11 +98,21 @@ export function AppAuthedLayout({
actions={actions}
stats={stats}
/>
-
-
- {children}
+ {scroll ? (
+
+
+ {children}
+
+
+ ) : (
+
+ {children}
-
+ )}
diff --git a/libs/design-system/src/components/Table.tsx b/libs/design-system/src/components/Table.tsx
index 29236ba6f..02a590840 100644
--- a/libs/design-system/src/components/Table.tsx
+++ b/libs/design-system/src/components/Table.tsx
@@ -46,6 +46,8 @@ type Props<
pageSize: number
isLoading: boolean
emptyState?: React.ReactNode
+ focusId?: string
+ focusColor?: 'green' | 'red' | 'blue' | 'default'
}
export function Table<
@@ -66,6 +68,8 @@ export function Table<
pageSize,
isLoading,
emptyState,
+ focusId,
+ focusColor = 'default',
}: Props) {
let show = 'emptyState'
@@ -78,12 +82,16 @@ export function Table<
}
const getCellClassNames = useCallback(
- (i: number, className?: string) =>
+ (i: number, className: string | undefined, rounded?: boolean) =>
cx(
i === 0 ? 'pl-6' : 'pl-4',
i === columns.length - 1 ? 'pr-6' : 'pr-4',
- i === 0 ? 'rounded-tl-lg' : '',
- i === columns.length - 1 ? 'rounded-tr-lg' : '',
+ rounded
+ ? [
+ i === 0 ? 'rounded-tl-lg' : '',
+ i === columns.length - 1 ? 'rounded-tr-lg' : '',
+ ]
+ : '',
className
),
[columns]
@@ -99,11 +107,11 @@ export function Table<
-
+
{columns.map(
(
{ id, icon, label, tip, cellClassName, contentClassName },
@@ -114,7 +122,13 @@ export function Table<
!!toggleSort
const isSortActive = (sortField as string) === id
return (
-
+
{
@@ -162,12 +176,15 @@ export function Table<
)}
-
+
{summary && (
{columns.map(
({ id, summary, contentClassName, cellClassName }, i) => (
-
+
{summary && summary()}
@@ -180,9 +197,10 @@ export function Table<
data?.map((row) => (
@@ -198,7 +216,30 @@ export function Table<
) => (
(
+) {
+ const { settings } = useAppSettings()
+ return useGetSwr({
+ api,
+ ...args,
+ route:
+ '/hosts/list?showinactive=false&sort=download_speed&dir=desc&protocol=rhp3&page=0&limit=1000',
+ disabled: args?.disabled || !settings.siaCentral,
+ })
+}
diff --git a/libs/react-core/src/useTryUntil.ts b/libs/react-core/src/useTryUntil.ts
new file mode 100644
index 000000000..71508ac1c
--- /dev/null
+++ b/libs/react-core/src/useTryUntil.ts
@@ -0,0 +1,21 @@
+import { useState, useEffect } from 'react'
+
+export function useTryUntil(fn: () => boolean, interval = 1000) {
+ const [isTruthy, setIsTruthy] = useState(false)
+
+ useEffect(() => {
+ if (isTruthy) return // If already truthy, don't set up the interval
+
+ const intervalId = setInterval(() => {
+ if (fn()) {
+ setIsTruthy(true)
+ clearInterval(intervalId)
+ }
+ }, interval)
+
+ // Clean up interval on unmount
+ return () => clearInterval(intervalId)
+ }, [fn, isTruthy, interval])
+
+ return isTruthy
+}