Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/leaderboard/src/app/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export const Navbar = () => {
<li className="hidden sm:block">
<Button
href={TANGLE_DAPP_URL}
target="blank"
target="_blank"
rel="noopener noreferrer"
className="px-5 border lg:py-4"
>
Launch dApp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BLOCK_TIME_MS } from '@tangle-network/dapp-config/constants/tangle';
import { NetworkType } from '@tangle-network/tangle-shared-ui/graphql/graphql';
import {
executeEnvioGraphQL,
isEnvioUnavailableError,
type EnvioNetwork,
} from '@tangle-network/tangle-shared-ui/utils/executeEnvioGraphQL';
import { useQuery as _useQuery } from '@tanstack/react-query';
Expand Down Expand Up @@ -66,7 +67,16 @@ export function useIndexingProgress(network: NetworkType) {
return _useQuery({
queryKey: [INDEXING_PROGRESS_QUERY_KEY, network],
queryFn: () => fetcher(network),
refetchInterval: BLOCK_TIME_MS,
// Pause polling while the indexer is unreachable; it resumes after the
// user-triggered retry from the leaderboard's empty state.
refetchInterval: (query) => (query.state.error ? false : BLOCK_TIME_MS),
placeholderData: (prev) => prev,
retry: (failureCount, error) => {
if (isEnvioUnavailableError(error)) {
return false;
}

return failureCount < 2;
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Spinner } from '@tangle-network/icons';
import { Search } from '@tangle-network/icons/Search';
import TableStatus from '@tangle-network/tangle-shared-ui/components/tables/TableStatus';
import type { NetworkType } from '@tangle-network/tangle-shared-ui/graphql/graphql';
import { isEnvioUnavailableError } from '@tangle-network/tangle-shared-ui/utils/executeEnvioGraphQL';
import {
Input,
KeyValueWithButton,
Expand Down Expand Up @@ -256,6 +257,7 @@ export const LeaderboardTable = () => {
error,
isPending,
isFetching,
refetch,
} = useLeaderboard(
networkTab,
// Load more data when doing client-side filtering to ensure we don't miss results
Expand All @@ -269,6 +271,12 @@ export const LeaderboardTable = () => {
accountQuery,
);

const handleRetry = useCallback(() => {
refetch();
}, [refetch]);

const isIndexerUnavailable = error !== null && isEnvioUnavailableError(error);

const data = useMemo<Account[]>(() => {
if (!leaderboardData?.nodes) {
return [] as Account[];
Expand Down Expand Up @@ -414,19 +422,34 @@ export const LeaderboardTable = () => {
</div>
</div>

{isPending ? (
{error && data.length === 0 ? (
<TableStatus
className="min-h-80"
icon={<Spinner size="2xl" />}
title="Loading..."
description="Loading leaderboard data..."
icon={<CrossCircledIcon className="fill-red-500 size-8" />}
title={
isIndexerUnavailable
? 'Leaderboard data is temporarily unavailable'
: 'Error loading leaderboard data'
}
description={
isIndexerUnavailable
? `The Tangle indexer is not responding right now. Please retry in a moment. (${error.message})`
: error.message
}
buttonText="Retry"
buttonProps={{
onClick: handleRetry,
isLoading: isFetching,
isDisabled: isFetching,
variant: 'secondary',
}}
/>
) : error && data.length === 0 ? (
) : isPending ? (
<TableStatus
className="min-h-80"
icon={<CrossCircledIcon className="fill-red-500" />}
title="Error loading leaderboard data"
description="Please try again later."
icon={<Spinner size="2xl" />}
title="Loading leaderboard"
description="Fetching points and rankings from the Tangle indexer."
/>
) : data.length === 0 ? (
<TableStatus
Expand Down Expand Up @@ -466,20 +489,22 @@ export const LeaderboardTable = () => {
<Spinner size="2xl" />
</Overlay>
) : error ? (
<Overlay className="flex flex-col gap-6 justify-center">
<CrossCircledIcon className="stroke-red-500 size-12" />

<div className="space-y-2">
<Typography variant="h4" component="h3">
Error while fetching leaderboard data
</Typography>

<Typography variant="body1" component="p">
Error name: {error.name}
<Overlay className="flex flex-col gap-4 justify-center px-6 text-center">
<CrossCircledIcon className="stroke-red-500 size-10" />

<div className="space-y-1">
<Typography variant="h5" component="h3">
{isIndexerUnavailable
? 'Indexer is temporarily unavailable'
: 'Could not refresh leaderboard'}
</Typography>

<Typography variant="body1" component="p">
Error message: {error.message}
<Typography
variant="body1"
component="p"
className="!text-mono-120 dark:!text-mono-80"
>
Showing cached rankings. {error.message}
</Typography>
</div>
</Overlay>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import { NetworkType } from '@tangle-network/tangle-shared-ui/graphql/graphql';
import {
executeEnvioGraphQL,
isEnvioUnavailableError,
type EnvioNetwork,
} from '@tangle-network/tangle-shared-ui/utils/executeEnvioGraphQL';
import { useQuery } from '@tanstack/react-query';
import { LEADERBOARD_QUERY_KEY } from '../../../constants/query';
import { RoleFilterEnum } from '../constants';

// Auto-refresh interval in milliseconds (10 seconds).
const LEADERBOARD_REFETCH_INTERVAL = 10_000;

// When the indexer is offline (Cloudflare 5xx, DNS, CORS, etc.) further retries
// just stack latency on top of an already broken UI — fail fast instead so the
// table can swap the spinner for an actionable error state.
const shouldRetryEnvioQuery = (failureCount: number, error: Error): boolean => {
if (isEnvioUnavailableError(error)) {
return false;
}

return failureCount < 2;
};

const DEFAULT_EXCLUDED_ACCOUNT_IDS = [
'0x0000000000000000000000000000000000000000',
] as const;
Expand Down Expand Up @@ -468,9 +483,6 @@ const fetchAccountActivity = async (
return result.data;
};

// Auto-refresh interval in milliseconds (10 seconds)
const LEADERBOARD_REFETCH_INTERVAL = 10_000;

export function useLeaderboard(
network: NetworkType,
first: number,
Expand Down Expand Up @@ -498,7 +510,11 @@ export function useLeaderboard(
),
enabled: first > 0 && offset >= 0 && timestampSevenDaysAgo > 0,
placeholderData: (prev) => prev,
refetchInterval: LEADERBOARD_REFETCH_INTERVAL,
// Stop the poll once we know the indexer is down. It re-arms after the
// next successful fetch (e.g. via the user-triggered Retry).
refetchInterval: (query) =>
query.state.error ? false : LEADERBOARD_REFETCH_INTERVAL,
retry: shouldRetryEnvioQuery,
});
}

Expand Down Expand Up @@ -648,6 +664,7 @@ export function useRoleAccounts(
queryFn: () => fetchRoleAccounts(network, sortedRoles),
enabled: sortedRoles.length > 0,
staleTime: 30_000,
retry: shouldRetryEnvioQuery,
});
}

Expand All @@ -656,5 +673,6 @@ export function useRoleCounts(network: NetworkType) {
queryKey: ['roleCounts', network],
queryFn: () => fetchRoleCounts(network),
staleTime: 60_000,
retry: shouldRetryEnvioQuery,
});
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { StatusIndicator } from '@tangle-network/icons';
import { RefreshLineIcon, StatusIndicator } from '@tangle-network/icons';
import { useOperators } from '@tangle-network/tangle-shared-ui/data/graphql/useOperators';
import { useDelegatorCount } from '@tangle-network/tangle-shared-ui/data/graphql/useDelegator';
import { useIndexerStatus } from '@tangle-network/tangle-shared-ui/context/IndexerStatusContext';
import type { ProtocolStakingAsset } from '@tangle-network/tangle-shared-ui/data/graphql';
import {
Card,
CardVariant,
EMPTY_VALUE_PLACEHOLDER,
IconButton,
SkeletonLoader,
Typography,
} from '@tangle-network/ui-components';
import { FC, useMemo } from 'react';
import { FC, useCallback, useMemo } from 'react';
import { twMerge } from 'tailwind-merge';
import { formatUnits } from 'viem';
import { StatsItem } from './StatsItem';
Expand Down Expand Up @@ -41,13 +43,39 @@ export const ProtocolStatisticCard: FC<Props> = ({
stakingAssets,
tvlData,
}) => {
const { data: operators, isLoading: isLoadingOperators } = useOperators({
const {
data: operators,
isLoading: isLoadingOperators,
error: operatorsError,
refetch: refetchOperators,
} = useOperators({
status: 'ACTIVE',
});

// Get unique staker (delegator) count
const { data: stakerCount, isLoading: isLoadingStakers } =
useDelegatorCount();
const {
data: stakerCount,
isLoading: isLoadingStakers,
error: stakerCountError,
refetch: refetchStakerCount,
} = useDelegatorCount();

const { isHealthy, isCheckingHealth, checkHealth } = useIndexerStatus();

// The indexer feeds Stakers and (when on-chain fallback also fails) Operators.
// Surface a single muted notice rather than a row of EMPTY_VALUE_PLACEHOLDERs
// that read as "card is broken".
const isIndexerUnavailable =
!isCheckingHealth &&
(isHealthy === false ||
stakerCountError !== null ||
(operatorsError !== null && (operators?.length ?? 0) === 0));

const handleRetry = useCallback(() => {
void checkHealth();
void refetchOperators();
void refetchStakerCount();
}, [checkHealth, refetchOperators, refetchStakerCount]);

// Calculate TVL display value
const tvlDisplay = useMemo(() => {
Expand Down Expand Up @@ -101,27 +129,55 @@ export const ProtocolStatisticCard: FC<Props> = ({
)}
</div>

<div className="grid grid-cols-3 gap-6 mt-auto">
<StatsItem
label="Stakers"
result={stakerCount ?? null}
isLoading={isLoadingStakers}
displayLabelBottom
/>

<StatsItem
label="Operators"
result={operators?.length ?? null}
isLoading={isLoadingOperators}
displayLabelBottom
/>

<StatsItem
label="Assets"
result={stakingAssets.length}
isLoading={isLoading}
displayLabelBottom
/>
<div className="mt-auto flex flex-col gap-3">
{isIndexerUnavailable && (
<div className="flex items-center gap-2 text-mono-120 dark:text-mono-100">
<Typography
variant="body2"
className="text-mono-120 dark:text-mono-100"
>
Network data temporarily unavailable. Retrying automatically.
</Typography>

<IconButton
onClick={handleRetry}
tooltip="Retry"
aria-label="Retry network data"
className="!p-1"
>
<RefreshLineIcon size="md" />
</IconButton>
</div>
)}

<div className="grid grid-cols-3 gap-6">
<StatsItem
label="Stakers"
result={stakerCount ?? null}
isLoading={isLoadingStakers}
error={stakerCountError ?? undefined}
displayLabelBottom
/>

<StatsItem
label="Operators"
result={operators?.length ?? null}
isLoading={isLoadingOperators}
error={
operatorsError !== null && (operators?.length ?? 0) === 0
? operatorsError
: undefined
}
displayLabelBottom
/>

<StatsItem
label="Assets"
result={stakingAssets.length}
isLoading={isLoading}
displayLabelBottom
/>
</div>
</div>
</div>
</Card>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,14 @@ export const StatsItem: FC<StatsItemProps> = ({
<Typography
variant="h4"
fw="bold"
className={twMerge('text-mono-200 dark:text-mono-0', valueClassName)}
className={twMerge(
'text-mono-200 dark:text-mono-0',
error && 'text-mono-120 dark:text-mono-100',
valueClassName,
)}
>
{error
? error.name
? EMPTY_VALUE_PLACEHOLDER
: result !== null
? typeof result === 'number'
? addCommasToNumber(result)
Expand Down
Loading
Loading