diff --git a/src/app/_components/BlockList/LayoutA/__tests__/__snapshots__/BlockListWithControls.test.tsx.snap b/src/app/_components/BlockList/LayoutA/__tests__/__snapshots__/BlockListWithControls.test.tsx.snap index a62ea4a0f..0de5495f7 100644 --- a/src/app/_components/BlockList/LayoutA/__tests__/__snapshots__/BlockListWithControls.test.tsx.snap +++ b/src/app/_components/BlockList/LayoutA/__tests__/__snapshots__/BlockListWithControls.test.tsx.snap @@ -6,10 +6,10 @@ exports[`BlockListWithControls renders correctly 1`] = ` class="css-16e8ooo" >
Recent Blocks diff --git a/src/app/_components/time-filter/DateInput.tsx b/src/app/_components/time-filter/DateInput.tsx new file mode 100644 index 000000000..990cb49e6 --- /dev/null +++ b/src/app/_components/time-filter/DateInput.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { forwardRef } from '@chakra-ui/react'; + +import { Input } from '../../../ui/Input'; + +export const DateInput = forwardRef((props, ref) => ( + +)); diff --git a/src/app/_components/time-filter/DatePickerInput.tsx b/src/app/_components/time-filter/DatePickerInput.tsx new file mode 100644 index 000000000..cfb6e4daa --- /dev/null +++ b/src/app/_components/time-filter/DatePickerInput.tsx @@ -0,0 +1,141 @@ +import { FormLabel } from '@/ui/FormLabel'; +import { UTCDate } from '@date-fns/utc'; +import { Field, FieldProps, Form, Formik } from 'formik'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; + +import { Box } from '../../../ui/Box'; +import { Button } from '../../../ui/Button'; +import { FormControl } from '../../../ui/FormControl'; +import { Stack } from '../../../ui/Stack'; +import { DateInput } from './DateInput'; + +type DateValue = number | undefined; + +export interface DatePickerValues { + date: DateValue; +} + +export interface DatePickerFormProps { + initialDate: DateValue; + onSubmit: (values: DatePickerValues) => void; + placeholder?: string; + label?: string; + key?: string; +} + +// TODO: move this to the search +// function searchAfterDatePickerOnSubmitHandler({ +// searchParams, +// router, +// onClose, +// }: { +// searchParams: URLSearchParams; +// router: ReturnType; +// onClose: () => void; +// }) { +// return ({ date: startTime }: DatePickerFormValues) => { +// const params = new URLSearchParams(searchParams); +// const startTimeTs = startTime ? Math.floor(startTime).toString() : undefined; +// params.delete('endTime'); +// if (startTimeTs) { +// params.set('startTime', startTimeTs); +// } else { +// params.delete('startTime'); +// } +// router.push(`?${params.toString()}`, { scroll: false }); +// onClose(); +// }; +// } + +// TODO: move this to the search +// function searchBeforeDatePickerOnSubmitHandler({ +// searchParams, +// router, +// onClose, +// }: { +// searchParams: URLSearchParams; +// router: ReturnType; +// onClose: () => void; +// }) { +// return ({ date: endTime }: DatePickerFormValues) => { +// const params = new URLSearchParams(searchParams); +// const endTimeTs = endTime ? Math.floor(endTime).toString() : undefined; +// params.delete('startTime'); +// if (endTimeTs) { +// params.set('endTime', endTimeTs); +// } else { +// params.delete('endTime'); +// } +// router.push(`?${params.toString()}`, { scroll: false }); +// onClose(); +// }; +// } + +export function DatePickerInput({ + initialDate, + label = 'Date:', + onSubmit, + placeholder = 'YYYY-MM-DD', + key, +}: DatePickerFormProps) { + const initialValues: DatePickerValues = { + date: initialDate, + }; + return ( + { + onSubmit({ date }); + }} + key={key} + > + {() => ( +
+ + + {({ field, form }: FieldProps) => ( + + {label} + } + selected={form.values.date ? new UTCDate(form.values.date * 1000) : undefined} + onChange={date => { + if (date) { + const utcDate = new UTCDate( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + 0, + 0, + 0 + ); + form.setFieldValue('date', utcDate.getTime() / 1000); + } + }} + dateFormat="yyyy-MM-dd" + /> + + )} + + + + + +
+ )} +
+ ); +} diff --git a/src/app/_components/time-filter/DatePickerRangeInput.tsx b/src/app/_components/time-filter/DatePickerRangeInput.tsx new file mode 100644 index 000000000..07e993990 --- /dev/null +++ b/src/app/_components/time-filter/DatePickerRangeInput.tsx @@ -0,0 +1,142 @@ +import { UTCDate } from '@date-fns/utc'; +import { Field, FieldProps, Form, Formik } from 'formik'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; + +import { Box } from '../../../ui/Box'; +import { Button } from '../../../ui/Button'; +import { FormControl } from '../../../ui/FormControl'; +import { FormLabel } from '../../../ui/FormLabel'; +import { Stack } from '../../../ui/Stack'; +import { DateInput } from './DateInput'; + +type DateValue = number | undefined; + +export interface DatePickerRangeInputState { + startDate: DateValue; + endDate: DateValue; +} + +interface DatePickerRangeInputProps { + onSubmit: (values: DatePickerRangeInputState) => void; + initialStartDate?: DateValue; + initialEndDate?: DateValue; + label?: string; + key?: string; +} + +// // TODO: move this to the search +// function searchDatePickerRangeFormOnSubmitHandler({ +// searchParams, +// router, +// onClose, +// }: { +// searchParams: URLSearchParams; +// router: ReturnType; +// onClose: () => void; +// }) { +// return ({ startTime, endTime }: DateRangeFormValues) => { +// const params = new URLSearchParams(searchParams); +// const startTimeTs = startTime ? Math.floor(startTime).toString() : undefined; +// const endTimeTs = endTime ? Math.floor(endTime).toString() : undefined; +// if (startTimeTs) { +// params.set('startTime', startTimeTs); +// } else { +// params.delete('startTime'); +// } +// if (endTimeTs) { +// params.set('endTime', endTimeTs); +// } else { +// params.delete('endTime'); +// } +// router.push(`?${params.toString()}`, { scroll: false }); +// onClose(); +// }; +// } + +export function DatePickerRangeInput({ + initialStartDate, + initialEndDate, + onSubmit, + label = 'Between:', + key, +}: DatePickerRangeInputProps) { + const initialValues: DatePickerRangeInputState = { + startDate: initialStartDate, + endDate: initialEndDate, + }; + return ( + { + onSubmit({ startDate, endDate }); + }} + key={key} + > + {() => ( +
+ + + {({ form }: FieldProps) => ( + + {label} + } + onChange={dateRange => { + const [startDate, endDate] = dateRange; + const utcStart = startDate + ? new UTCDate( + startDate.getUTCFullYear(), + startDate.getUTCMonth(), + startDate.getUTCDate(), + 0, + 0, + 0 + ).getTime() / 1000 + : null; + const utcEnd = endDate + ? new UTCDate( + endDate.getUTCFullYear(), + endDate.getUTCMonth(), + endDate.getUTCDate(), + 23, + 59, + 59 + ).getTime() / 1000 + : null; + form.setFieldValue('endTime', utcEnd); + form.setFieldValue('startTime', utcStart); + }} + startDate={ + form.values.startDate ? new UTCDate(form.values.startDate * 1000) : undefined + } + endDate={ + form.values.endDate ? new UTCDate(form.values.endDate * 1000) : undefined + } + dateFormat="yyyy-MM-dd" + /> + + )} + + + + + +
+ )} +
+ ); +} diff --git a/src/app/_components/time-filter/TimeFilter.tsx b/src/app/_components/time-filter/TimeFilter.tsx new file mode 100644 index 000000000..7df45dfe2 --- /dev/null +++ b/src/app/_components/time-filter/TimeFilter.tsx @@ -0,0 +1,336 @@ +import { Stack } from '@/ui/Stack'; +import { useColorModeValue } from '@chakra-ui/react'; +import { CaretDown } from '@phosphor-icons/react'; +import { ReactNode, useEffect, useMemo, useState } from 'react'; + +import { Badge } from '../../../common/components/Badge'; +import { Button } from '../../../ui/Button'; +import { Flex } from '../../../ui/Flex'; +import { Icon } from '../../../ui/Icon'; +import { Popover } from '../../../ui/Popover'; +import { PopoverContent } from '../../../ui/PopoverContent'; +import { PopoverTrigger } from '../../../ui/PopoverTrigger'; +import { Text } from '../../../ui/Text'; +import { useDisclosure } from '../../../ui/hooks/useDisclosure'; +import { DatePickerInput, DatePickerValues } from './DatePickerInput'; +import { DatePickerRangeInput, DatePickerRangeInputState } from './DatePickerRangeInput'; +import { TimeInput, TimeInputState } from './TimeInput'; +import { TimeRangeInput, TimeRangeInputState } from './TimeRangeInput'; + +const cyclefilterToFormattedValueMap: Record string> = { + endTime: (value: string) => value, + startTime: (value: string) => value, +}; + +// endTime: formatTimestamp, +// startTime: formatTimestamp, + +type TimeFilterType = 'range' | 'before' | 'after' | 'on' | null; + +type filterToFormattedValueMap = {}; + +function FilterTypeButton({ + // TODO: move to separate file + isSelected, + setSelected, + children, +}: { + isSelected?: boolean; + setSelected?: () => void; + children: ReactNode; +}) { + const purpleBadgeColor = useColorModeValue('purple.600', 'purple.300'); + const purpleBadgeBg = useColorModeValue('purple.100', 'purple.900'); + const badgeBorder = useColorModeValue('purple.300', 'purple.700'); + + return ( + + {children} + + ); +} + +interface DateFilterProps { + defaultStartTime?: string; + defaultEndTime?: string; + defaultOnTime?: string; + hasRange?: boolean; + hasBefore?: boolean; + hasAfter?: boolean; + hasOn?: boolean; + formatOn?: (value: string) => string; + formatBefore?: (value: string) => string; + formatAfter?: (value: string) => string; + datePickerRangeOnSubmit?: (values: DatePickerRangeInputState) => void; + dateInputRangeOnSubmit?: (values: TimeRangeInputState) => void; + datePickerOnSubmit?: (values: DatePickerValues) => void; + timeInputOnSubmitHandler?: (values: TimeInputState) => void; + beforeOnSubmit?: (values: TimeInputState) => void; + afterOnSubmit?: (values: TimeInputState) => void; + onOnSubmit?: (values: TimeInputState) => void; + rangeOnSubmit?: (values: TimeRangeInputState) => void; + formType: TimeFilterFormType; + defaultButtonText?: string; +} + +type TimeFilterFormType = 'date-picker' | 'input'; + +export function TimeFilter({ + defaultStartTime, + defaultEndTime, + defaultOnTime, + hasRange = true, + hasBefore = true, + hasAfter = true, + hasOn = true, + formatOn, + formatBefore, + formatAfter, + datePickerRangeOnSubmit, + dateInputRangeOnSubmit, + datePickerOnSubmit, + timeInputOnSubmitHandler, + beforeOnSubmit, + afterOnSubmit, + onOnSubmit, + rangeOnSubmit, + formType, + defaultButtonText = 'Date', +}: DateFilterProps) { + const { onOpen, onClose, isOpen } = useDisclosure(); + const defaultStartTimeNumber = isNaN(Number(defaultStartTime)) + ? undefined + : Number(defaultStartTime); + const defaultEndTimeNumber = isNaN(Number(defaultEndTime)) ? undefined : Number(defaultEndTime); + const defaultOnTimeNumber = isNaN(Number(defaultEndTime)) ? undefined : Number(defaultEndTime); + + const populatedFilter: TimeFilterType = // TODO: these props arent changin anymore. i need a new way to determine which filter is populated and waht theat value is so I can display it in the button + defaultStartTime && defaultEndTime + ? 'range' + : defaultStartTime + ? 'after' + : defaultEndTime + ? 'before' + : defaultOnTime + ? 'on' + : null; + + const buttonText = !populatedFilter + ? defaultButtonText + : populatedFilter === 'range' + ? 'Range:' + : populatedFilter === 'before' + ? 'Before:' + : populatedFilter === 'after' + ? 'After:' + : 'Date'; + + const firstFilterType = hasRange + ? 'range' + : hasBefore + ? 'before' + : hasAfter + ? 'after' + : hasOn + ? 'on' + : null; + + if (!firstFilterType) { + // Should never be thrown + throw new Error('No filter type found'); + } + + const [selectedFilterType, setSelectedFilterType] = useState( + populatedFilter || firstFilterType + ); + + useEffect(() => { + setSelectedFilterType(populatedFilter || firstFilterType); + }, [populatedFilter, firstFilterType]); + + const datePickerRangeOnSubmitHandler = useMemo( + () => (values: DatePickerRangeInputState) => { + datePickerRangeOnSubmit?.(values); + onClose(); + }, + [datePickerRangeOnSubmit, onClose] + ); + + const dateInputRangeOnSubmitHandler = useMemo( + () => (values: TimeRangeInputState) => { + dateInputRangeOnSubmit?.(values); + onClose(); + }, + [dateInputRangeOnSubmit, onClose] + ); + + const datePickerOnSubmitHandler = useMemo( + () => (values: DatePickerValues) => { + datePickerOnSubmit?.(values); + onClose(); + }, + [datePickerOnSubmit, onClose] + ); + + const timeInputOnSubmit = useMemo( + () => (values: TimeInputState) => { + timeInputOnSubmitHandler?.(values); + onClose(); + }, + [timeInputOnSubmitHandler, onClose] + ); + + return ( + + + + + + + + {[ + { type: 'range', label: 'Range', condition: hasRange }, + { type: 'before', label: 'Before', condition: hasBefore }, + { type: 'after', label: 'After', condition: hasAfter }, + { type: 'on', label: 'On', condition: hasOn }, + ].map( + ({ type, label, condition }) => + condition && ( + setSelectedFilterType(type as TimeFilterType)} + > + {label} + + ) + )} + + {selectedFilterType === 'range' ? ( + formType === 'date-picker' ? ( + + ) : ( + + ) + ) : selectedFilterType === 'before' ? ( + formType === 'date-picker' ? ( + + ) : ( + + ) + ) : selectedFilterType === 'after' ? ( + formType === 'date-picker' ? ( + + ) : ( + + ) + ) : selectedFilterType === 'on' ? ( + formType === 'date-picker' ? ( + + ) : ( + + ) + ) : null} + + + + ); +} + +// function getFormType( +// formType: TimeFilterFormType, +// initialDate: number, +// onSubmitHandler: (values: any) => void +// ) { +// return formType === 'date-picker' ? ( +// +// ) : ( +// +// ); +// } diff --git a/src/app/_components/time-filter/TimeInput.tsx b/src/app/_components/time-filter/TimeInput.tsx new file mode 100644 index 000000000..ada430eb3 --- /dev/null +++ b/src/app/_components/time-filter/TimeInput.tsx @@ -0,0 +1,79 @@ +import { FormLabel } from '@/ui/FormLabel'; +import { Input } from '@/ui/Input'; +import { Field, FieldProps, Form, Formik } from 'formik'; + +import { Box } from '../../../ui/Box'; +import { Button } from '../../../ui/Button'; +import { FormControl } from '../../../ui/FormControl'; +import { Stack } from '../../../ui/Stack'; + +export type Time = string; + +export interface TimeInputState { + time: Time; +} + +interface TimeInputProps { + initialTime?: Time; + onSubmit: (values: TimeInputState) => void; + placeholder?: string; + label: string; + type: 'number' | 'text'; +} + +export function TimeInput({ + initialTime = '', + label, + onSubmit, + placeholder = '', + type, +}: TimeInputProps) { + const initialValues: TimeInputState = { + time: initialTime, + }; + return ( + { + onSubmit(values); + resetForm(); + }} + > + {({ values, handleChange }) => ( +
+ + + {({ field, form }: FieldProps) => ( + + {label} + + + )} + + + + + +
+ )} +
+ ); +} diff --git a/src/app/_components/time-filter/TimeRangeInput.tsx b/src/app/_components/time-filter/TimeRangeInput.tsx new file mode 100644 index 000000000..da8fa3919 --- /dev/null +++ b/src/app/_components/time-filter/TimeRangeInput.tsx @@ -0,0 +1,100 @@ +import { FormLabel } from '@/ui/FormLabel'; +import { Input } from '@/ui/Input'; +import { Field, FieldProps, Form, Formik } from 'formik'; + +import { Box } from '../../../ui/Box'; +import { Button } from '../../../ui/Button'; +import { FormControl } from '../../../ui/FormControl'; +import { Stack } from '../../../ui/Stack'; + +type Time = number | string | undefined; + +export interface TimeRangeInputState { + startTime: Time; + endTime: Time; +} + +interface DateInputRangeFormProps { + initialStartTime: Time; + initialEndTime: Time; + onSubmit: (values: TimeRangeInputState) => void; + startPlaceholder?: string; + startLabel?: string; + endPlaceholder?: string; + endLabel?: string; + type: 'number' | 'text'; +} + +export function TimeRangeInput({ + initialStartTime, + initialEndTime, + startLabel = 'From:', + onSubmit, + startPlaceholder = '', + endPlaceholder = '', + endLabel = 'To:', + type, +}: DateInputRangeFormProps) { + const initialValues: TimeRangeInputState = { + startTime: initialStartTime, + endTime: initialEndTime, + }; + return ( + { + onSubmit({ startTime, endTime }); + }} + > + {({ values, handleChange }) => ( +
+ + + {({ field, form }: FieldProps) => ( + + {startLabel} + + + )} + + + {({ field, form }: FieldProps) => ( + + {endLabel} + + + )} + + + + + +
+ )} +
+ ); +} diff --git a/src/app/block/[hash]/PageClient.tsx b/src/app/block/[hash]/PageClient.tsx index bd197435b..bdccd01e4 100644 --- a/src/app/block/[hash]/PageClient.tsx +++ b/src/app/block/[hash]/PageClient.tsx @@ -38,7 +38,7 @@ export default function BlockPage({ params: { hash } }: any) { const { data: block } = useSuspenseBlockByHeightOrHash(hash, { refetchOnWindowFocus: true }); const title = (block && `STX Block #${block.height.toLocaleString()}`) || ''; const { isOpen, onToggle, onClose } = useDisclosure(); - + // const { data: signerMetricsBlock } = useSignerMetricsBlock(hash); return ( <> {title} @@ -65,6 +65,46 @@ export default function BlockPage({ params: { hash } }: any) { value={{block.tenure_height}} /> )} + {/* + {signerMetricsBlock?.signer_data + ? `${signerMetricsBlock?.signer_data?.average_response_time_ms / 1_000}s` + : '-'} + + } + /> + + {signerMetricsBlock?.signer_data + ? `${signerMetricsBlock?.signer_data?.accepted_weight}%` + : '-'} + + } + /> + + {signerMetricsBlock?.signer_data + ? `${signerMetricsBlock?.signer_data?.rejected_weight}%` + : '-'} + + } + /> + + {signerMetricsBlock?.signer_data + ? `${signerMetricsBlock?.signer_data?.missing_weight}%` + : '-'} + + } + /> */} {!block.canonical ? ( - + ) : null} - + ); diff --git a/src/app/search/filters/DateRange.tsx b/src/app/search/filters/DateRange.tsx index 6c0e1fdca..d41a7528e 100644 --- a/src/app/search/filters/DateRange.tsx +++ b/src/app/search/filters/DateRange.tsx @@ -64,7 +64,7 @@ export function DateRangeForm({ defaultStartTime, defaultEndTime, onClose }: Dat customInput={} onChange={dateRange => { const [startDate, endDate] = dateRange; - console.log(startDate, endDate); + // console.log(startDate, endDate); const utcStart = startDate ? new UTCDate( startDate.getUTCFullYear(), diff --git a/src/app/signers/AddressesStackingCard.tsx b/src/app/signers/AddressesStackingCard.tsx index b9d6e4fba..a0380ba8d 100644 --- a/src/app/signers/AddressesStackingCard.tsx +++ b/src/app/signers/AddressesStackingCard.tsx @@ -20,7 +20,7 @@ export function AddressesStackingCardBase() { const { data: { results: currentCycleSigners }, - } = useSuspensePoxSigners(currentCycleId); + } = useSuspensePoxSigners(currentCycleId.toString()); if (!currentCycleSigners) { throw new Error('No stacking data available'); @@ -28,7 +28,7 @@ export function AddressesStackingCardBase() { const { data: { results: previousCycleSigners }, - } = useSuspensePoxSigners(previousCycleId); + } = useSuspensePoxSigners(previousCycleId.toString()); const queryClient = useQueryClient(); const getQuery = useGetStackersBySignerQuery(); @@ -55,8 +55,14 @@ export function AddressesStackingCardBase() { }; }, [previousCycleSigners, getQuery, previousCycleId]); - const currentCycleSignersStackers = useQueries(currentCycleSignersStackersQueries, queryClient); - const previousCycleSignersStackers = useQueries(previousCycleSignersStackersQueries, queryClient); + const currentCycleSignersStackers = useQueries({ + queries: currentCycleSignersStackersQueries.queries, + combine: result => result.map(r => r.data ?? []), + }); + const previousCycleSignersStackers = useQueries({ + queries: previousCycleSignersStackersQueries.queries, + combine: result => result.map(r => r.data ?? []), + }); const numCurrentCycleStackers = currentCycleSignersStackers.length; const numPreviousCycleStackers = previousCycleSignersStackers.length; diff --git a/src/app/signers/CycleFilter.tsx b/src/app/signers/CycleFilter.tsx new file mode 100644 index 000000000..d190847b0 --- /dev/null +++ b/src/app/signers/CycleFilter.tsx @@ -0,0 +1,78 @@ +import { Flex, IconButton, Input } from '@chakra-ui/react'; +import { CaretLeft, CaretRight } from '@phosphor-icons/react'; +import { Field, Form, Formik } from 'formik'; +import { useCallback, useState } from 'react'; + +export function CycleFilter({ + onChange, + defaultCycleId, + currentCycleId, +}: { + onChange: (cycle: string) => void; + defaultCycleId: string; + currentCycleId: string; +}) { + const [cycleId, setCycleId] = useState(defaultCycleId); + + const handleCycleChange = useCallback( + (newCycleId: string) => { + setCycleId(newCycleId); + onChange(newCycleId); + }, + [setCycleId, onChange] + ); + + return ( + handleCycleChange(values.cycle)}> + {({ values, setFieldValue, submitForm }) => ( +
+ + } + onClick={() => { + const newCycleId = String(Number(values.cycle) - 1); + setFieldValue('cycle', newCycleId); + submitForm(); + }} + h={5} + w={5} + /> + + {({ field }: any) => ( + { + field.onChange(e); + }} + onKeyDown={e => { + if (e.key === 'Enter') { + submitForm(); + } + }} + w={'72px'} + h="full" + /> + )} + + } + onClick={() => { + const newCycleId = String(Number(values.cycle) + 1); + setFieldValue('cycle', newCycleId); + submitForm(); + }} + isDisabled={cycleId === currentCycleId} + h={5} + w={5} + /> + +
+ )} +
+ ); +} diff --git a/src/app/signers/SignerDistribution.tsx b/src/app/signers/SignerDistribution.tsx index 59f302e15..7d1a478ee 100644 --- a/src/app/signers/SignerDistribution.tsx +++ b/src/app/signers/SignerDistribution.tsx @@ -51,7 +51,7 @@ export function SignersDistributionBase() { const { currentCycleId } = useSuspenseCurrentStackingCycle(); const { data: { results: signers }, - } = useSuspensePoxSigners(currentCycleId); + } = useSuspensePoxSigners(currentCycleId.toString()); const [onlyShowPublicSigners, setOnlyShowPublicSigners] = useState(false); return signers.length > 0 ? ( diff --git a/src/app/signers/SignersTable.tsx b/src/app/signers/SignersTable.tsx index a8d46cf05..245da5ca0 100644 --- a/src/app/signers/SignersTable.tsx +++ b/src/app/signers/SignersTable.tsx @@ -1,14 +1,12 @@ import { useColorModeValue } from '@chakra-ui/react'; import styled from '@emotion/styled'; import { UseQueryResult, useQueries, useQueryClient } from '@tanstack/react-query'; -import React, { ReactNode, Suspense, useMemo, useState } from 'react'; +import { ReactNode, Suspense, useCallback, useMemo, useState } from 'react'; -import { AddressLink } from '../../common/components/ExplorerLinks'; +import { CopyButton } from '../../common/components/CopyButton'; import { Section } from '../../common/components/Section'; -import { ApiResponseWithResultsOffset } from '../../common/types/api'; import { truncateMiddle } from '../../common/utils/utils'; import { Flex } from '../../ui/Flex'; -import { Show } from '../../ui/Show'; import { Table } from '../../ui/Table'; import { Tbody } from '../../ui/Tbody'; import { Td } from '../../ui/Td'; @@ -19,10 +17,14 @@ import { Tr } from '../../ui/Tr'; import { ScrollableBox } from '../_components/BlockList/ScrollableDiv'; import { ExplorerErrorBoundary } from '../_components/ErrorBoundary'; import { useSuspenseCurrentStackingCycle } from '../_components/Stats/CurrentStackingCycle/useCurrentStackingCycle'; +import { CycleFilter } from './CycleFilter'; import { removeStackingDaoFromName } from './SignerDistributionLegend'; import { SortByVotingPowerFilter, VotingPowerSortOrder } from './SortByVotingPowerFilter'; import { mobileBorderCss } from './consts'; -import { SignersStackersData, useGetStackersBySignerQuery } from './data/UseSignerAddresses'; +import { + SignerMetricsSignerForCycle, + useGetSignerMetricsBySignerQuery, +} from './data/signer-metrics-hooks'; import { SignerInfo, useSuspensePoxSigners } from './data/useSigners'; import { SignersTableSkeleton } from './skeleton'; import { getSignerKeyName } from './utils'; @@ -37,8 +39,6 @@ const StyledTable = styled(Table)` } `; -const NUM_OF_ADDRESSES_TO_SHOW = 1; - export const SignersTableHeader = ({ headerTitle, isFirst, @@ -46,7 +46,16 @@ export const SignersTableHeader = ({ headerTitle: string; isFirst: boolean; }) => ( - + ( {signersTableHeaders.map((header, i) => ( @@ -101,14 +124,20 @@ const SignerTableRow = ({ isFirst, isLast, signerKey, - votingPowerPercentage: votingPower, + votingPower, stxStaked, - stackers, + numStackers, + latency, + approved, + rejected, + missing, }: { index: number; isFirst: boolean; isLast: boolean; } & SignerRowInfo) => { + const [isSignerKeyHovered, setIsSignerKeyHovered] = useState(false); + return ( - + + setIsSignerKeyHovered(true)} + onMouseLeave={() => setIsSignerKeyHovered(false)} + > + + {truncateMiddle(signerKey)} + + + + + - {index + 1} + {getEntityName(signerKey)} - - - {truncateMiddle(signerKey)} - {truncateMiddle(signerKey)} + + {numStackers} - - {getEntityName(signerKey)} + + {`${votingPower.toFixed(2)}%`} - - {stackers.slice(0, NUM_OF_ADDRESSES_TO_SHOW).map((stacker, index) => ( - - - {truncateMiddle(stacker.stacker_address, 5, 5)} - - {index < stackers.length - 1 && ( - - ,  - - )} - {stackers && stackers.length > NUM_OF_ADDRESSES_TO_SHOW ? ( - -  +{stackers.length - NUM_OF_ADDRESSES_TO_SHOW} more - - ) : null} - - ))} - + + {Number(stxStaked.toFixed(0)).toLocaleString()} + - {`${votingPower.toFixed(2)}%`} + {formatSignerLatency(latency, missing)} - {Number(stxStaked.toFixed(0)).toLocaleString()} + {`${formatSignerProposalMetric(approved)} / ${formatSignerProposalMetric( + rejected + )} / ${formatSignerProposalMetric(missing)}`} @@ -179,21 +209,48 @@ export function SignersTableLayout({ signersTableRows, votingPowerSortOrder, setVotingPowerSortOrder, + cycleFilterOnSubmitHandler, + selectedCycle, + currentCycleId, }: { numSigners: ReactNode; signersTableHeaders: ReactNode; signersTableRows: ReactNode; votingPowerSortOrder: VotingPowerSortOrder; setVotingPowerSortOrder: (order: VotingPowerSortOrder) => void; + cycleFilterOnSubmitHandler: (cycle: string) => void; + selectedCycle: string; + currentCycleId: string; }) { return (
+ + + + Cycle: + + + } > @@ -207,62 +264,82 @@ export function SignersTableLayout({ } interface SignerRowInfo { signerKey: string; - votingPowerPercentage: number; + votingPower: number; stxStaked: number; - stackers: SignersStackersData[]; + numStackers: number; + latency: number; + approved: number; + rejected: number; + missing: number; } function formatSignerRowData( singerInfo: SignerInfo, - stackers: SignersStackersData[] + signerMetrics: SignerMetricsSignerForCycle ): SignerRowInfo { + const totalProposals = + signerMetrics.proposals_accepted_count + + signerMetrics.proposals_rejected_count + + signerMetrics.proposals_missed_count; return { signerKey: singerInfo.signing_key, - votingPowerPercentage: singerInfo.weight_percent, + votingPower: singerInfo.weight_percent, stxStaked: parseFloat(singerInfo.stacked_amount) / 1_000_000, - stackers, + numStackers: singerInfo.pooled_stacker_count + singerInfo.solo_stacker_count, + latency: signerMetrics.average_response_time_ms, + approved: signerMetrics.proposals_accepted_count / totalProposals, + rejected: signerMetrics.proposals_rejected_count / totalProposals, + missing: signerMetrics.proposals_missed_count / totalProposals, }; } const SignersTableBase = () => { const [votingPowerSortOrder, setVotingPowerSortOrder] = useState(VotingPowerSortOrder.Desc); const { currentCycleId } = useSuspenseCurrentStackingCycle(); + const [selectedCycle, setSelectedCycle] = useState(currentCycleId.toString()); + + const cycleFilterOnSubmitHandler = useCallback( + (cycle: string) => { + setSelectedCycle(cycle); + }, + [setSelectedCycle] + ); const { data: { results: signers }, - } = useSuspensePoxSigners(currentCycleId); + } = useSuspensePoxSigners(selectedCycle); if (!signers) { throw new Error('Signers data is not available'); } const queryClient = useQueryClient(); - const getQuery = useGetStackersBySignerQuery(); - const signersStackersQueries = useMemo(() => { + const getSignerMetricsBySignerQuery = useGetSignerMetricsBySignerQuery(); + const signersMetricsQueries = useMemo(() => { return { queries: signers.map(signer => { - return getQuery(currentCycleId, signer.signing_key); + return getSignerMetricsBySignerQuery(parseInt(selectedCycle), signer.signing_key); }), - combine: ( - response: UseQueryResult, Error>[] - ) => response.map(r => r.data?.results ?? []), + combine: (response: UseQueryResult[]) => + response.map(r => r.data ?? ({} as SignerMetricsSignerForCycle)), }; - }, [signers, getQuery, currentCycleId]); - const signersStackers = useQueries(signersStackersQueries, queryClient); + }, [signers, getSignerMetricsBySignerQuery, selectedCycle]); + const signersMetrics = useQueries(signersMetricsQueries, queryClient); + const signersData = useMemo( () => signers .map((signer, index) => { return { - ...formatSignerRowData(signer, signersStackers[index]), + ...formatSignerRowData(signer, signersMetrics[index]), }; }) .sort((a, b) => votingPowerSortOrder === 'desc' - ? b.votingPowerPercentage - a.votingPowerPercentage - : a.votingPowerPercentage - b.votingPowerPercentage + ? b.votingPower - a.votingPower + : a.votingPower - b.votingPower ), - [signers, signersStackers, votingPowerSortOrder] + [signers, signersMetrics, votingPowerSortOrder] ); return ( @@ -280,6 +357,9 @@ const SignersTableBase = () => { isLast={i === signers.length - 1} /> ))} + cycleFilterOnSubmitHandler={cycleFilterOnSubmitHandler} + selectedCycle={selectedCycle} + currentCycleId={currentCycleId.toString()} /> ); }; diff --git a/src/app/signers/consts.ts b/src/app/signers/consts.ts index a0c7b3591..1520a79a2 100644 --- a/src/app/signers/consts.ts +++ b/src/app/signers/consts.ts @@ -89,7 +89,14 @@ export const SIGNER_KEY_MAP: Record ({ queryKey: [SIGNER_ADDRESSES_QUERY_KEY, cycleId, signerKey], queryFn: () => fetch( `${activeNetworkUrl}/extended/v2/pox/cycles/${cycleId}/signers/${signerKey}/stackers` - ).then(res => res.json()), + ).then(res => res.json()) as Promise, staleTime: TWO_MINUTES, cacheTime: 15 * 60 * 1000, refetchOnWindowFocus: false, + enabled: !!cycleId && !!signerKey, }); } export function useSuspenseSignerAddresses(cycleId: number, signerKey: string) { const { url: activeNetworkUrl } = useGlobalContext().activeNetwork; - return useSuspenseQuery({ + return useSuspenseQuery({ queryKey: [SIGNER_ADDRESSES_QUERY_KEY, cycleId, signerKey], queryFn: () => fetch(`${activeNetworkUrl}/extended/v2/pox/cycles/${cycleId}/signers/${signerKey}`).then( diff --git a/src/app/signers/data/signer-metrics-hooks.ts b/src/app/signers/data/signer-metrics-hooks.ts new file mode 100644 index 000000000..f565deb8f --- /dev/null +++ b/src/app/signers/data/signer-metrics-hooks.ts @@ -0,0 +1,180 @@ +import { TWO_MINUTES } from '@/common/queries/query-stale-time'; +import { ApiResponseWithResultsOffset } from '@/common/types/api'; +import { getNextPageParam } from '@/common/utils/utils'; +import { + InfiniteData, + UseInfiniteQueryResult, + useInfiniteQuery, + useSuspenseQuery, +} from '@tanstack/react-query'; + +import { useGlobalContext } from '../../../common/context/useGlobalContext'; + +const SIGNER_METRICS_STATUS_QUERY_KEY = 'signer-metrics-status'; +const SIGNER_METRICS_SIGNERS_FOR_CYCLE_QUERY_KEY = 'signer-metrics-signers-for-cycle'; +const SIGNER_METRICS_SIGNER_FOR_CYCLE_QUERY_KEY = 'signer-metrics-signer-for-cycle'; +const SIGNER_METRICS_BLOCKS_QUERY_KEY = 'signer-metrics-blocks'; +const SIGNER_METRICS_BLOCK_QUERY_KEY = 'signer-metrics-block'; + +export interface SignerMetricsStatus { + server_version: string; + status: string; + chain_tip: { + block_height: number; + }; +} + +export interface SignerMetricsSignerForCycle { + signer_key: string; + weight: number; + weight_percentage: number; + stacked_amount: string; + stacked_amount_percent: number; + proposals_accepted_count: number; + proposals_rejected_count: number; + proposals_missed_count: number; + average_response_time_ms: number; +} + +export interface SignerMetricsBlock { + block_height: number; + block_hash: string; + block_time: number; + index_block_hash: string; + burn_block_height: number; + tenure_height: number; + signer_data: { + cycle_number: number; + total_signer_count: number; + accepted_count: number; + rejected_count: number; + missing_count: number; + accepted_excluded_count: number; + average_response_time_ms: number; + block_proposal_time_ms: number; + accepted_stacked_amount: string; + rejected_stacked_amount: string; + missing_stacked_amount: string; + accepted_weight: number; + rejected_weight: number; + missing_weight: number; + }; +} + +export function useSignerMetricsStatus(signerKey: string) { + const { url: activeNetworkUrl } = useGlobalContext().activeNetwork; + + return useSuspenseQuery({ + queryKey: [SIGNER_METRICS_STATUS_QUERY_KEY, signerKey], + queryFn: () => fetch(`${activeNetworkUrl}`).then(res => res.json()), + }); +} + +const DEFAULT_LIST_LIMIT = 10; + +const fetchSignersForCycle = async ( + apiUrl: string, + cycleId: number, + pageParam: number, + options: any +): Promise> => { + const limit = options.limit || DEFAULT_LIST_LIMIT; + const offset = pageParam || 0; + const queryString = new URLSearchParams({ + limit: limit.toString(), + offset: offset.toString(), + }).toString(); + const response = await fetch( + `${apiUrl}/signer-metrics/v1/cycles/${cycleId}/signers${queryString ? `?${queryString}` : ''}` + ); + return response.json(); +}; + +export function useSignerMetricsSignersForCycle( + cycleId: number, + options: any = {} +): UseInfiniteQueryResult< + InfiniteData> +> { + const { url: activeNetworkUrl } = useGlobalContext().activeNetwork; + + return useInfiniteQuery>({ + queryKey: [SIGNER_METRICS_SIGNERS_FOR_CYCLE_QUERY_KEY, cycleId], + queryFn: ({ pageParam }: { pageParam: number }) => + fetchSignersForCycle(activeNetworkUrl, cycleId, pageParam, options), + getNextPageParam, + initialPageParam: 0, + staleTime: TWO_MINUTES, + enabled: !!cycleId, + ...options, + }); +} + +export function useSignerMetricsSignerForCycle(cycleId: number, signerKey: string) { + const { url: activeNetworkUrl } = useGlobalContext().activeNetwork; + + return useSuspenseQuery({ + queryKey: [SIGNER_METRICS_SIGNER_FOR_CYCLE_QUERY_KEY, cycleId, signerKey], + queryFn: () => + fetch(`${activeNetworkUrl}/signer-metrics/v1/cycles/${cycleId}/signers/${signerKey}`).then( + res => res.json() + ), + }); +} + +const fetchBlocks = async ( + apiUrl: string, + pageParam: number, + options: any +): Promise> => { + const limit = options.limit || DEFAULT_LIST_LIMIT; + const offset = pageParam || 0; + const queryString = new URLSearchParams({ + limit: limit.toString(), + offset: offset.toString(), + }).toString(); + const response = await fetch( + `${apiUrl}/signer-metrics/v1/blocks${queryString ? `?${queryString}` : ''}` + ); + return response.json(); +}; + +export function useSignerMetricsBlocks(options: any = {}) { + const { url: activeNetworkUrl } = useGlobalContext().activeNetwork; + + return useInfiniteQuery>({ + queryKey: [SIGNER_METRICS_BLOCKS_QUERY_KEY], + queryFn: ({ pageParam }: { pageParam: number }) => + fetchBlocks(activeNetworkUrl, pageParam, options), + getNextPageParam, + initialPageParam: 0, + staleTime: TWO_MINUTES, + ...options, + }); +} + +export function useSignerMetricsBlock(blockHash: string) { + const { url: activeNetworkUrl } = useGlobalContext().activeNetwork; + + return useSuspenseQuery({ + queryKey: [SIGNER_METRICS_BLOCK_QUERY_KEY, blockHash], + queryFn: () => + fetch(`${activeNetworkUrl}/signer-metrics/v1/blocks/${blockHash}`).then(res => res.json()), + }); +} + +export function useGetSignerMetricsBySignerQuery() { + const { url: activeNetworkUrl } = useGlobalContext().activeNetwork; + + return (cycleId: number, signerKey: string) => ({ + queryKey: [SIGNER_METRICS_SIGNER_FOR_CYCLE_QUERY_KEY, cycleId, signerKey], + queryFn: () => + fetch(`${activeNetworkUrl}/signer-metrics/v1/cycles/${cycleId}/signers/${signerKey}`).then( + res => res.json() + ), + staleTime: TWO_MINUTES, + cacheTime: 15 * 60 * 1000, + refetchOnWindowFocus: false, + enabled: !!cycleId && !!signerKey, + }); +} diff --git a/src/app/signers/data/useSigners.ts b/src/app/signers/data/useSigners.ts index 0a0afd0a9..5268360b2 100644 --- a/src/app/signers/data/useSigners.ts +++ b/src/app/signers/data/useSigners.ts @@ -10,15 +10,21 @@ export interface SignerInfo { stacked_amount: string; weight_percent: number; stacked_amount_percent: number; + pooled_stacker_count: number; + solo_stacker_count: number; } -export function useSuspensePoxSigners(cycleId: number) { +export function useSuspensePoxSigners(cycleId: string) { const { url: activeNetworkUrl } = useGlobalContext().activeNetwork; - return useSuspenseQuery>({ + return useSuspenseQuery< + ApiResponseWithResultsOffset, + Error, + ApiResponseWithResultsOffset + >({ queryKey: ['signers', cycleId], queryFn: () => - fetch(`${activeNetworkUrl}/extended/v2/pox/cycles/${cycleId}/signers`).then(res => - res.json() + fetch(`${activeNetworkUrl}/extended/v2/pox/cycles/${cycleId}/signers`).then( + res => res.json() as Promise> ), staleTime: TEN_MINUTES, }); diff --git a/src/app/signers/skeleton.tsx b/src/app/signers/skeleton.tsx index 08d6ef740..7b33c2029 100644 --- a/src/app/signers/skeleton.tsx +++ b/src/app/signers/skeleton.tsx @@ -68,6 +68,9 @@ export const SignersTableSkeleton = () => { ))} votingPowerSortOrder={VotingPowerSortOrder.Asc} setVotingPowerSortOrder={() => {}} + cycleFilterOnSubmitHandler={() => {}} + selectedCycle={''} + currentCycleId={''} /> ); }; diff --git a/src/common/components/Section.tsx b/src/common/components/Section.tsx index c96d65ef2..09b1d2981 100644 --- a/src/common/components/Section.tsx +++ b/src/common/components/Section.tsx @@ -26,7 +26,7 @@ export function Section({ {title || TopRight ? ( {title ? ( typeof title === 'string' ? ( - + {title} ) : (