diff --git a/.changeset/sweet-cities-mate.md b/.changeset/sweet-cities-mate.md new file mode 100644 index 000000000..e40d9fd01 --- /dev/null +++ b/.changeset/sweet-cities-mate.md @@ -0,0 +1,5 @@ +--- +"ensadmin": minor +--- + +Updates `useIndexingStatusWithSwr` to always return current realtime indexing status projection. diff --git a/.changeset/wild-results-wash.md b/.changeset/wild-results-wash.md new file mode 100644 index 000000000..fdcd0068b --- /dev/null +++ b/.changeset/wild-results-wash.md @@ -0,0 +1,5 @@ +--- +"ensadmin": minor +--- + +Includes `ProjectionInfo` component on Indexing Status page. diff --git a/apps/ensadmin/src/app/mock/indexing-stats/page.tsx b/apps/ensadmin/src/app/mock/indexing-stats/page.tsx index b169c2bd0..1ceffccf8 100644 --- a/apps/ensadmin/src/app/mock/indexing-stats/page.tsx +++ b/apps/ensadmin/src/app/mock/indexing-stats/page.tsx @@ -1,10 +1,13 @@ "use client"; +import { useNow } from "@namehash/namehash-ui"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { - type IndexingStatusResponse, + CrossChainIndexingStatusSnapshot, + createRealtimeIndexingStatusProjection, + IndexingStatusResponseCodes, IndexingStatusResponseOk, OmnichainIndexingStatusIds, } from "@ensnode/ensnode-sdk"; @@ -13,10 +16,7 @@ import { IndexingStats } from "@/components/indexing-status/indexing-stats"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { - indexingStatusResponseError, - indexingStatusResponseOkOmnichain, -} from "../indexing-status-api.mock"; +import { indexingStatusResponseOkOmnichain } from "../indexing-status-api.mock"; type LoadingVariant = "Loading" | "Loading Error"; type ResponseOkVariant = keyof typeof indexingStatusResponseOkOmnichain; @@ -37,7 +37,7 @@ let loadingTimeoutId: number; async function fetchMockedIndexingStatus( selectedVariant: Variant, -): Promise { +): Promise { // always try clearing loading timeout when performing a mocked fetch // this way we get a fresh and very long request to observe the loading state if (loadingTimeoutId) { @@ -48,14 +48,19 @@ async function fetchMockedIndexingStatus( case OmnichainIndexingStatusIds.Unstarted: case OmnichainIndexingStatusIds.Backfill: case OmnichainIndexingStatusIds.Following: - case OmnichainIndexingStatusIds.Completed: - return indexingStatusResponseOkOmnichain[selectedVariant] as IndexingStatusResponseOk; + case OmnichainIndexingStatusIds.Completed: { + const response = indexingStatusResponseOkOmnichain[ + selectedVariant + ] as IndexingStatusResponseOk; + + return response.realtimeProjection.snapshot; + } case "Error ResponseCode": throw new Error( "Received Indexing Status response with responseCode other than 'ok' which will not be cached.", ); case "Loading": - return new Promise((_resolve, reject) => { + return new Promise((_resolve, reject) => { loadingTimeoutId = +setTimeout(reject, 5 * 60 * 1_000); }); case "Loading Error": @@ -67,10 +72,17 @@ export default function MockIndexingStatusPage() { const [selectedVariant, setSelectedVariant] = useState( OmnichainIndexingStatusIds.Unstarted, ); + const now = useNow(); const mockedIndexingStatus = useQuery({ queryKey: ["mock", "useIndexingStatus", selectedVariant], queryFn: () => fetchMockedIndexingStatus(selectedVariant), + select: (cachedSnapshot) => { + return { + responseCode: IndexingStatusResponseCodes.Ok, + realtimeProjection: createRealtimeIndexingStatusProjection(cachedSnapshot, now), + } satisfies IndexingStatusResponseOk; + }, retry: false, // allows loading error to be observed immediately }); diff --git a/apps/ensadmin/src/components/indexing-status/indexing-stats.tsx b/apps/ensadmin/src/components/indexing-status/indexing-stats.tsx index d89a0c3a6..1af60ece8 100644 --- a/apps/ensadmin/src/components/indexing-status/indexing-stats.tsx +++ b/apps/ensadmin/src/components/indexing-status/indexing-stats.tsx @@ -5,9 +5,8 @@ */ import { ChainIcon, ChainName } from "@namehash/namehash-ui"; -import type { PropsWithChildren, ReactElement } from "react"; +import type { PropsWithChildren, ReactElement, ReactNode } from "react"; -import type { useIndexingStatus } from "@ensnode/ensnode-react"; import { ChainIndexingConfigTypeIds, ChainIndexingStatusIds, @@ -24,7 +23,6 @@ import { sortChainStatusesByStartBlockAsc, } from "@ensnode/ensnode-sdk"; -import { useIndexingStatusWithSwr } from "@/components/indexing-status/use-indexing-status-with-swr"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { formatChainStatus, formatOmnichainIndexingStatus } from "@/lib/indexing-status"; @@ -33,6 +31,8 @@ import { cn } from "@/lib/utils"; import { BackfillStatus } from "./backfill-status"; import { BlockStats } from "./block-refs"; import { IndexingStatusLoading } from "./indexing-status-loading"; +import { ProjectionInfo } from "./projection-info"; +import type { useIndexingStatusWithSwr } from "./use-indexing-status-with-swr"; interface IndexingStatsForOmnichainStatusSnapshotProps< OmnichainIndexingStatusSnapshotType extends @@ -316,30 +316,20 @@ export function IndexingStatsForSnapshotFollowing({ }); } +interface IndexingStatsShellProps extends PropsWithChildren { + title: ReactNode; +} + /** * Indexing Stats Shell * * UI component for presenting indexing stats UI for specific overall status. */ -export function IndexingStatsShell({ - omnichainStatus, - children, -}: PropsWithChildren<{ omnichainStatus?: OmnichainIndexingStatusId }>) { +export function IndexingStatsShell({ title, children }: IndexingStatsShellProps) { return ( - - Indexing Status - - {omnichainStatus && ( - - {formatOmnichainIndexingStatus(omnichainStatus)} - - )} - + {title} @@ -418,11 +408,34 @@ export function IndexingStatsForRealtimeStatusProjection({ break; } + const { snapshot, worstCaseDistance } = realtimeProjection; + const { omnichainSnapshot, snapshotTime } = snapshot; + const omnichainStatus = omnichainSnapshot.omnichainStatus; + return (
{maybeIndexingTimeline} - + + Indexing Status + + + + + {formatOmnichainIndexingStatus(omnichainStatus)} + + + } + > {indexingStats}
@@ -439,7 +452,7 @@ export function IndexingStats(props: IndexingStatsProps) { if (indexingStatusQuery.isError) { return ( - + ); @@ -449,11 +462,7 @@ export function IndexingStats(props: IndexingStatsProps) { return ; } - const indexingStatus = indexingStatusQuery.data; + const { realtimeProjection } = indexingStatusQuery.data; - return ( - - ); + return ; } diff --git a/apps/ensadmin/src/components/indexing-status/projection-info.tsx b/apps/ensadmin/src/components/indexing-status/projection-info.tsx new file mode 100644 index 000000000..677e80a07 --- /dev/null +++ b/apps/ensadmin/src/components/indexing-status/projection-info.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { formatRelativeTime, RelativeTime, useNow } from "@namehash/namehash-ui"; +import { InfoIcon } from "lucide-react"; + +import type { Duration, UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +/** + * Formats the worst-case distance for display in the projection info tooltip. + * + * If the worst-case distance is 1 minute or less, we present it as + * an absolute number of seconds (e.g. "45 seconds"). + * + * Otherwise, we present it as a relative time (e.g. "2 hours ago") instead of + * an absolute number of seconds. Also, we drop the "ago" suffix from + * the relative time string to focus on the distance aspect + * (e.g. "2 hours" instead of "2 hours ago"). + */ +const formatWorstCaseDistance = ( + worstCaseDistance: Duration, + omnichainIndexingCursor: UnixTimestamp, +) => { + const presentWorstCaseDistanceAsRelativeTime = worstCaseDistance > 60; + + if (presentWorstCaseDistanceAsRelativeTime) { + return formatRelativeTime(omnichainIndexingCursor, true, true, true).replace(" ago", ""); + } + + return `${worstCaseDistance} seconds`; +}; + +interface ProjectionInfoProps { + omnichainIndexingCursor: UnixTimestamp; + snapshotTime: UnixTimestamp; + worstCaseDistance: Duration; +} + +/** + * Displays metadata about the current indexing status projection in a tooltip. + * Shows when the projection was generated, when the snapshot was taken, and worst-case distance. + */ +export function ProjectionInfo({ + omnichainIndexingCursor, + snapshotTime, + worstCaseDistance, +}: ProjectionInfoProps) { + const now = useNow(); + return ( + + + + + +
+
+
+ Worst-Case Distance* +
+
+ {formatWorstCaseDistance(worstCaseDistance, omnichainIndexingCursor)} +
+
+ +
+ * as of the real-time projection generated just now from indexing status snapshot + captured{" "} + + . +
+
+
+
+ ); +} diff --git a/apps/ensadmin/src/components/indexing-status/use-indexing-status-with-swr.ts b/apps/ensadmin/src/components/indexing-status/use-indexing-status-with-swr.ts index f1c1209b8..ba9d80cc2 100644 --- a/apps/ensadmin/src/components/indexing-status/use-indexing-status-with-swr.ts +++ b/apps/ensadmin/src/components/indexing-status/use-indexing-status-with-swr.ts @@ -1,5 +1,7 @@ "use client"; +import { useNow } from "@namehash/namehash-ui"; +import { secondsToMilliseconds } from "date-fns"; import { useCallback, useMemo } from "react"; import { @@ -11,16 +13,21 @@ import { WithSDKConfigParameter, } from "@ensnode/ensnode-react"; import { + CrossChainIndexingStatusSnapshotOmnichain, + createRealtimeIndexingStatusProjection, + Duration, type IndexingStatusRequest, IndexingStatusResponseCodes, IndexingStatusResponseOk, } from "@ensnode/ensnode-sdk"; -const DEFAULT_REFETCH_INTERVAL = 10 * 1000; +const DEFAULT_REFETCH_INTERVAL = secondsToMilliseconds(10); + +const REALTIME_PROJECTION_REFRESH_RATE: Duration = 1; interface UseIndexingStatusParameters extends IndexingStatusRequest, - QueryParameter {} + QueryParameter {} /** * A proxy hook for {@link useIndexingStatus} which applies @@ -31,6 +38,7 @@ export function useIndexingStatusWithSwr( ) { const { config, query = {} } = parameters; const _config = useENSNodeSDKConfig(config); + const now = useNow({ timeToRefresh: REALTIME_PROJECTION_REFRESH_RATE }); const queryOptions = useMemo(() => createIndexingStatusQueryOptions(_config), [_config]); const queryKey = useMemo(() => ["swr", ...queryOptions.queryKey], [queryOptions.queryKey]); @@ -46,18 +54,39 @@ export function useIndexingStatusWithSwr( ); } - // successful response to be cached - return response; + // The indexing status snapshot has been fetched and successfully validated for caching. + // Therefore, return it so that query cache for `queryOptions.queryKey` will: + // - Replace the currently cached value (if any) with this new value. + // - Return this non-null value. + return response.realtimeProjection.snapshot; }), [queryOptions.queryFn], ); + // Call select function to `createRealtimeIndexingStatusProjection` each time + // `now` is updated. + const select = useCallback( + (cachedSnapshot: CrossChainIndexingStatusSnapshotOmnichain): IndexingStatusResponseOk => { + const realtimeProjection = createRealtimeIndexingStatusProjection(cachedSnapshot, now); + + // Maintain the original response shape of `IndexingStatusResponse` + // for the consumers. Creating a new projection from the cached snapshot + // each time `now` is updated should be implementation detail. + return { + responseCode: IndexingStatusResponseCodes.Ok, + realtimeProjection, + } satisfies IndexingStatusResponseOk; + }, + [now], + ); + return useSwrQuery({ ...queryOptions, - refetchInterval: query.refetchInterval ?? DEFAULT_REFETCH_INTERVAL, // Indexing status changes frequently ...query, + refetchInterval: query.refetchInterval ?? DEFAULT_REFETCH_INTERVAL, // Indexing status changes frequently enabled: query.enabled ?? queryOptions.enabled, queryKey, queryFn, + select, }); } diff --git a/apps/ensadmin/src/components/registrar-actions/use-stateful-fetch-registrar-actions.ts b/apps/ensadmin/src/components/registrar-actions/use-stateful-fetch-registrar-actions.ts index 7e9fd63af..8ed5a1eee 100644 --- a/apps/ensadmin/src/components/registrar-actions/use-stateful-fetch-registrar-actions.ts +++ b/apps/ensadmin/src/components/registrar-actions/use-stateful-fetch-registrar-actions.ts @@ -1,6 +1,5 @@ import { useENSNodeConfig, useRegistrarActions } from "@ensnode/ensnode-react"; import { - IndexingStatusResponseCodes, RegistrarActionsOrders, RegistrarActionsResponseCodes, registrarActionsPrerequisites, @@ -44,13 +43,10 @@ export function useStatefulRegistrarActions({ let isRegistrarActionsApiSupported = false; - if ( - ensNodeConfigQuery.isSuccess && - indexingStatusQuery.isSuccess && - indexingStatusQuery.data.responseCode === IndexingStatusResponseCodes.Ok - ) { + if (ensNodeConfigQuery.isSuccess && indexingStatusQuery.isSuccess) { const { ensIndexerPublicConfig } = ensNodeConfigQuery.data; - const { omnichainSnapshot } = indexingStatusQuery.data.realtimeProjection.snapshot; + const { realtimeProjection } = indexingStatusQuery.data; + const { omnichainSnapshot } = realtimeProjection.snapshot; isRegistrarActionsApiSupported = hasEnsIndexerConfigSupport(ensIndexerPublicConfig) && @@ -100,7 +96,8 @@ export function useStatefulRegistrarActions({ } satisfies StatefulFetchRegistrarActionsUnsupported; } - const { omnichainSnapshot } = indexingStatusQuery.data.realtimeProjection.snapshot; + const { realtimeProjection } = indexingStatusQuery.data; + const { omnichainSnapshot } = realtimeProjection.snapshot; // fetching is temporarily not possible due to indexing status being not advanced enough if (!hasIndexingStatusSupport(omnichainSnapshot.omnichainStatus)) {