diff --git a/apollo/subgraph.ts b/apollo/subgraph.ts index e7511b3e..f8ff53e9 100644 --- a/apollo/subgraph.ts +++ b/apollo/subgraph.ts @@ -1648,6 +1648,23 @@ export enum LivepeerAccount_OrderBy { LastUpdatedTimestamp = 'lastUpdatedTimestamp' } +/** + * The severity level of a log entry. + * Log levels are ordered from most to least severe: CRITICAL > ERROR > WARNING > INFO > DEBUG + */ +export enum LogLevel { + /** Critical errors that require immediate attention */ + Critical = 'CRITICAL', + /** Detailed diagnostic information for debugging */ + Debug = 'DEBUG', + /** Error conditions that indicate a failure */ + Error = 'ERROR', + /** Informational messages about normal operations */ + Info = 'INFO', + /** Warning conditions that may require attention */ + Warning = 'WARNING' +} + /** MigrateDelegatorFinalizedEvent entities are created for every emitted WithdrawStake event. */ export type MigrateDelegatorFinalizedEvent = Event & { __typename: 'MigrateDelegatorFinalizedEvent'; @@ -3331,6 +3348,8 @@ export enum Protocol_OrderBy { export type Query = { __typename: 'Query'; + /** Query execution logs emitted by the subgraph during indexing. Results are sorted by timestamp in descending order (newest first). */ + _logs: Array<_Log_>; /** Access to subgraph metadata */ _meta?: Maybe<_Meta_>; bondEvent?: Maybe; @@ -3436,6 +3455,17 @@ export type Query = { }; +export type Query_LogsArgs = { + first?: InputMaybe; + from?: InputMaybe; + level?: InputMaybe; + orderDirection?: InputMaybe; + search?: InputMaybe; + skip?: InputMaybe; + to?: InputMaybe; +}; + + export type Query_MetaArgs = { block?: InputMaybe; }; @@ -9579,6 +9609,54 @@ export type _Block_ = { timestamp?: Maybe; }; +/** + * A key-value pair of additional data associated with a log entry. + * These correspond to arguments passed to the log function in the subgraph code. + */ +export type _LogArgument_ = { + __typename: '_LogArgument_'; + /** The parameter name */ + key: Scalars['String']; + /** The parameter value, serialized as a string */ + value: Scalars['String']; +}; + +/** + * Source code location metadata for a log entry. + * Indicates where in the subgraph's AssemblyScript code the log statement was executed. + */ +export type _LogMeta_ = { + __typename: '_LogMeta_'; + /** The column number in the source file */ + column: Scalars['Int']; + /** The line number in the source file */ + line: Scalars['Int']; + /** The module or file path where the log was emitted */ + module: Scalars['String']; +}; + +/** + * A log entry emitted by a subgraph during indexing. + * Logs can be generated by the subgraph's AssemblyScript code using the `log.*` functions. + */ +export type _Log_ = { + __typename: '_Log_'; + /** Additional structured data passed to the log function as key-value pairs */ + arguments: Array<_LogArgument_>; + /** Unique identifier for this log entry */ + id: Scalars['String']; + /** The severity level of the log entry */ + level: LogLevel; + /** Metadata about the source location in the subgraph code where the log was emitted */ + meta: _LogMeta_; + /** The deployment hash of the subgraph that emitted this log */ + subgraphId: Scalars['String']; + /** The log message text */ + text: Scalars['String']; + /** The timestamp when the log was emitted, in RFC3339 format (e.g., '2024-01-15T10:30:00Z') */ + timestamp: Scalars['String']; +}; + /** The type for the top-level _meta field */ export type _Meta_ = { __typename: '_Meta_'; @@ -9658,6 +9736,17 @@ export type MetaQueryVariables = Exact<{ [key: string]: never; }>; export type MetaQuery = { __typename: 'Query', _meta?: { __typename: '_Meta_', hasIndexingErrors: boolean, block: { __typename: '_Block_', number: number } } | null }; +export type OrchestratorDelegatorsQueryVariables = Exact<{ + id: Scalars['ID']; + first?: InputMaybe; + skip?: InputMaybe; + orderBy?: InputMaybe; + orderDirection?: InputMaybe; +}>; + + +export type OrchestratorDelegatorsQuery = { __typename: 'Query', transcoder?: { __typename: 'Transcoder', id: string, delegators?: Array<{ __typename: 'Delegator', id: string, bondedAmount: string, startRound: string }> | null } | null }; + export type OrchestratorsQueryVariables = Exact<{ currentRound?: InputMaybe; currentRoundString?: InputMaybe; @@ -10344,6 +10433,55 @@ export function useMetaLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions; export type MetaLazyQueryHookResult = ReturnType; export type MetaQueryResult = Apollo.QueryResult; +export const OrchestratorDelegatorsDocument = gql` + query orchestratorDelegators($id: ID!, $first: Int, $skip: Int, $orderBy: Delegator_orderBy, $orderDirection: OrderDirection) { + transcoder(id: $id) { + id + delegators( + first: $first + skip: $skip + orderBy: $orderBy + orderDirection: $orderDirection + ) { + id + bondedAmount + startRound + } + } +} + `; + +/** + * __useOrchestratorDelegatorsQuery__ + * + * To run a query within a React component, call `useOrchestratorDelegatorsQuery` and pass it any options that fit your needs. + * When your component renders, `useOrchestratorDelegatorsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useOrchestratorDelegatorsQuery({ + * variables: { + * id: // value for 'id' + * first: // value for 'first' + * skip: // value for 'skip' + * orderBy: // value for 'orderBy' + * orderDirection: // value for 'orderDirection' + * }, + * }); + */ +export function useOrchestratorDelegatorsQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(OrchestratorDelegatorsDocument, options); + } +export function useOrchestratorDelegatorsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(OrchestratorDelegatorsDocument, options); + } +export type OrchestratorDelegatorsQueryHookResult = ReturnType; +export type OrchestratorDelegatorsLazyQueryHookResult = ReturnType; +export type OrchestratorDelegatorsQueryResult = Apollo.QueryResult; export const OrchestratorsDocument = gql` query orchestrators($currentRound: BigInt, $currentRoundString: String, $where: Transcoder_filter, $first: Int, $skip: Int, $orderBy: Transcoder_orderBy, $orderDirection: OrderDirection) { transcoders( diff --git a/components/DelegatorList/index.tsx b/components/DelegatorList/index.tsx new file mode 100644 index 00000000..0a65cf60 --- /dev/null +++ b/components/DelegatorList/index.tsx @@ -0,0 +1,343 @@ +import { ExplorerTooltip } from "@components/ExplorerTooltip"; +import IdentityAvatar from "@components/IdentityAvatar"; +import PopoverLink from "@components/PopoverLink"; +import Table from "@components/Table"; +import { textTruncate } from "@lib/utils"; +import { + Badge, + Box, + Flex, + IconButton, + Link as A, + Popover, + PopoverContent, + PopoverTrigger, + Text, +} from "@livepeer/design-system"; +import { DotsHorizontalIcon } from "@radix-ui/react-icons"; +import { formatStakeAmount } from "@utils/numberFormatters"; +import { OrchestratorDelegatorsQuery } from "apollo"; +import { useEnsData, usePendingFeesAndStakeData } from "hooks"; +import Link from "next/link"; +import { useMemo } from "react"; +import { Column, Row } from "react-table"; + +type Delegator = NonNullable< + NonNullable["delegators"] +>[number]; + +type DelegatorRow = Delegator & { + bondedAmountNumber: number; + startRoundNumber: number; +}; + +// Compare the already-numeric row values directly. react-table's built-in +// "number" sortType stringifies each value and strips non-numeric chars, which +// mangles values rendered in exponential notation (e.g. 9.4e-7 -> "9.47"), +// scattering sub-1e-6 "dust" amounts into the middle of the list. +const numericSort = ( + rowA: Row, + rowB: Row, + columnId: string +) => + (Number(rowA.values[columnId]) || 0) - (Number(rowB.values[columnId]) || 0); + +const DelegatorList = ({ + data, + pageSize = 10, +}: { + pageSize?: number; + data: + | NonNullable["delegators"] + | undefined; +}) => { + const rows: DelegatorRow[] = useMemo( + () => + (data ?? []).map((delegator) => ({ + ...delegator, + bondedAmountNumber: Number(delegator?.bondedAmount ?? 0), + startRoundNumber: Number(delegator?.startRound ?? 0), + })), + [data] + ); + + const columns: Column[] = useMemo( + () => [ + { + Header: ( + + An account that has delegated stake to this orchestrator. + + } + > + Delegator + + ), + accessor: "id", + Cell: ({ row, rows, value }) => { + const address = value as string; + // row.index is the position in the original data array; use the row's + // position in the sorted row model so the ordinal reflects the visible + // order and stays continuous across sorting and pagination. + const ordinal = rows.indexOf(row) + 1; + const identity = useEnsData(address); + const ensName = identity?.name; + const shortAddress = address.replace(address.slice(6, 38), "…"); + + return ( + + + + {ordinal} + + + + {ensName ? ( + + + {textTruncate(ensName, 20, "…")} + + + {address.substring(0, 6)} + + + ) : ( + {shortAddress} + )} + + + + ); + }, + }, + { + Header: ( + + The amount of stake this account has bonded to the orchestrator, + excluding rewards not yet claimed (see Pending Rewards). + + } + > + Stake + + ), + accessor: "bondedAmountNumber", + Cell: ({ value }) => ( + + {formatStakeAmount(Number(value ?? 0))} + + ), + sortType: numericSort, + }, + { + Header: ( + + Rewards earned from this orchestrator that have not been claimed + yet. Stake + Pending Rewards equals the account's current + total stake. + + } + > + Pending Rewards + + ), + // Live pendingStake is fetched per row (on-screen only), so this column + // has no row-model value and cannot be globally sorted. + id: "pendingRewards", + disableSortBy: true, + Cell: ({ row }) => { + const { id, bondedAmountNumber } = row.original; + const data = usePendingFeesAndStakeData(id); + // Pending rewards = live total stake − bonded amount (never negative). + const rewards = data + ? Math.max(0, Number(data.pendingStake) / 1e18 - bondedAmountNumber) + : undefined; + + return ( + + {rewards === undefined ? "—" : formatStakeAmount(rewards)} + + ); + }, + }, + { + Header: ( + The round this account became bonded.} + > + Start Round + + ), + accessor: "startRoundNumber", + Cell: ({ value }) => ( + + {value ? Number(value) : "N/A"} + + ), + sortType: numericSort, + }, + { + Header: <>, + id: "actions", + Cell: ({ row }) => ( + + { + e.stopPropagation(); + }} + asChild + > + + + + + + + { + e.stopPropagation(); + }} + > + + + Account Details + + + + Profile + + + History + + + + + ), + }, + ], + [] + ); + + if (!rows?.length) { + return ( + + No delegators found. + + ); + } + + return ( + + ); +}; + +export default DelegatorList; diff --git a/components/DelegatorsView/index.tsx b/components/DelegatorsView/index.tsx new file mode 100644 index 00000000..153cde4c --- /dev/null +++ b/components/DelegatorsView/index.tsx @@ -0,0 +1,158 @@ +import DelegatorList from "@components/DelegatorList"; +import Spinner from "@components/Spinner"; +import { Box, Button, Flex, Text } from "@livepeer/design-system"; +import { + AccountQueryResult, + Delegator_OrderBy, + OrderDirection, + useOrchestratorDelegatorsQuery, +} from "apollo"; +import { useCallback, useEffect, useRef, useState } from "react"; + +// The subgraph caps `delegators(first:)` at 1000 rows per request, so a single +// query silently drops delegators beyond the first 1000. Page through with +// `skip` until a short page comes back to load the complete list. +const PAGE_SIZE = 1000; + +interface Props { + transcoder?: NonNullable["transcoder"]; +} + +const DelegatorsView = ({ transcoder }: Props) => { + const { data, loading, error, fetchMore } = useOrchestratorDelegatorsQuery({ + variables: { + id: transcoder?.id ?? "", + first: PAGE_SIZE, + skip: 0, + orderBy: Delegator_OrderBy.BondedAmount, + orderDirection: OrderDirection.Desc, + }, + skip: !transcoder?.id, + notifyOnNetworkStatusChange: true, + }); + + const delegators = data?.transcoder?.delegators; + + // Set when a later page fails to load, so we can surface that the list is + // incomplete (and pause auto-paging until the user retries). + const [pageFetchFailed, setPageFetchFailed] = useState(false); + + // Concurrency lock (ref so it's synchronous) to avoid overlapping page fetches. + const fetchingRef = useRef(false); + const fetchNext = useCallback( + async (skip: number) => { + if (fetchingRef.current) return; + fetchingRef.current = true; + setPageFetchFailed(false); + try { + await fetchMore({ + variables: { skip }, + updateQuery: (previousResult, { fetchMoreResult }) => { + const next = fetchMoreResult?.transcoder?.delegators; + if (!next?.length || !previousResult.transcoder) { + return previousResult; + } + return { + ...previousResult, + transcoder: { + ...previousResult.transcoder, + delegators: [ + ...(previousResult.transcoder.delegators ?? []), + ...next, + ], + }, + }; + }, + }); + } catch (e) { + console.error("Failed to fetch additional delegators:", e); + setPageFetchFailed(true); + } finally { + fetchingRef.current = false; + } + }, + [fetchMore] + ); + + useEffect(() => { + // A full page implies there may be more; keep paging until a short page + // (or an exact multiple returning an empty page) ends the loop. Pause while + // a page fetch has failed so we don't hammer a failing endpoint; the retry + // action clears the flag and re-runs this effect. + if (pageFetchFailed) return; + if (!delegators?.length || delegators.length % PAGE_SIZE !== 0) return; + fetchNext(delegators.length); + }, [delegators, fetchNext, pageFetchFailed]); + + useEffect(() => { + // Log the initial-query failure as an after-render side effect rather than + // during render (which can run twice or be discarded). + if (error && !delegators) { + console.error(error); + } + }, [error, delegators]); + + if (loading && !delegators) { + return ( + + + + ); + } + + if (error && !delegators) { + return ( + + Unable to load delegators. Please try again later. + + ); + } + + return ( + + {pageFetchFailed && ( + + + Couldn't load all delegators — the list may be incomplete. + + + + )} + + + ); +}; + +export default DelegatorsView; diff --git a/components/Table/index.tsx b/components/Table/index.tsx index 42505038..0c37a2df 100644 --- a/components/Table/index.tsx +++ b/components/Table/index.tsx @@ -46,6 +46,7 @@ function DataTable({ data, columns, initialState = {}, + minWidth = 960, autoResetPage, autoResetSortBy, onStateChange, @@ -55,6 +56,7 @@ function DataTable({ data: T[]; columns: Column[]; initialState: TableInitialState; + minWidth?: number; autoResetPage?: boolean; autoResetSortBy?: boolean; onStateChange?: (state: PersistedTableState) => void; @@ -126,7 +128,7 @@ function DataTable({ css={{ borderCollapse: "collapse", tableLayout: "auto", - minWidth: 960, + minWidth, width: "100%", "@bp4": { width: "100%", diff --git a/layouts/account.tsx b/layouts/account.tsx index 51774a37..89daac5e 100644 --- a/layouts/account.tsx +++ b/layouts/account.tsx @@ -1,5 +1,6 @@ import BottomDrawer from "@components/BottomDrawer"; import BroadcastingView from "@components/BroadcastingView"; +import DelegatorsView from "@components/DelegatorsView"; import HistoryView from "@components/HistoryView"; import HorizontalScrollContainer from "@components/HorizontalScrollContainer"; import OrchestratingView from "@components/OrchestratingView"; @@ -47,11 +48,17 @@ export interface TabType { isActive?: boolean; } -type TabTypeEnum = "delegating" | "orchestrating" | "history" | "broadcasting"; +type TabTypeEnum = + | "delegating" + | "orchestrating" + | "delegators" + | "history" + | "broadcasting"; const ACCOUNT_VIEWS: TabTypeEnum[] = [ "delegating", "orchestrating", + "delegators", "broadcasting", "history", ]; @@ -198,6 +205,10 @@ const AccountLayout = ({ paddingRight: 0, paddingTop: "$4", width: "100%", + // Allow this column to shrink to its flex share instead of being + // held open by wide content (e.g. the delegators table's minWidth), + // which would otherwise squeeze the side widget. + minWidth: 0, "@bp3": { paddingTop: "$6", paddingRight: "$7", @@ -310,6 +321,9 @@ const AccountLayout = ({ transcoder={viewedAccount?.transcoder} /> )} + {view === "delegators" && ( + + )} {view === "delegating" && ( { + if (hadError) { + return ; + } + + return ( + + ); +}; + +Delegators.getLayout = getLayout; + +export const getStaticPaths = async () => { + const { sortedOrchestrators } = await getSortedOrchestrators(); + + return { + paths: + sortedOrchestrators?.data?.transcoders?.map((t) => ({ + params: { account: t.id }, + })) ?? [], + fallback: "blocking", + }; +}; + +export const getStaticProps = async (context: { + params: { account: string }; +}) => { + try { + const accountId = context.params?.account?.toString().toLowerCase(); + + // 404 only on malformed addresses; a valid address with no on-chain activity still + // renders the empty-state account page. + if (!accountId || !isAddress(accountId)) { + return { notFound: true }; + } + + const client = getApollo(); + const { account, fallback } = await getAccount(client, accountId); + + // If we couldn't fetch account data, treat it as a temporary error + if (!account.data) { + throw new Error("Failed to fetch account data"); + } + + // This route is orchestrator-only. Accounts without a transcoder can't have + // delegators, so 404 instead of rendering a misleading empty state. + if (!account.data.transcoder) { + return { notFound: true, revalidate: 600 }; + } + + const { sortedOrchestrators, fallback: sortedOrchestratorsFallback } = + await getSortedOrchestrators(client); + + // If we couldn't fetch orchestrators data, treat it as a temporary error + if (!sortedOrchestrators.data) { + throw new Error("Failed to fetch orchestrators data"); + } + + const props: PageProps = { + hadError: false, + account: account.data, + sortedOrchestrators: sortedOrchestrators.data, + fallback: { + ...sortedOrchestratorsFallback, + ...fallback, + }, + }; + + return { + props, + revalidate: 600, + }; + } catch (e) { + console.log(e); + return { + props: { + hadError: true, + }, + revalidate: 60, + }; + } +}; + +export default Delegators; diff --git a/queries/orchestratorDelegators.graphql b/queries/orchestratorDelegators.graphql new file mode 100644 index 00000000..c4def0fd --- /dev/null +++ b/queries/orchestratorDelegators.graphql @@ -0,0 +1,21 @@ +query orchestratorDelegators( + $id: ID! + $first: Int + $skip: Int + $orderBy: Delegator_orderBy + $orderDirection: OrderDirection +) { + transcoder(id: $id) { + id + delegators( + first: $first + skip: $skip + orderBy: $orderBy + orderDirection: $orderDirection + ) { + id + bondedAmount + startRound + } + } +}