Skip to content

Commit 3bbae76

Browse files
committed
feat(ensadmin): include projection info on status page
Also, make `<RelativeTime includeSeconds />` component to re-render every second to present relative time to the current time.
1 parent abbd193 commit 3bbae76

10 files changed

Lines changed: 160 additions & 36 deletions

File tree

apps/ensadmin/src/app/mock/indexing-stats/page.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"use client";
22

33
import { useQuery } from "@tanstack/react-query";
4+
import { getUnixTime } from "date-fns";
45
import { useEffect, useState } from "react";
56

67
import {
7-
type IndexingStatusResponse,
8+
CrossChainIndexingStatusSnapshot,
9+
createRealtimeIndexingStatusProjection,
810
IndexingStatusResponseOk,
911
OmnichainIndexingStatusIds,
1012
} from "@ensnode/ensnode-sdk";
@@ -13,10 +15,7 @@ import { IndexingStats } from "@/components/indexing-status/indexing-stats";
1315
import { Button } from "@/components/ui/button";
1416
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
1517

16-
import {
17-
indexingStatusResponseError,
18-
indexingStatusResponseOkOmnichain,
19-
} from "../indexing-status-api.mock";
18+
import { indexingStatusResponseOkOmnichain } from "../indexing-status-api.mock";
2019

2120
type LoadingVariant = "Loading" | "Loading Error";
2221
type ResponseOkVariant = keyof typeof indexingStatusResponseOkOmnichain;
@@ -37,7 +36,7 @@ let loadingTimeoutId: number;
3736

3837
async function fetchMockedIndexingStatus(
3938
selectedVariant: Variant,
40-
): Promise<IndexingStatusResponseOk> {
39+
): Promise<CrossChainIndexingStatusSnapshot> {
4140
// always try clearing loading timeout when performing a mocked fetch
4241
// this way we get a fresh and very long request to observe the loading state
4342
if (loadingTimeoutId) {
@@ -48,14 +47,19 @@ async function fetchMockedIndexingStatus(
4847
case OmnichainIndexingStatusIds.Unstarted:
4948
case OmnichainIndexingStatusIds.Backfill:
5049
case OmnichainIndexingStatusIds.Following:
51-
case OmnichainIndexingStatusIds.Completed:
52-
return indexingStatusResponseOkOmnichain[selectedVariant] as IndexingStatusResponseOk;
50+
case OmnichainIndexingStatusIds.Completed: {
51+
const response = indexingStatusResponseOkOmnichain[
52+
selectedVariant
53+
] as IndexingStatusResponseOk;
54+
55+
return response.realtimeProjection.snapshot;
56+
}
5357
case "Error ResponseCode":
5458
throw new Error(
5559
"Received Indexing Status response with responseCode other than 'ok' which will not be cached.",
5660
);
5761
case "Loading":
58-
return new Promise<IndexingStatusResponseOk>((_resolve, reject) => {
62+
return new Promise<CrossChainIndexingStatusSnapshot>((_resolve, reject) => {
5963
loadingTimeoutId = +setTimeout(reject, 5 * 60 * 1_000);
6064
});
6165
case "Loading Error":
@@ -67,10 +71,12 @@ export default function MockIndexingStatusPage() {
6771
const [selectedVariant, setSelectedVariant] = useState<Variant>(
6872
OmnichainIndexingStatusIds.Unstarted,
6973
);
74+
const now = getUnixTime(new Date());
7075

7176
const mockedIndexingStatus = useQuery({
7277
queryKey: ["mock", "useIndexingStatus", selectedVariant],
7378
queryFn: () => fetchMockedIndexingStatus(selectedVariant),
79+
select: (cachedSnapshot) => createRealtimeIndexingStatusProjection(cachedSnapshot, now),
7480
retry: false, // allows loading error to be observed immediately
7581
});
7682

apps/ensadmin/src/components/datetime-utils/index.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,18 @@ export function RelativeTime({
104104
const [relativeTime, setRelativeTime] = useState<string>("");
105105

106106
useEffect(() => {
107-
setRelativeTime(
108-
formatRelativeTime(timestamp, enforcePast, includeSeconds, conciseFormatting, relativeTo),
109-
);
107+
const updateTime = () => {
108+
setRelativeTime(
109+
formatRelativeTime(timestamp, enforcePast, includeSeconds, conciseFormatting, relativeTo),
110+
);
111+
};
112+
113+
updateTime();
114+
115+
if (includeSeconds) {
116+
const interval = setInterval(updateTime, 1000);
117+
return () => clearInterval(interval);
118+
}
110119
}, [timestamp, conciseFormatting, enforcePast, includeSeconds, relativeTo]);
111120

112121
const tooltipTriggerContent = (

apps/ensadmin/src/components/indexing-status/indexing-stats.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import {
2525

2626
import { ChainIcon } from "@/components/chains/ChainIcon";
2727
import { ChainName } from "@/components/chains/ChainName";
28-
import { useIndexingStatusWithSwr } from "@/components/indexing-status/use-indexing-status-with-swr";
2928
import { Badge } from "@/components/ui/badge";
3029
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
3130
import { formatChainStatus, formatOmnichainIndexingStatus } from "@/lib/indexing-status";
@@ -34,6 +33,8 @@ import { cn } from "@/lib/utils";
3433
import { BackfillStatus } from "./backfill-status";
3534
import { BlockStats } from "./block-refs";
3635
import { IndexingStatusLoading } from "./indexing-status-loading";
36+
import { ProjectionInfo } from "./projection-info";
37+
import { useIndexingStatusWithSwr } from "./use-indexing-status-with-swr";
3738

3839
interface IndexingStatsForOmnichainStatusSnapshotProps<
3940
OmnichainIndexingStatusSnapshotType extends
@@ -323,15 +324,18 @@ export function IndexingStatsForSnapshotFollowing({
323324
* UI component for presenting indexing stats UI for specific overall status.
324325
*/
325326
export function IndexingStatsShell({
326-
omnichainStatus,
327+
realtimeProjection,
327328
children,
328-
}: PropsWithChildren<{ omnichainStatus?: OmnichainIndexingStatusId }>) {
329+
}: PropsWithChildren<{ realtimeProjection?: RealtimeIndexingStatusProjection }>) {
330+
const omnichainStatus = realtimeProjection?.snapshot.omnichainSnapshot.omnichainStatus;
329331
return (
330332
<Card className="w-full flex flex-col gap-2">
331333
<CardHeader>
332334
<CardTitle className="flex gap-2 items-center">
333335
<span>Indexing Status</span>
334336

337+
{realtimeProjection && <ProjectionInfo realtimeProjection={realtimeProjection} />}
338+
335339
{omnichainStatus && (
336340
<Badge
337341
className={cn("uppercase text-xs leading-none")}
@@ -423,7 +427,7 @@ export function IndexingStatsForRealtimeStatusProjection({
423427
<section className="flex flex-col gap-6">
424428
{maybeIndexingTimeline}
425429

426-
<IndexingStatsShell omnichainStatus={omnichainStatusSnapshot.omnichainStatus}>
430+
<IndexingStatsShell realtimeProjection={realtimeProjection}>
427431
{indexingStats}
428432
</IndexingStatsShell>
429433
</section>
@@ -450,11 +454,5 @@ export function IndexingStats(props: IndexingStatsProps) {
450454
return <IndexingStatusLoading />;
451455
}
452456

453-
const indexingStatus = indexingStatusQuery.data;
454-
455-
return (
456-
<IndexingStatsForRealtimeStatusProjection
457-
realtimeProjection={indexingStatus.realtimeProjection}
458-
/>
459-
);
457+
return <IndexingStatsForRealtimeStatusProjection realtimeProjection={indexingStatusQuery.data} />;
460458
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"use client";
2+
3+
import { InfoIcon } from "lucide-react";
4+
5+
import type { RealtimeIndexingStatusProjection } from "@ensnode/ensnode-sdk";
6+
7+
import { RelativeTime } from "@/components/datetime-utils";
8+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
9+
10+
interface ProjectionInfoProps {
11+
realtimeProjection: RealtimeIndexingStatusProjection;
12+
}
13+
14+
/**
15+
* Displays metadata about the current indexing status projection in a tooltip.
16+
* Shows when the projection was generated, when the snapshot was taken, and worst-case distance.
17+
*/
18+
export function ProjectionInfo({ realtimeProjection }: ProjectionInfoProps) {
19+
const { projectedAt, snapshot, worstCaseDistance } = realtimeProjection;
20+
const { snapshotTime } = snapshot;
21+
22+
return (
23+
<Tooltip delayDuration={300}>
24+
<TooltipTrigger asChild>
25+
<button
26+
type="button"
27+
className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground h-8 w-8"
28+
aria-label="Indexing Status Metadata"
29+
>
30+
<InfoIcon className="h-4 w-4" />
31+
</button>
32+
</TooltipTrigger>
33+
<TooltipContent
34+
side="right"
35+
className="bg-gray-50 text-sm text-black shadow-md outline-none w-80 p-4"
36+
>
37+
<div className="flex flex-col gap-3">
38+
<div className="flex flex-col gap-1">
39+
<div className="font-semibold text-xs text-gray-500 uppercase">
40+
Worst-Case Distance*
41+
</div>
42+
<div className="text-sm">
43+
{worstCaseDistance !== null ? `${worstCaseDistance} seconds` : "N/A"}
44+
</div>
45+
</div>
46+
47+
<div className="text-xs text-gray-600 leading-relaxed">
48+
* as of real-time projection generated just now from indexing status snapshot captured{" "}
49+
<RelativeTime timestamp={snapshotTime} includeSeconds conciseFormatting />.
50+
</div>
51+
</div>
52+
</TooltipContent>
53+
</Tooltip>
54+
);
55+
}

apps/ensadmin/src/components/indexing-status/use-indexing-status-with-swr.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,32 @@
11
"use client";
22

3+
import { secondsToMilliseconds } from "date-fns";
34
import { useCallback, useMemo } from "react";
45

56
import {
67
createIndexingStatusQueryOptions,
78
QueryParameter,
89
useENSNodeSDKConfig,
910
type useIndexingStatus,
11+
useNow,
1012
useSwrQuery,
1113
WithSDKConfigParameter,
1214
} from "@ensnode/ensnode-react";
1315
import {
16+
CrossChainIndexingStatusSnapshotOmnichain,
17+
createRealtimeIndexingStatusProjection,
1418
type IndexingStatusRequest,
1519
IndexingStatusResponseCodes,
16-
IndexingStatusResponseOk,
20+
RealtimeIndexingStatusProjection,
1721
} from "@ensnode/ensnode-sdk";
1822

19-
const DEFAULT_REFETCH_INTERVAL = 10 * 1000;
23+
const DEFAULT_REFETCH_INTERVAL = secondsToMilliseconds(10);
24+
25+
const REALTIME_PROJECTION_REFRESH_RATE = secondsToMilliseconds(1);
2026

2127
interface UseIndexingStatusParameters
2228
extends IndexingStatusRequest,
23-
QueryParameter<IndexingStatusResponseOk> {}
29+
QueryParameter<CrossChainIndexingStatusSnapshotOmnichain> {}
2430

2531
/**
2632
* A proxy hook for {@link useIndexingStatus} which applies
@@ -31,6 +37,7 @@ export function useIndexingStatusWithSwr(
3137
) {
3238
const { config, query = {} } = parameters;
3339
const _config = useENSNodeSDKConfig(config);
40+
const now = useNow(REALTIME_PROJECTION_REFRESH_RATE);
3441

3542
const queryOptions = useMemo(() => createIndexingStatusQueryOptions(_config), [_config]);
3643
const queryKey = useMemo(() => ["swr", ...queryOptions.queryKey], [queryOptions.queryKey]);
@@ -46,18 +53,35 @@ export function useIndexingStatusWithSwr(
4653
);
4754
}
4855

49-
// successful response to be cached
50-
return response;
56+
// The indexing status snapshot has been fetched and successfully validated for caching.
57+
// Therefore, return it so that query cache for `queryOptions.queryKey` will:
58+
// - Replace the currently cached value (if any) with this new value.
59+
// - Return this non-null value.
60+
return response.realtimeProjection.snapshot;
5161
}),
5262
[queryOptions.queryFn],
5363
);
5464

65+
// Call select function to `createRealtimeIndexingStatusProjection` each time
66+
// `now` is updated.
67+
const select = useCallback(
68+
(
69+
cachedSnapshot: CrossChainIndexingStatusSnapshotOmnichain,
70+
): RealtimeIndexingStatusProjection => {
71+
const realtimeProjection = createRealtimeIndexingStatusProjection(cachedSnapshot, now);
72+
73+
return realtimeProjection;
74+
},
75+
[now],
76+
);
77+
5578
return useSwrQuery({
5679
...queryOptions,
5780
refetchInterval: query.refetchInterval ?? DEFAULT_REFETCH_INTERVAL, // Indexing status changes frequently
5881
...query,
5982
enabled: query.enabled ?? queryOptions.enabled,
6083
queryKey,
6184
queryFn,
85+
select,
6286
});
6387
}

apps/ensadmin/src/components/registrar-actions/use-stateful-fetch-registrar-actions.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,9 @@ export function useStatefulRegistrarActions({
4444

4545
let isRegistrarActionsApiSupported = false;
4646

47-
if (
48-
ensNodeConfigQuery.isSuccess &&
49-
indexingStatusQuery.isSuccess &&
50-
indexingStatusQuery.data.responseCode === IndexingStatusResponseCodes.Ok
51-
) {
47+
if (ensNodeConfigQuery.isSuccess && indexingStatusQuery.isSuccess) {
5248
const { ensIndexerPublicConfig } = ensNodeConfigQuery.data;
53-
const { omnichainSnapshot } = indexingStatusQuery.data.realtimeProjection.snapshot;
49+
const { omnichainSnapshot } = indexingStatusQuery.data.snapshot;
5450

5551
isRegistrarActionsApiSupported =
5652
hasEnsIndexerConfigSupport(ensIndexerPublicConfig) &&
@@ -100,7 +96,7 @@ export function useStatefulRegistrarActions({
10096
} satisfies StatefulFetchRegistrarActionsUnsupported;
10197
}
10298

103-
const { omnichainSnapshot } = indexingStatusQuery.data.realtimeProjection.snapshot;
99+
const { omnichainSnapshot } = indexingStatusQuery.data.snapshot;
104100

105101
// fetching is temporarily not possible due to indexing status being not advanced enough
106102
if (!hasIndexingStatusSupport(omnichainSnapshot.omnichainStatus)) {

packages/ensnode-react/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"vitest": "catalog:"
5858
},
5959
"dependencies": {
60-
"@ensnode/ensnode-sdk": "workspace:*"
60+
"@ensnode/ensnode-sdk": "workspace:*",
61+
"date-fns": "catalog:"
6162
}
6263
}

packages/ensnode-react/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from "./useENSNodeConfig";
22
export * from "./useENSNodeSDKConfig";
33
export * from "./useIndexingStatus";
4+
export * from "./useNow";
45
export * from "./usePrimaryName";
56
export * from "./usePrimaryNames";
67
export * from "./useRecords";
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { getUnixTime } from "date-fns";
2+
import { useEffect, useState } from "react";
3+
4+
/**
5+
* Hook that returns the current Unix timestamp, updated at a specified interval.
6+
*
7+
* @param refreshRate - How often to update the timestamp in milliseconds (default: 1000ms)
8+
* @returns Current Unix timestamp that updates every refreshRate milliseconds
9+
*
10+
* @example
11+
* ```tsx
12+
* // Updates every second
13+
* const now = useNow(1000);
14+
*
15+
* // Updates every 5 seconds
16+
* const now = useNow(5000);
17+
* ```
18+
*/
19+
export function useNow(refreshRate = 1000): number {
20+
const [now, setNow] = useState(() => getUnixTime(new Date()));
21+
22+
useEffect(() => {
23+
const interval = setInterval(() => {
24+
setNow(getUnixTime(new Date()));
25+
}, refreshRate);
26+
27+
return () => clearInterval(interval);
28+
}, [refreshRate]);
29+
30+
return now;
31+
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)