From 258f11c239047da6a8e46f20584b7fdf63c7605d Mon Sep 17 00:00:00 2001 From: Nicholas Barnett Date: Mon, 21 Oct 2024 16:06:25 -0500 Subject: [PATCH] feat: added signer updates --- .../BlockListWithControls.test.tsx.snap | 4 +- src/app/_components/time-filter/DateInput.tsx | 17 + .../time-filter/DatePickerInput.tsx | 141 ++++++++ .../time-filter/DatePickerRangeInput.tsx | 142 ++++++++ .../_components/time-filter/TimeFilter.tsx | 336 ++++++++++++++++++ src/app/_components/time-filter/TimeInput.tsx | 79 ++++ .../time-filter/TimeRangeInput.tsx | 100 ++++++ src/app/block/[hash]/PageClient.tsx | 44 ++- src/app/search/filters/Date.tsx | 6 +- src/app/search/filters/DateRange.tsx | 2 +- src/app/signers/AddressesStackingCard.tsx | 4 +- src/app/signers/CycleFilter.tsx | 78 ++++ src/app/signers/SignerDistribution.tsx | 2 +- src/app/signers/SignersTable.tsx | 175 +++++++-- src/app/signers/consts.ts | 11 +- src/app/signers/data/UseSignerAddresses.ts | 3 + src/app/signers/data/signer-metrics-hooks.ts | 206 +++++++++++ src/app/signers/data/useSigners.ts | 2 +- src/app/signers/skeleton.tsx | 3 + src/common/components/Section.tsx | 6 +- 20 files changed, 1319 insertions(+), 42 deletions(-) create mode 100644 src/app/_components/time-filter/DateInput.tsx create mode 100644 src/app/_components/time-filter/DatePickerInput.tsx create mode 100644 src/app/_components/time-filter/DatePickerRangeInput.tsx create mode 100644 src/app/_components/time-filter/TimeFilter.tsx create mode 100644 src/app/_components/time-filter/TimeInput.tsx create mode 100644 src/app/_components/time-filter/TimeRangeInput.tsx create mode 100644 src/app/signers/CycleFilter.tsx create mode 100644 src/app/signers/data/signer-metrics-hooks.ts 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..3d4104729 100644 --- a/src/app/block/[hash]/PageClient.tsx +++ b/src/app/block/[hash]/PageClient.tsx @@ -4,6 +4,7 @@ import { Modal, ModalContent, ModalOverlay } from '@chakra-ui/react'; import { Question, X } from '@phosphor-icons/react'; import dynamic from 'next/dynamic'; +import { useSignerMetricsBlock } from '../../../app/signers/data/signer-metrics-hooks'; import { BtcStxBlockLinks } from '../../../common/components/BtcStxBlockLinks'; import { KeyValueHorizontal } from '../../../common/components/KeyValueHorizontal'; import { Section } from '../../../common/components/Section'; @@ -38,7 +39,8 @@ 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); // TODO: Test this when the new signers endpoints are available + console.log({ signerMetricsBlock }); return ( <> {title} @@ -65,6 +67,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..9ccb7f01b 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(); 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..3793e3950 100644 --- a/src/app/signers/SignersTable.tsx +++ b/src/app/signers/SignersTable.tsx @@ -1,14 +1,14 @@ 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 React, { ReactNode, Suspense, useCallback, useMemo, useState } from 'react'; +import { CopyButton } from '../../common/components/CopyButton'; import { AddressLink } from '../../common/components/ExplorerLinks'; 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 +19,15 @@ 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'; @@ -46,7 +51,16 @@ export const SignersTableHeader = ({ headerTitle: string; isFirst: boolean; }) => ( - + ( {signersTableHeaders.map((header, i) => ( @@ -104,11 +132,17 @@ const SignerTableRow = ({ votingPowerPercentage: votingPower, stxStaked, stackers, + latency, + approved, + rejected, + missing, }: { index: number; isFirst: boolean; isLast: boolean; } & SignerRowInfo) => { + const [isSignerKeyHovered, setIsSignerKeyHovered] = useState(false); + return ( - - - {index + 1} - - - - - - {truncateMiddle(signerKey)} - {truncateMiddle(signerKey)} - + + setIsSignerKeyHovered(true)} + onMouseLeave={() => setIsSignerKeyHovered(false)} + > + + {truncateMiddle(signerKey)} + + + @@ -152,7 +196,7 @@ const SignerTableRow = ({ )} {stackers && stackers.length > NUM_OF_ADDRESSES_TO_SHOW ? ( -  +{stackers.length - NUM_OF_ADDRESSES_TO_SHOW} more +  +{stackers.length - NUM_OF_ADDRESSES_TO_SHOW} ) : null} @@ -169,6 +213,18 @@ const SignerTableRow = ({ {Number(stxStaked.toFixed(0)).toLocaleString()} + + + {formatSignerLatency(latency, missing)} + + + + + {`${formatSignerProposalMetric(approved)} / ${formatSignerProposalMetric( + rejected + )} / ${formatSignerProposalMetric(missing)}`} + + ); }; @@ -179,21 +235,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: + + + } > @@ -210,51 +293,86 @@ interface SignerRowInfo { votingPowerPercentage: number; stxStaked: number; stackers: SignersStackersData[]; + latency: number; + approved: number; + rejected: number; + missing: number; } function formatSignerRowData( singerInfo: SignerInfo, - stackers: SignersStackersData[] + 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, stxStaked: parseFloat(singerInfo.stacked_amount) / 1_000_000, stackers, + latency: signerMetrics.average_response_time_ms, // '5 ms', // // TODO: Test this when the new signers endpoints are available + approved: signerMetrics.proposals_accepted_count / totalProposals, // '78%', // // TODO: Test this when the new signers endpoints are available + rejected: signerMetrics.proposals_rejected_count / totalProposals, // '12%', // // TODO: Test this when the new signers endpoints are available + missing: signerMetrics.proposals_missed_count / totalProposals, // '5%', // // TODO: Test this when the new signers endpoints are available }; } 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] + ); + + // TODO: supposedly the new signer endpoint will return all the data that I need so that I don't need to make an additional call for each signer to get their stackers. Waiting on the new endpoint to be deployed. 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 getStackersBySignerQuery = useGetStackersBySignerQuery(); const signersStackersQueries = useMemo(() => { return { queries: signers.map(signer => { - return getQuery(currentCycleId, signer.signing_key); + return getStackersBySignerQuery(parseInt(selectedCycle), signer.signing_key); }), combine: ( response: UseQueryResult, Error>[] ) => response.map(r => r.data?.results ?? []), }; - }, [signers, getQuery, currentCycleId]); + }, [signers, getStackersBySignerQuery, selectedCycle]); const signersStackers = useQueries(signersStackersQueries, queryClient); + + const getSignerMetricsBySignerQuery = useGetSignerMetricsBySignerQuery(); + const signersMetricsQueries = useMemo(() => { + return { + queries: signers.map(signer => { + return getSignerMetricsBySignerQuery(parseInt(selectedCycle), signer.signing_key); + }), + combine: (response: UseQueryResult[]) => + response.map(r => r.data ?? ({} as SignerMetricsSignerForCycle)), + }; + }, [signers, getSignerMetricsBySignerQuery, selectedCycle]); + const signersMetrics = useQueries(signersMetricsQueries, queryClient); + const signersData = useMemo( () => signers .map((signer, index) => { return { - ...formatSignerRowData(signer, signersStackers[index]), + ...formatSignerRowData(signer, signersStackers[index], signersMetrics[index]), }; }) .sort((a, b) => @@ -262,7 +380,7 @@ const SignersTableBase = () => { ? b.votingPowerPercentage - a.votingPowerPercentage : a.votingPowerPercentage - b.votingPowerPercentage ), - [signers, signersStackers, votingPowerSortOrder] + [signers, signersStackers, signersMetrics, votingPowerSortOrder] ); return ( @@ -280,6 +398,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_METRICS_STATUS_QUERY_KEY, signerKey], + queryFn: () => + fetch(`${isProdReady ? activeNetworkUrl : isMainnet ? stgUrl : devUrl}`).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, mode } = useGlobalContext().activeNetwork; + const isMainnet = mode === 'mainnet'; + + return useInfiniteQuery>({ + queryKey: [SIGNER_METRICS_SIGNERS_FOR_CYCLE_QUERY_KEY, cycleId], + queryFn: ({ pageParam }: { pageParam: number }) => + fetchSignersForCycle( + isProdReady ? activeNetworkUrl : isMainnet ? stgUrl : devUrl, + cycleId, + pageParam, + options + ), + getNextPageParam, + initialPageParam: 0, + staleTime: TWO_MINUTES, + enabled: !!cycleId, + ...options, + }); +} + +export function useSignerMetricsSignerForCycle(cycleId: number, signerKey: string) { + const { url: activeNetworkUrl, mode } = useGlobalContext().activeNetwork; + const isMainnet = mode === 'mainnet'; + + return useSuspenseQuery({ + queryKey: [SIGNER_METRICS_SIGNER_FOR_CYCLE_QUERY_KEY, cycleId, signerKey], + queryFn: () => + fetch( + `${ + isProdReady ? activeNetworkUrl : isMainnet ? stgUrl : devUrl + }/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, mode } = useGlobalContext().activeNetwork; + const isMainnet = mode === 'mainnet'; + + return useInfiniteQuery>({ + queryKey: [SIGNER_METRICS_BLOCKS_QUERY_KEY], + queryFn: ({ pageParam }: { pageParam: number }) => + fetchBlocks(isProdReady ? activeNetworkUrl : isMainnet ? stgUrl : devUrl, pageParam, options), + getNextPageParam, + initialPageParam: 0, + staleTime: TWO_MINUTES, + ...options, + }); +} + +export function useSignerMetricsBlock(blockHash: string) { + const { url: activeNetworkUrl, mode } = useGlobalContext().activeNetwork; + const isMainnet = mode === 'mainnet'; + + return useSuspenseQuery({ + queryKey: [SIGNER_METRICS_BLOCK_QUERY_KEY, blockHash], + queryFn: () => + fetch( + `${ + isProdReady ? activeNetworkUrl : isMainnet ? stgUrl : devUrl + }/signer-metrics/v1/blocks/${blockHash}` + ).then(res => res.json()), + }); +} + +export function useGetSignerMetricsBySignerQuery() { + const { url: activeNetworkUrl, mode } = useGlobalContext().activeNetwork; + const isMainnet = mode === 'mainnet'; + + return (cycleId: number, signerKey: string) => ({ + queryKey: [SIGNER_METRICS_SIGNER_FOR_CYCLE_QUERY_KEY, cycleId, signerKey], + queryFn: () => + fetch( + `${ + isProdReady ? activeNetworkUrl : isMainnet ? stgUrl : devUrl + }/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..547785c23 100644 --- a/src/app/signers/data/useSigners.ts +++ b/src/app/signers/data/useSigners.ts @@ -11,7 +11,7 @@ export interface SignerInfo { weight_percent: number; stacked_amount_percent: number; } -export function useSuspensePoxSigners(cycleId: number) { +export function useSuspensePoxSigners(cycleId: string) { const { url: activeNetworkUrl } = useGlobalContext().activeNetwork; return useSuspenseQuery>({ 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} ) : (