diff --git a/apps/ensadmin/src/app/mock/indexing-stats/page.tsx b/apps/ensadmin/src/app/mock/indexing-stats/page.tsx index b169c2bd0..5e3d5cc4e 100644 --- a/apps/ensadmin/src/app/mock/indexing-stats/page.tsx +++ b/apps/ensadmin/src/app/mock/indexing-stats/page.tsx @@ -1,10 +1,12 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { getUnixTime } from "date-fns"; import { useEffect, useState } from "react"; import { - type IndexingStatusResponse, + CrossChainIndexingStatusSnapshot, + createRealtimeIndexingStatusProjection, IndexingStatusResponseOk, OmnichainIndexingStatusIds, } from "@ensnode/ensnode-sdk"; @@ -13,10 +15,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 +36,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 +47,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 +71,12 @@ export default function MockIndexingStatusPage() { const [selectedVariant, setSelectedVariant] = useState( OmnichainIndexingStatusIds.Unstarted, ); + const now = getUnixTime(new Date()); const mockedIndexingStatus = useQuery({ queryKey: ["mock", "useIndexingStatus", selectedVariant], queryFn: () => fetchMockedIndexingStatus(selectedVariant), + select: (cachedSnapshot) => createRealtimeIndexingStatusProjection(cachedSnapshot, now), retry: false, // allows loading error to be observed immediately }); diff --git a/apps/ensadmin/src/components/datetime-utils/index.tsx b/apps/ensadmin/src/components/datetime-utils/index.tsx index 9042a0ea3..310a96af5 100644 --- a/apps/ensadmin/src/components/datetime-utils/index.tsx +++ b/apps/ensadmin/src/components/datetime-utils/index.tsx @@ -104,9 +104,18 @@ export function RelativeTime({ const [relativeTime, setRelativeTime] = useState(""); useEffect(() => { - setRelativeTime( - formatRelativeTime(timestamp, enforcePast, includeSeconds, conciseFormatting, relativeTo), - ); + const updateTime = () => { + setRelativeTime( + formatRelativeTime(timestamp, enforcePast, includeSeconds, conciseFormatting, relativeTo), + ); + }; + + updateTime(); + + if (includeSeconds) { + const interval = setInterval(updateTime, 1000); + return () => clearInterval(interval); + } }, [timestamp, conciseFormatting, enforcePast, includeSeconds, relativeTo]); const tooltipTriggerContent = ( diff --git a/apps/ensadmin/src/components/indexing-status/indexing-stats.tsx b/apps/ensadmin/src/components/indexing-status/indexing-stats.tsx index 33f019769..cf82a9d55 100644 --- a/apps/ensadmin/src/components/indexing-status/indexing-stats.tsx +++ b/apps/ensadmin/src/components/indexing-status/indexing-stats.tsx @@ -25,7 +25,6 @@ import { import { ChainIcon } from "@/components/chains/ChainIcon"; import { ChainName } from "@/components/chains/ChainName"; -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"; @@ -34,6 +33,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 { useIndexingStatusWithSwr } from "./use-indexing-status-with-swr"; interface IndexingStatsForOmnichainStatusSnapshotProps< OmnichainIndexingStatusSnapshotType extends @@ -323,15 +324,18 @@ export function IndexingStatsForSnapshotFollowing({ * UI component for presenting indexing stats UI for specific overall status. */ export function IndexingStatsShell({ - omnichainStatus, + realtimeProjection, children, -}: PropsWithChildren<{ omnichainStatus?: OmnichainIndexingStatusId }>) { +}: PropsWithChildren<{ realtimeProjection?: RealtimeIndexingStatusProjection }>) { + const omnichainStatus = realtimeProjection?.snapshot.omnichainSnapshot.omnichainStatus; return ( Indexing Status + {realtimeProjection && } + {omnichainStatus && ( {maybeIndexingTimeline} - + {indexingStats} @@ -450,11 +454,5 @@ export function IndexingStats(props: IndexingStatsProps) { return ; } - const indexingStatus = 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..ecd22cdd6 --- /dev/null +++ b/apps/ensadmin/src/components/indexing-status/projection-info.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { InfoIcon } from "lucide-react"; + +import type { RealtimeIndexingStatusProjection } from "@ensnode/ensnode-sdk"; + +import { RelativeTime } from "@/components/datetime-utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +interface ProjectionInfoProps { + realtimeProjection: RealtimeIndexingStatusProjection; +} + +/** + * 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({ realtimeProjection }: ProjectionInfoProps) { + const { projectedAt, snapshot, worstCaseDistance } = realtimeProjection; + const { snapshotTime } = snapshot; + + return ( + + + + + +
+
+
+ Worst-Case Distance* +
+
+ {worstCaseDistance !== null ? `${worstCaseDistance} seconds` : "N/A"} +
+
+ +
+ * as of 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..c692374b9 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,6 @@ "use client"; +import { secondsToMilliseconds } from "date-fns"; import { useCallback, useMemo } from "react"; import { @@ -7,20 +8,25 @@ import { QueryParameter, useENSNodeSDKConfig, type useIndexingStatus, + useNow, useSwrQuery, WithSDKConfigParameter, } from "@ensnode/ensnode-react"; import { + CrossChainIndexingStatusSnapshotOmnichain, + createRealtimeIndexingStatusProjection, type IndexingStatusRequest, IndexingStatusResponseCodes, - IndexingStatusResponseOk, + RealtimeIndexingStatusProjection, } from "@ensnode/ensnode-sdk"; -const DEFAULT_REFETCH_INTERVAL = 10 * 1000; +const DEFAULT_REFETCH_INTERVAL = secondsToMilliseconds(10); + +const REALTIME_PROJECTION_REFRESH_RATE = secondsToMilliseconds(1); interface UseIndexingStatusParameters extends IndexingStatusRequest, - QueryParameter {} + QueryParameter {} /** * A proxy hook for {@link useIndexingStatus} which applies @@ -31,6 +37,7 @@ export function useIndexingStatusWithSwr( ) { const { config, query = {} } = parameters; const _config = useENSNodeSDKConfig(config); + const now = useNow(REALTIME_PROJECTION_REFRESH_RATE); const queryOptions = useMemo(() => createIndexingStatusQueryOptions(_config), [_config]); const queryKey = useMemo(() => ["swr", ...queryOptions.queryKey], [queryOptions.queryKey]); @@ -46,12 +53,28 @@ 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, + ): RealtimeIndexingStatusProjection => { + const realtimeProjection = createRealtimeIndexingStatusProjection(cachedSnapshot, now); + + return realtimeProjection; + }, + [now], + ); + return useSwrQuery({ ...queryOptions, refetchInterval: query.refetchInterval ?? DEFAULT_REFETCH_INTERVAL, // Indexing status changes frequently @@ -59,5 +82,6 @@ export function useIndexingStatusWithSwr( 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 7566b6e65..5229e84b1 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 @@ -44,13 +44,9 @@ 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 { omnichainSnapshot } = indexingStatusQuery.data.snapshot; isRegistrarActionsApiSupported = hasEnsIndexerConfigSupport(ensIndexerPublicConfig) && @@ -100,7 +96,7 @@ export function useStatefulRegistrarActions({ } satisfies StatefulFetchRegistrarActionsUnsupported; } - const { omnichainSnapshot } = indexingStatusQuery.data.realtimeProjection.snapshot; + const { omnichainSnapshot } = indexingStatusQuery.data.snapshot; // fetching is temporarily not possible due to indexing status being not advanced enough if (!hasIndexingStatusSupport(omnichainSnapshot.omnichainStatus)) { diff --git a/packages/ensnode-react/package.json b/packages/ensnode-react/package.json index e9f56a286..a667dfa0e 100644 --- a/packages/ensnode-react/package.json +++ b/packages/ensnode-react/package.json @@ -57,6 +57,7 @@ "vitest": "catalog:" }, "dependencies": { - "@ensnode/ensnode-sdk": "workspace:*" + "@ensnode/ensnode-sdk": "workspace:*", + "date-fns": "catalog:" } } diff --git a/packages/ensnode-react/src/hooks/index.ts b/packages/ensnode-react/src/hooks/index.ts index fe3f89ecc..c44f772e5 100644 --- a/packages/ensnode-react/src/hooks/index.ts +++ b/packages/ensnode-react/src/hooks/index.ts @@ -1,6 +1,7 @@ export * from "./useENSNodeConfig"; export * from "./useENSNodeSDKConfig"; export * from "./useIndexingStatus"; +export * from "./useNow"; export * from "./usePrimaryName"; export * from "./usePrimaryNames"; export * from "./useRecords"; diff --git a/packages/ensnode-react/src/hooks/useNow.ts b/packages/ensnode-react/src/hooks/useNow.ts new file mode 100644 index 000000000..2458c0f36 --- /dev/null +++ b/packages/ensnode-react/src/hooks/useNow.ts @@ -0,0 +1,31 @@ +import { getUnixTime } from "date-fns"; +import { useEffect, useState } from "react"; + +/** + * Hook that returns the current Unix timestamp, updated at a specified interval. + * + * @param refreshRate - How often to update the timestamp in milliseconds (default: 1000ms) + * @returns Current Unix timestamp that updates every refreshRate milliseconds + * + * @example + * ```tsx + * // Updates every second + * const now = useNow(1000); + * + * // Updates every 5 seconds + * const now = useNow(5000); + * ``` + */ +export function useNow(refreshRate = 1000): number { + const [now, setNow] = useState(() => getUnixTime(new Date())); + + useEffect(() => { + const interval = setInterval(() => { + setNow(getUnixTime(new Date())); + }, refreshRate); + + return () => clearInterval(interval); + }, [refreshRate]); + + return now; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ba22da63..1d370d676 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -697,6 +697,9 @@ importers: '@ensnode/ensnode-sdk': specifier: workspace:* version: link:../ensnode-sdk + date-fns: + specifier: 'catalog:' + version: 4.1.0 react: specifier: ^18.0.0 || ^19.0.0 version: 19.2.0