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} + > + + + ) +} 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 (
+ {gpu.canGpuRender && ( + + )}
) 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 ( - - + {summary && ( {columns.map( ({ id, summary, contentClassName, cellClassName }, i) => ( - @@ -198,7 +216,30 @@ export function Table< ) => (
+
{ @@ -162,12 +176,15 @@ export function Table< )}
+
{summary && summary()}
@@ -180,9 +197,10 @@ export function Table< data?.map((row) => (
(
+) { + 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 +}