getSortOptionLabel(cycleSortOrder)}
+ menuItems={menuItems}
+ leftIcon={SortDescending}
+ />
+ );
+}
diff --git a/src/app/signer/[signerKey]/PageClient.tsx b/src/app/signer/[signerKey]/PageClient.tsx
new file mode 100644
index 000000000..78e867542
--- /dev/null
+++ b/src/app/signer/[signerKey]/PageClient.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { useParams } from 'next/navigation';
+
+import { Grid, GridProps } from '../../../ui/Grid';
+import { Stack } from '../../../ui/Stack';
+import { PageTitle } from '../../_components/PageTitle';
+import { AssociatedAddressesTable } from './AssociatedAddressesTable';
+import { SignerKeyStats } from './SignerKeyStats';
+import { SignerKeySummary } from './SignerKeySummary';
+import { StackingHistoryTable } from './StackingHistoryTable';
+
+export function SignerKeyPageLayout(props: GridProps) {
+ return (
+
+ );
+}
+
+export default function PageClient() {
+ const params = useParams<{ signerKey: string }>();
+
+ if (!params) {
+ console.error('params is undefined. This component should receive params from its parent.');
+ return null; // or some error UI
+ }
+
+ const { signerKey } = params;
+ return (
+ <>
+ Signer key
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/signer/[signerKey]/SignerKeyStats.tsx b/src/app/signer/[signerKey]/SignerKeyStats.tsx
new file mode 100644
index 000000000..deb29a4fa
--- /dev/null
+++ b/src/app/signer/[signerKey]/SignerKeyStats.tsx
@@ -0,0 +1,48 @@
+import { ExplorerErrorBoundary } from '@/app/_components/ErrorBoundary';
+import { useSuspenseSignerStackers } from '@/app/signers/data/UseSignerAddresses';
+import { Section } from '@/common/components/Section';
+import { Box } from '@/ui/Box';
+import { Stack } from '@/ui/Stack';
+import { Caption } from '@/ui/typography';
+
+import { useSuspenseCurrentStackingCycle } from '../../_components/Stats/CurrentStackingCycle/useCurrentStackingCycle';
+import { useSuspensePoxSigner } from '../../signers/data/UseSigner';
+
+interface SignerKeyStatsProps {
+ signerKey: string;
+}
+
+function SignerKeyStatsBase({ signerKey }: SignerKeyStatsProps) {
+ const { currentCycleId } = useSuspenseCurrentStackingCycle();
+ const { data: signerData } = useSuspensePoxSigner(currentCycleId, signerKey);
+ const { data: signerStackers } = useSuspenseSignerStackers(currentCycleId, signerKey);
+
+ return (
+
+
+ Performance
+ Waiting on the API for the data
+
+
+ Total signed transactions
+ Waiting on the API for the data
+
+
+ Slot success rate
+ Waiting on the API for the data
+
+
+ Uptime
+ Waiting on the API for the data
+
+
+ );
+}
+
+export function SignerKeyStats(props: SignerKeyStatsProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/signer/[signerKey]/SignerKeySummary.tsx b/src/app/signer/[signerKey]/SignerKeySummary.tsx
new file mode 100644
index 000000000..cb7d69e36
--- /dev/null
+++ b/src/app/signer/[signerKey]/SignerKeySummary.tsx
@@ -0,0 +1,59 @@
+import { useSuspenseSignerStackers } from '@/app/signers/data/UseSignerAddresses';
+import { KeyValueHorizontal } from '@/common/components/KeyValueHorizontal';
+import { Section } from '@/common/components/Section';
+import { Value } from '@/common/components/Value';
+import { microToStacksFormatted } from '@/common/utils/utils';
+import { Box } from '@/ui/Box';
+import { Flex } from '@/ui/Flex';
+
+import { getEntityName } from '../../../app/signers/SignersTable';
+import { useSuspenseCurrentStackingCycle } from '../../_components/Stats/CurrentStackingCycle/useCurrentStackingCycle';
+import { useSuspensePoxSigner } from '../../signers/data/UseSigner';
+
+interface SignerKeySummaryProps {
+ signerKey: string;
+}
+export const SignerKeySummary = ({ signerKey }: SignerKeySummaryProps) => {
+ const { currentCycleId } = useSuspenseCurrentStackingCycle();
+ const { data: signerData } = useSuspensePoxSigner(currentCycleId, signerKey);
+ const { data: signerStackers } = useSuspenseSignerStackers(currentCycleId, signerKey);
+
+ return (
+
+
+
+ {signerKey}}
+ copyValue={signerKey}
+ />
+ {getEntityName(signerKey)}}
+ copyValue={getEntityName(signerKey)}
+ />
+ {/* {principal}}
+ copyValue={principal}
+ /> */}
+ {signerData?.weight_percent.toFixed(2)}%}
+ copyValue={signerData?.weight_percent.toFixed(2)}
+ />
+ {microToStacksFormatted(signerData?.stacked_amount)}}
+ copyValue={microToStacksFormatted(signerData?.stacked_amount)}
+ />
+ {signerStackers?.results.length}}
+ copyValue={signerStackers?.results.length.toString()}
+ />
+
+
+
+ );
+};
diff --git a/src/app/signer/[signerKey]/StackingHistoryTable.tsx b/src/app/signer/[signerKey]/StackingHistoryTable.tsx
new file mode 100644
index 000000000..36e28d309
--- /dev/null
+++ b/src/app/signer/[signerKey]/StackingHistoryTable.tsx
@@ -0,0 +1,281 @@
+import { useColorModeValue } from '@chakra-ui/react';
+import styled from '@emotion/styled';
+import { ReactNode, Suspense, useMemo, useState } from 'react';
+
+import { ScrollableBox } from '../../../app/_components/BlockList/ScrollableDiv';
+import { mobileBorderCss } from '../../../app/signers/consts';
+import { StackingHistoryInfo, useSignerStackingHistory } from '../../../app/signers/data/UseSigner';
+import { SignersTableSkeleton } from '../../../app/signers/skeleton';
+import { ListFooter } from '../../../common/components/ListFooter';
+import { Section } from '../../../common/components/Section';
+import { Flex } from '../../../ui/Flex';
+import { Table } from '../../../ui/Table';
+import { Tbody } from '../../../ui/Tbody';
+import { Td } from '../../../ui/Td';
+import { Text } from '../../../ui/Text';
+import { Th } from '../../../ui/Th';
+import { Thead } from '../../../ui/Thead';
+import { Tr } from '../../../ui/Tr';
+import { ExplorerErrorBoundary } from '../../_components/ErrorBoundary';
+import { CycleSortFilter, CycleSortOrder } from './CycleSortFilter';
+
+const StyledTable = styled(Table)`
+ th {
+ border-bottom: none;
+ }
+
+ tr:last-child td {
+ border-bottom: none;
+ }
+`;
+
+export const SignersTableHeader = ({
+ headerTitle,
+ isFirst,
+}: {
+ headerTitle: string;
+ isFirst: boolean;
+}) => (
+
+
+
+ {headerTitle}
+
+
+ |
+);
+
+export const signersTableHeaders = [
+ 'Cycle',
+ 'Voting power',
+ 'STX stacked',
+ 'Latency',
+ 'Approved',
+ 'Rejected',
+ 'Missing',
+];
+
+export const SignersTableHeaders = () => (
+
+ {signersTableHeaders.map((header, i) => (
+
+ ))}
+
+);
+
+export const SignerTableRow = ({
+ isFirst,
+ isLast,
+ cycleid,
+ votingPower,
+ stxStacked,
+ latency,
+ approved,
+ rejected,
+ missing,
+}: {
+ index: number;
+ isFirst: boolean;
+ isLast: boolean;
+} & SignerRowInfo) => {
+ return (
+
+
+
+ {cycleid}
+
+ |
+
+
+ {`${votingPower.toFixed(2)}%`}
+
+ |
+
+
+ {stxStacked.toFixed(0).toLocaleString()}
+
+ |
+
+
+ {`${latency}ms`}
+
+ |
+
+
+ {`${(approved * 100).toFixed(2)}%`}
+
+ |
+
+
+ {`${(rejected * 100).toFixed(2)}%`}
+
+ |
+
+
+ {`${(missing * 100).toFixed(2)}%`}
+
+ |
+
+ );
+};
+
+export function SignersTableLayout({
+ title,
+ topRight,
+ signersTableHeaders,
+ signersTableRows,
+ fetchNextPage,
+ hasNextPage,
+ isLoading,
+ isFetching,
+}: {
+ title: ReactNode;
+ topRight?: ReactNode;
+ signersTableHeaders: ReactNode;
+ signersTableRows: ReactNode;
+ fetchNextPage: () => void;
+ hasNextPage: boolean;
+ isLoading: boolean;
+ isFetching: boolean;
+}) {
+ return (
+
+
+
+ {signersTableHeaders}
+ {signersTableRows}
+
+
+
+
+ );
+}
+interface SignerRowInfo {
+ signerKey: string;
+ cycleid: number;
+ votingPower: number;
+ stxStacked: number;
+ latency: number;
+ approved: number;
+ rejected: number;
+ missing: number;
+}
+
+function formatSignerRowData(singerInfo: StackingHistoryInfo): SignerRowInfo {
+ const totalProposals =
+ singerInfo.proposals_accepted_count +
+ singerInfo.proposals_rejected_count +
+ singerInfo.proposals_missing_count;
+ return {
+ signerKey: singerInfo.signing_key,
+ cycleid: singerInfo.cycleid,
+ votingPower: singerInfo.weight_percent,
+ stxStacked: parseFloat(singerInfo.stacked_amount) / 1_000_000,
+ latency: singerInfo.average_response_time_ms ? singerInfo.average_response_time_ms : 50,
+ approved: singerInfo.proposals_accepted_count
+ ? singerInfo.proposals_accepted_count / totalProposals
+ : 0.85,
+ rejected: singerInfo.proposals_rejected_count
+ ? singerInfo.proposals_rejected_count / totalProposals
+ : 0.1,
+ missing: singerInfo.proposals_missing_count
+ ? singerInfo.proposals_missing_count / totalProposals
+ : 0.05,
+ };
+}
+
+const SignersTableBase = ({ signerKey }: { signerKey: string }) => {
+ const [cycleSortOrder, setCycleSortOrder] = useState(CycleSortOrder.Desc);
+
+ const { signerStackingHistory, fetchNextPage, hasNextPage, isLoading, isFetching } =
+ useSignerStackingHistory(signerKey);
+
+ const signerData = useMemo(
+ () =>
+ signerStackingHistory
+ .map((signer, index) => {
+ return {
+ ...formatSignerRowData(signer),
+ };
+ })
+ .sort((a, b) =>
+ cycleSortOrder === CycleSortOrder.Desc ? b.cycleid - a.cycleid : a.cycleid - b.cycleid
+ ),
+ [signerStackingHistory, cycleSortOrder]
+ );
+
+ return (
+
+ }
+ title={Stacking history}
+ signersTableHeaders={}
+ signersTableRows={signerData.map((cycleData, i) => (
+
+ ))}
+ fetchNextPage={fetchNextPage}
+ hasNextPage={hasNextPage}
+ isLoading={isLoading}
+ isFetching={isFetching}
+ />
+ );
+};
+
+export const StackingHistoryTable = ({ signerKey }: { signerKey: string }) => {
+ return (
+
+ }>
+
+
+
+ );
+};
diff --git a/src/app/signer/[signerKey]/page.tsx b/src/app/signer/[signerKey]/page.tsx
new file mode 100644
index 000000000..6e7f695ee
--- /dev/null
+++ b/src/app/signer/[signerKey]/page.tsx
@@ -0,0 +1,12 @@
+import dynamic from 'next/dynamic';
+
+import { Box } from '../../../ui/Box';
+
+const Page = dynamic(() => import('./PageClient'), {
+ loading: () => Loading..., // , // TODO: create a skeleton for the signer page
+ ssr: false,
+});
+
+export default async function () {
+ return ;
+}
diff --git a/src/app/signers/SignersTable.tsx b/src/app/signers/SignersTable.tsx
index a8d46cf05..562816695 100644
--- a/src/app/signers/SignersTable.tsx
+++ b/src/app/signers/SignersTable.tsx
@@ -3,7 +3,7 @@ import styled from '@emotion/styled';
import { UseQueryResult, useQueries, useQueryClient } from '@tanstack/react-query';
import React, { ReactNode, Suspense, useMemo, useState } from 'react';
-import { AddressLink } from '../../common/components/ExplorerLinks';
+import { AddressLink, ExplorerLink } from '../../common/components/ExplorerLinks';
import { Section } from '../../common/components/Section';
import { ApiResponseWithResultsOffset } from '../../common/types/api';
import { truncateMiddle } from '../../common/utils/utils';
@@ -91,12 +91,12 @@ export const SignersTableHeaders = () => (
);
-function getEntityName(signerKey: string) {
+export function getEntityName(signerKey: string) {
const entityName = removeStackingDaoFromName(getSignerKeyName(signerKey));
return entityName === 'unknown' ? '-' : entityName;
}
-const SignerTableRow = ({
+export const SignerTableRow = ({
index,
isFirst,
isLast,
@@ -123,10 +123,17 @@ const SignerTableRow = ({
-
+
{truncateMiddle(signerKey)}
{truncateMiddle(signerKey)}
-
+
|
@@ -174,28 +181,18 @@ const SignerTableRow = ({
};
export function SignersTableLayout({
- numSigners,
+ title,
+ topRight,
signersTableHeaders,
signersTableRows,
- votingPowerSortOrder,
- setVotingPowerSortOrder,
}: {
- numSigners: ReactNode;
+ title: ReactNode;
+ topRight?: ReactNode;
signersTableHeaders: ReactNode;
signersTableRows: ReactNode;
- votingPowerSortOrder: VotingPowerSortOrder;
- setVotingPowerSortOrder: (order: VotingPowerSortOrder) => void;
}) {
return (
-
- }
- >
+
{signersTableHeaders}
@@ -212,7 +209,7 @@ interface SignerRowInfo {
stackers: SignersStackersData[];
}
-function formatSignerRowData(
+export function formatSignerRowData(
singerInfo: SignerInfo,
stackers: SignersStackersData[]
): SignerRowInfo {
@@ -267,9 +264,13 @@ const SignersTableBase = () => {
return (
{signersData.length} Active Signers}
+ topRight={
+
+ }
+ title={{signersData.length} Active Signers}
signersTableHeaders={}
signersTableRows={signersData.map((signer, i) => (
({
+ queryKey: [SIGNER_QUERY_KEY, cycleId, signerKey],
+ queryFn: () =>
+ fetch(`${activeNetworkUrl}/extended/v2/pox/cycles/${cycleId}/signers/${signerKey}`).then(
+ res => res.json()
+ ),
+ staleTime: TWO_MINUTES,
+ cacheTime: 15 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ });
+}
+
+export function useSuspensePoxSigner(cycleId: number, signerId: string) {
+ const { url: activeNetworkUrl } = useGlobalContext().activeNetwork;
+
+ return useSuspenseQuery({
+ queryKey: [SIGNER_QUERY_KEY, cycleId, signerId],
+ queryFn: () =>
+ fetch(`${activeNetworkUrl}/extended/v2/pox/cycles/${cycleId}/signers/${signerId}`).then(res =>
+ res.json()
+ ),
+ staleTime: TEN_MINUTES,
+ });
+}
+
+const DEFAULT_LIST_LIMIT = 5;
+
+export function useSignerStackingHistory(signerKey: string) {
+ const { currentCycleId } = useSuspenseCurrentStackingCycle();
+ const getQuery = useGetSignerQuery();
+ const [offset, setOffset] = useState(0);
+ const cyclesToQuery = Array.from(
+ { length: offset + DEFAULT_LIST_LIMIT },
+ (_, i) => currentCycleId - i
+ );
+ const queryClient = useQueryClient();
+ const queries = useMemo(() => {
+ return cyclesToQuery.map(cycle => {
+ return getQuery(cycle, signerKey);
+ });
+ }, [cyclesToQuery, getQuery, signerKey]);
+
+ const signerStackingHistoryQueries = useMemo(() => {
+ return {
+ queries,
+ combine: (response: UseQueryResult[]) => {
+ return response.map(r => r.data);
+ },
+ };
+ }, [queries]);
+ const signerStackingHistory = useQueries(signerStackingHistoryQueries, queryClient);
+
+ // const isLoading = useMemo(() =>
+ // signerStackingHistory.some(query => query?.isLoading),
+ // [signerStackingHistory]
+ // );
+
+ // const isFetching = useMemo(() =>
+ // signerStackingHistory.some(query => query?.isFetching),
+ // [signerStackingHistory]
+ // );
+
+ const signerStackingHistoryFiltered = useMemo(() => {
+ return signerStackingHistory
+ .filter(
+ (data): data is SignerInfo | SignerError | undefined => data !== null && data !== undefined
+ )
+ .filter((r): r is SignerInfo | SignerError => {
+ if (r && 'error' in r && 'statusCode' in r) {
+ return r.statusCode !== 404;
+ }
+ return true;
+ })
+ .map((r, index) => ({
+ ...(r as SignerInfo),
+ cycleid: cyclesToQuery[index],
+ }));
+ }, [signerStackingHistory, cyclesToQuery]);
+
+ const fetchNextPage = useCallback(() => {
+ setOffset(prev => prev + DEFAULT_LIST_LIMIT);
+ }, []);
+ const found404s = useMemo(() => {
+ return signerStackingHistory.some(r => {
+ if (r && 'error' in r && 'statusCode' in r) {
+ return r.statusCode === 404;
+ }
+ return false;
+ });
+ }, [signerStackingHistory]);
+
+ const hasNextPage = useMemo(() => {
+ return !found404s;
+ }, [found404s]);
+
+ return {
+ signerStackingHistory: signerStackingHistoryFiltered,
+ fetchNextPage,
+ hasNextPage,
+ isLoading: false, // TODO: add loading state
+ isFetching: false, // TODO: add loading state
+ };
+}
diff --git a/src/app/signers/data/UseSignerAddresses.ts b/src/app/signers/data/UseSignerAddresses.ts
index 293606b5b..182525928 100644
--- a/src/app/signers/data/UseSignerAddresses.ts
+++ b/src/app/signers/data/UseSignerAddresses.ts
@@ -1,21 +1,31 @@
// Add missing import statement
-import { useSuspenseQuery } from '@tanstack/react-query';
+import {
+ InfiniteData,
+ UseInfiniteQueryResult,
+ useInfiniteQuery,
+ useSuspenseQuery,
+} from '@tanstack/react-query';
import { useGlobalContext } from '../../../common/context/useGlobalContext';
+import { GenericResponseType } from '../../../common/hooks/useInfiniteQueryResult';
import { TEN_MINUTES, TWO_MINUTES } from '../../../common/queries/query-stale-time';
+import { ApiResponseWithResultsOffset } from '../../../common/types/api';
+import { getNextPageParam } from '../../../common/utils/utils';
-const SIGNER_ADDRESSES_QUERY_KEY = 'signer-addresses';
+const SIGNER_STACKERS_QUERY_KEY = 'signer-stackers';
+const SIGNER_STACKERS_INFINITE_QUERY_KEY = 'signer-stackers-infinite';
export interface SignersStackersData {
stacker_address: string;
stacked_amount: string;
pox_address: string;
+ stacker_type: string;
}
export function useGetStackersBySignerQuery() {
const { url: activeNetworkUrl } = useGlobalContext().activeNetwork;
return (cycleId: number, signerKey: string) => ({
- queryKey: [SIGNER_ADDRESSES_QUERY_KEY, cycleId, signerKey],
+ queryKey: [SIGNER_STACKERS_QUERY_KEY, cycleId, signerKey],
queryFn: () =>
fetch(
`${activeNetworkUrl}/extended/v2/pox/cycles/${cycleId}/signers/${signerKey}/stackers`
@@ -26,15 +36,59 @@ export function useGetStackersBySignerQuery() {
});
}
-export function useSuspenseSignerAddresses(cycleId: number, signerKey: string) {
+export function useSuspenseSignerStackers(cycleId: number, signerKey: string) {
const { url: activeNetworkUrl } = useGlobalContext().activeNetwork;
- return useSuspenseQuery({
- queryKey: [SIGNER_ADDRESSES_QUERY_KEY, cycleId, signerKey],
+ return useSuspenseQuery>({
+ queryKey: [SIGNER_STACKERS_QUERY_KEY, cycleId, signerKey],
queryFn: () =>
- fetch(`${activeNetworkUrl}/extended/v2/pox/cycles/${cycleId}/signers/${signerKey}`).then(
- res => res.json()
- ),
+ fetch(
+ `${activeNetworkUrl}/extended/v2/pox/cycles/${cycleId}/signers/${signerKey}/stackers`
+ ).then(res => res.json()),
staleTime: TEN_MINUTES,
});
}
+
+const DEFAULT_LIST_LIMIT = 10;
+
+const fetchStackers = async (
+ apiUrl: string,
+ cycleId: number,
+ signerKey: 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}/extended/v2/pox/cycles/${cycleId}/signers/${signerKey}/stackers${
+ queryString ? `?${queryString}` : ''
+ }`
+ );
+ return response.json();
+};
+
+export function useSuspenseSignerStackersInfinite(
+ cycleId: number,
+ signerKey: string,
+ options: any = {}
+): UseInfiniteQueryResult>> {
+ // const api = useApi();
+ // if (!address) throw new Error('Address is required');
+ const { url: activeNetworkUrl } = useGlobalContext().activeNetwork;
+
+ return useInfiniteQuery>({
+ queryKey: [SIGNER_STACKERS_INFINITE_QUERY_KEY, cycleId, signerKey],
+ queryFn: ({ pageParam }: { pageParam: number }) =>
+ fetchStackers(activeNetworkUrl, cycleId, signerKey, pageParam, options),
+ getNextPageParam,
+ initialPageParam: 0,
+ staleTime: TWO_MINUTES,
+ enabled: !!cycleId && !!signerKey,
+ ...options,
+ });
+}
|