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
5 changes: 5 additions & 0 deletions .changeset/sweet-cities-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensadmin": minor
---

Updates `useIndexingStatusWithSwr` to always return current realtime indexing status projection.
5 changes: 5 additions & 0 deletions .changeset/wild-results-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensadmin": minor
---

Includes `ProjectionInfo` component on Indexing Status page.
30 changes: 21 additions & 9 deletions apps/ensadmin/src/app/mock/indexing-stats/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -37,7 +37,7 @@ let loadingTimeoutId: number;

async function fetchMockedIndexingStatus(
selectedVariant: Variant,
): Promise<IndexingStatusResponseOk> {
): Promise<CrossChainIndexingStatusSnapshot> {
// 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) {
Expand All @@ -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<IndexingStatusResponseOk>((_resolve, reject) => {
return new Promise<CrossChainIndexingStatusSnapshot>((_resolve, reject) => {
loadingTimeoutId = +setTimeout(reject, 5 * 60 * 1_000);
});
case "Loading Error":
Expand All @@ -67,10 +72,17 @@ export default function MockIndexingStatusPage() {
const [selectedVariant, setSelectedVariant] = useState<Variant>(
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
});

Expand Down
63 changes: 36 additions & 27 deletions apps/ensadmin/src/components/indexing-status/indexing-stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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 (
<Card className="w-full flex flex-col gap-2">
<CardHeader>
<CardTitle className="flex gap-2 items-center">
<span>Indexing Status</span>

{omnichainStatus && (
<Badge
className={cn("uppercase text-xs leading-none")}
title={`Omnichain indexing status: ${formatOmnichainIndexingStatus(omnichainStatus)}`}
>
{formatOmnichainIndexingStatus(omnichainStatus)}
</Badge>
)}
</CardTitle>
<CardTitle className="flex gap-2 items-center">{title}</CardTitle>
</CardHeader>

<CardContent className="flex flex-col gap-8">
Expand Down Expand Up @@ -418,11 +408,34 @@ export function IndexingStatsForRealtimeStatusProjection({
break;
}

const { snapshot, worstCaseDistance } = realtimeProjection;
const { omnichainSnapshot, snapshotTime } = snapshot;
const omnichainStatus = omnichainSnapshot.omnichainStatus;

return (
<section className="flex flex-col gap-6">
{maybeIndexingTimeline}

<IndexingStatsShell omnichainStatus={omnichainStatusSnapshot.omnichainStatus}>
<IndexingStatsShell
title={
<>
<span>Indexing Status</span>

<ProjectionInfo
omnichainIndexingCursor={omnichainSnapshot.omnichainIndexingCursor}
snapshotTime={snapshotTime}
worstCaseDistance={worstCaseDistance}
/>

<Badge
className={cn("uppercase text-xs leading-none")}
title={`Omnichain indexing status: ${formatOmnichainIndexingStatus(omnichainStatus)}`}
>
{formatOmnichainIndexingStatus(omnichainStatus)}
</Badge>
</>
}
>
{indexingStats}
</IndexingStatsShell>
</section>
Expand All @@ -439,7 +452,7 @@ export function IndexingStats(props: IndexingStatsProps) {

if (indexingStatusQuery.isError) {
return (
<IndexingStatsShell>
<IndexingStatsShell title="Indexing Status Unavailable">
<IndexingStatsForUnavailableSnapshot />
</IndexingStatsShell>
);
Expand All @@ -449,11 +462,7 @@ export function IndexingStats(props: IndexingStatsProps) {
return <IndexingStatusLoading />;
}

const indexingStatus = indexingStatusQuery.data;
const { realtimeProjection } = indexingStatusQuery.data;

return (
<IndexingStatsForRealtimeStatusProjection
realtimeProjection={indexingStatus.realtimeProjection}
/>
);
return <IndexingStatsForRealtimeStatusProjection realtimeProjection={realtimeProjection} />;
}
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please optimize the hover state. The way there's a rectangular light-grey panel appearing in the background on hover doesn't feel good.

<InfoIcon className="shrink-0 text-[#9CA3AF] w-4 h-4" />
</TooltipTrigger>
<TooltipContent
side="right"
className="bg-gray-50 text-sm text-black shadow-md outline-none w-80 p-4"
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="font-semibold text-xs text-gray-500 uppercase">
Worst-Case Distance*
</div>
<div className="text-sm">
{formatWorstCaseDistance(worstCaseDistance, omnichainIndexingCursor)}
</div>
</div>

<div className="text-xs text-gray-600 leading-relaxed">
* as of the real-time projection generated just now from indexing status snapshot
captured{" "}
<RelativeTime
timestamp={snapshotTime}
relativeTo={now}
includeSeconds
conciseFormatting
/>
.
</div>
</div>
</TooltipContent>
</Tooltip>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client";

import { useNow } from "@namehash/namehash-ui";
import { secondsToMilliseconds } from "date-fns";
import { useCallback, useMemo } from "react";

import {
Expand All @@ -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<IndexingStatusResponseOk> {}
QueryParameter<CrossChainIndexingStatusSnapshotOmnichain> {}

/**
* A proxy hook for {@link useIndexingStatus} which applies
Expand All @@ -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]);
Expand All @@ -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,
});
}
Loading