Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/proposer duties #226

Merged
merged 13 commits into from
Nov 2, 2023
15 changes: 9 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react'
import React, { Suspense } from 'react'
import { useRecoilValue } from 'recoil'
import { appView } from './recoil/atoms'
import { AppView } from './constants/enums'
Expand All @@ -11,6 +11,7 @@ import 'rodal/lib/rodal.css'
import SSELogProvider from './components/SSELogProvider/SSELogProvider'
import SyncPollingWrapper from './wrappers/SyncPollingWrapper'
import ChangeScreen from './views/ChangeScreen'
import AppLoadFallback from './components/Fallback/AppLoadFallback'

function App() {
const view = useRecoilValue(appView)
Expand All @@ -19,11 +20,13 @@ function App() {
switch (view) {
case AppView.DASHBOARD:
return (
<SyncPollingWrapper>
<SSELogProvider>
<Dashboard />
</SSELogProvider>
</SyncPollingWrapper>
<Suspense fallback={<AppLoadFallback />}>
<SyncPollingWrapper>
<SSELogProvider>
<Dashboard />
</SSELogProvider>
</SyncPollingWrapper>
</Suspense>
)
case AppView.ONBOARD:
return <Onboard />
Expand Down
12 changes: 10 additions & 2 deletions src/components/DiagnosticTable/AlertInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ import sortAlertMessagesBySeverity from '../../utilities/sortAlerts'
import { StatusColor } from '../../types'
import AlertFilterSettings, { FilterValue } from '../AlertFilterSettings/AlertFilterSettings'
import useMediaQuery from '../../hooks/useMediaQuery'
import { useRecoilValue } from 'recoil'
import ProposerAlerts from '../ProposerAlerts/ProposerAlerts'
import { proposerDuties } from '../../recoil/atoms'

const AlertInfo = () => {
const { t } = useTranslation()
const { alerts, dismissAlert, resetDismissed } = useDiagnosticAlerts()
const { ref, dimensions } = useDivDimensions()
const headerDimensions = useDivDimensions()
const [filter, setFilter] = useState('all')
const duties = useRecoilValue(proposerDuties)

const setFilterValue = (value: FilterValue) => setFilter(value)
const isMobile = useMediaQuery('(max-width: 425px)')
Expand All @@ -29,7 +33,10 @@ const AlertInfo = () => {
return sortAlertMessagesBySeverity(baseAlerts)
}, [alerts, filter])

const isFiller = formattedAlerts.length < 6
const isFiller = formattedAlerts.length + (duties?.length || 0) < 6
const isAlerts = formattedAlerts.length > 0 || duties?.length > 0
const isProposerAlerts =
duties?.length > 0 && (filter === 'all' || filter === StatusColor.SUCCESS)

useEffect(() => {
const intervalId = setInterval(() => {
Expand Down Expand Up @@ -61,7 +68,7 @@ const AlertInfo = () => {
}
className='h-full w-full flex flex-col'
>
{formattedAlerts.length > 0 && (
{isAlerts && (
<div className={`overflow-scroll scrollbar-hide ${!isFiller ? 'flex-1' : ''}`}>
{formattedAlerts.map((alert) => {
const { severity, subText, message, id } = alert
Expand All @@ -78,6 +85,7 @@ const AlertInfo = () => {
/>
)
})}
{isProposerAlerts && <ProposerAlerts duties={duties} />}
</div>
)}
{isFiller && (
Expand Down
14 changes: 14 additions & 0 deletions src/components/Fallback/AppLoadFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import LoadingSpinner from '../LoadingSpinner/LoadingSpinner'

const AppLoadFallback = () => {
return (
<div className='relative w-screen h-screen bg-gradient-to-r from-primary to-tertiary'>
<div className='absolute top-0 left-0 w-full h-full bg-cover bg-lighthouse' />
<div className='absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2'>
<LoadingSpinner />
</div>
</div>
)
}

export default AppLoadFallback
76 changes: 76 additions & 0 deletions src/components/ProposerAlerts/AlertGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ProposerDuty, StatusColor } from '../../types'
import { FC, useState } from 'react'
import StatusBar from '../StatusBar/StatusBar'
import Typography from '../Typography/Typography'
import ProposalAlert from './ProposalAlert'
import getSlotTimeData from '../../utilities/getSlotTimeData'
import { useTranslation } from 'react-i18next'

export interface AlertGroupProps {
duties: ProposerDuty[]
onClick: (ids: string[]) => void
genesis: number
secondsPerSlot: number
}

const AlertGroup: FC<AlertGroupProps> = ({ duties, genesis, secondsPerSlot, onClick }) => {
const { t } = useTranslation()
const indices = duties.map(({ validator_index }) => validator_index)
const uuids = duties.map(({ uuid }) => uuid)
const isFullGroup = duties.length > 1
const [isExpand, toggleGroup] = useState(false)

const sortedDutiesBySlot = [...duties].sort((a, b) => Number(b.slot) - Number(a.slot))
const latestDuty = sortedDutiesBySlot[0]
const latestDutyTime = getSlotTimeData(Number(latestDuty.slot), genesis, secondsPerSlot)

const toggle = () => toggleGroup(!isExpand)
const removeGroup = () => onClick(uuids)

const renderMappedDuties = () =>
duties?.map((duty, index) => {
const { isFuture, shortHand } = getSlotTimeData(Number(duty.slot), genesis, secondsPerSlot)

return (
<ProposalAlert
onDelete={!isFullGroup ? removeGroup : undefined}
isFuture={isFuture}
key={index}
duty={duty}
time={shortHand}
/>
)
})

return (
<>
{isFullGroup ? (
<>
<div className='w-full @1540:h-22 group border-b-style500 flex justify-between items-center space-x-2 @1540:space-x-4 p-2'>
<StatusBar count={3} status={StatusColor.SUCCESS} />
<div onClick={toggle} className='cursor-pointer w-full max-w-tiny @1540:max-w-full'>
<Typography type='text-caption2'>
{t(
`alertMessages.groupedProposers.${latestDutyTime.isFuture ? 'future' : 'past'}`,
{ count: duties?.length, indices: indices.join(', ') },
)}
</Typography>
<Typography color='text-primary' darkMode='text-primary' type='text-caption2'>
{isExpand ? t('collapseInfo') : t('expandInfo')}
</Typography>
</div>
<i
onClick={removeGroup}
className='bi-trash-fill cursor-pointer opacity-0 group-hover:opacity-100 text-dark200 dark:text-dark300'
/>
</div>
{isExpand && renderMappedDuties()}
</>
) : (
renderMappedDuties()
)}
</>
)
}

export default AlertGroup
41 changes: 41 additions & 0 deletions src/components/ProposerAlerts/ProposalAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ProposerDuty, StatusColor } from '../../types'
import { FC } from 'react'
import StatusBar from '../StatusBar/StatusBar'
import Typography from '../Typography/Typography'
import { Trans } from 'react-i18next'

export interface ProposalAlertProps {
duty: ProposerDuty
time: string
isFuture?: boolean
onDelete?: (uuid: string[]) => void
}

const ProposalAlert: FC<ProposalAlertProps> = ({ duty, time, isFuture, onDelete }) => {
const { validator_index, slot, uuid } = duty

const removeAlert = () => onDelete?.([uuid])
return (
<div className='w-full @1540:h-22 group border-b-style500 flex justify-between items-center space-x-2 @1540:space-x-4 p-2'>
<StatusBar count={3} status={StatusColor.SUCCESS} />
<div className='w-full max-w-tiny @1540:max-w-full'>
<Typography type='text-caption2'>
<Trans
i18nKey={`alertMessages.proposerAlert.${isFuture ? 'future' : 'past'}`}
values={{ validator_index, slot, time }}
>
<span className='font-bold underline' />
</Trans>
</Typography>
</div>
{onDelete && (
<i
onClick={removeAlert}
className='bi-trash-fill cursor-pointer opacity-0 group-hover:opacity-100 text-dark200 dark:text-dark300'
/>
)}
</div>
)
}

export default ProposalAlert
58 changes: 58 additions & 0 deletions src/components/ProposerAlerts/ProposerAlerts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { FC } from 'react'
import { ProposerDuty } from '../../types'
import groupArray from '../../utilities/groupArray'
import AlertGroup from './AlertGroup'
import ProposalAlert from './ProposalAlert'
import { useRecoilValue, useSetRecoilState } from 'recoil'
import { selectGenesisBlock } from '../../recoil/selectors/selectGenesisBlock'
import { selectBnSpec } from '../../recoil/selectors/selectBnSpec'
import { proposerDuties } from '../../recoil/atoms'
import getSlotTimeData from '../../utilities/getSlotTimeData'

export interface ProposerAlertsProps {
duties: ProposerDuty[]
}

const ProposerAlerts: FC<ProposerAlertsProps> = ({ duties }) => {
const { SECONDS_PER_SLOT } = useRecoilValue(selectBnSpec)
const genesis = useRecoilValue(selectGenesisBlock) as number
const setProposers = useSetRecoilState(proposerDuties)
const groups = groupArray(duties, 10)

const removeAlert = (uuids: string[]) => {
setProposers((prev) => prev.filter(({ uuid }) => !uuids.includes(uuid)))
}

return (
<>
{duties.length >= 10
? groups.map((group, index) => (
<AlertGroup
onClick={removeAlert}
genesis={genesis}
secondsPerSlot={SECONDS_PER_SLOT}
duties={group}
key={index}
/>
))
: duties.map((duty, index) => {
const { isFuture, shortHand } = getSlotTimeData(
Number(duty.slot),
genesis,
SECONDS_PER_SLOT,
)
return (
<ProposalAlert
onDelete={removeAlert}
isFuture={isFuture}
time={shortHand}
key={index}
duty={duty}
/>
)
})}
</>
)
}

export default ProposerAlerts
2 changes: 0 additions & 2 deletions src/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,7 @@ export const CLIENT_PROVIDERS = [
] as ClientProvider[]

export const initialEthDeposit = 32
export const secondsInSlot = 12
export const slotsInEpoc = 32
export const secondsInEpoch = secondsInSlot * 32
export const secondsInHour = 3600
export const secondsInDay = 86400
export const secondsInWeek = 604800
Expand Down
28 changes: 22 additions & 6 deletions src/hooks/__tests__/useEpochAprEstimate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ import {
mockShortValidatorCache,
mockedRecentWithdrawalCash,
} from '../../mocks/validatorResults'
import { mockBeaconSpec } from '../../mocks/beaconSpec'
import useFilteredValidatorCacheData from '../useFilteredValidatorCacheData'

jest.mock('../useFilteredValidatorCacheData', () => jest.fn())

const mockedUseFilteredValidatorCacheData = useFilteredValidatorCacheData as jest.MockedFn<
typeof useFilteredValidatorCacheData
>

jest.mock('ethers/lib/utils', () => ({
formatUnits: jest.fn(),
Expand All @@ -21,22 +29,26 @@ describe('useEpochAprEstimate hook', () => {
mockedFormatUnits.mockImplementation((value) => value.toString())
})
it('should return default values', () => {
mockedRecoilValue.mockReturnValue(mockBeaconSpec)
mockedUseFilteredValidatorCacheData.mockReturnValue(undefined)
const { result } = renderHook(() => useEpochAprEstimate())
expect(result.current).toStrictEqual({
estimatedApr: undefined,
textColor: 'text-dark500',
})
})
it('should return default values when not enough epoch data', () => {
mockedRecoilValue.mockReturnValue(mockShortValidatorCache)
mockedRecoilValue.mockReturnValue(mockBeaconSpec)
mockedUseFilteredValidatorCacheData.mockReturnValue(mockShortValidatorCache)
const { result } = renderHook(() => useEpochAprEstimate())
expect(result.current).toStrictEqual({
estimatedApr: undefined,
textColor: 'text-dark500',
})
})
it('should return correct values', () => {
mockedRecoilValue.mockReturnValue(mockValidatorCache)
mockedRecoilValue.mockReturnValue(mockBeaconSpec)
mockedUseFilteredValidatorCacheData.mockReturnValue(mockValidatorCache)
const { result } = renderHook(() => useEpochAprEstimate())
expect(result.current).toStrictEqual({
estimatedApr: 1.3438636363304557,
Expand All @@ -45,7 +57,8 @@ describe('useEpochAprEstimate hook', () => {
})

it('should return correct values when provided an array of indexes', () => {
mockedRecoilValue.mockReturnValue(mockValidatorCache)
mockedRecoilValue.mockReturnValue(mockBeaconSpec)
mockedUseFilteredValidatorCacheData.mockReturnValue(mockValidatorCache)
const { result } = renderHook(() => useEpochAprEstimate(['1234567']))
expect(result.current).toStrictEqual({
estimatedApr: 1.3438636363304557,
Expand All @@ -54,7 +67,8 @@ describe('useEpochAprEstimate hook', () => {
})

it('should return correct when there is a withdrawal value', () => {
mockedRecoilValue.mockReturnValue(mockedWithdrawalCash)
mockedRecoilValue.mockReturnValue(mockBeaconSpec)
mockedUseFilteredValidatorCacheData.mockReturnValue(mockedWithdrawalCash)
const { result } = renderHook(() => useEpochAprEstimate())
expect(result.current).toStrictEqual({
estimatedApr: 3.8495973450145105,
Expand All @@ -63,7 +77,8 @@ describe('useEpochAprEstimate hook', () => {
})

it('should return correct when there is a withdrawal values at a loss', () => {
mockedRecoilValue.mockReturnValue(mockedWithdrawalCashLoss)
mockedRecoilValue.mockReturnValue(mockBeaconSpec)
mockedUseFilteredValidatorCacheData.mockReturnValue(mockedWithdrawalCashLoss)
const { result } = renderHook(() => useEpochAprEstimate())
expect(result.current).toStrictEqual({
estimatedApr: -0.1710932155095768,
Expand All @@ -72,7 +87,8 @@ describe('useEpochAprEstimate hook', () => {
})

it('should return correct values when last epoch was a withdrawal', () => {
mockedRecoilValue.mockReturnValue(mockedRecentWithdrawalCash)
mockedRecoilValue.mockReturnValue(mockBeaconSpec)
mockedUseFilteredValidatorCacheData.mockReturnValue(mockedRecentWithdrawalCash)
const { result } = renderHook(() => useEpochAprEstimate())
expect(result.current).toStrictEqual({
estimatedApr: 0,
Expand Down
Loading