diff --git a/components/allowlist-tool/allowlist-tool.types.ts b/components/allowlist-tool/allowlist-tool.types.ts
index a3029ac558..bbcc3e1f02 100644
--- a/components/allowlist-tool/allowlist-tool.types.ts
+++ b/components/allowlist-tool/allowlist-tool.types.ts
@@ -85,8 +85,7 @@ interface AllowlistPhaseComponentItem {
readonly tokensCount: number;
}
-interface AllowlistPhaseComponentWithItems
- extends AllowlistPhaseComponent {
+interface AllowlistPhaseComponentWithItems extends AllowlistPhaseComponent {
readonly items: AllowlistPhaseComponentItem[];
}
@@ -127,6 +126,7 @@ export enum AllowlistOperationCode {
export interface AllowlistOperationBase {
readonly code: AllowlistOperationCode;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly params: Record;
}
@@ -187,6 +187,19 @@ export enum DistributionPlanTokenPoolDownloadStatus {
COMPLETED = "COMPLETED",
}
+export enum DistributionPlanTokenPoolDownloadStage {
+ PREPARING = "PREPARING",
+ REQUEUED = "REQUEUED",
+ CLAIMED = "CLAIMED",
+ CHECKING_ALCHEMY = "CHECKING_ALCHEMY",
+ INDEXING_SINGLE = "INDEXING_SINGLE",
+ INDEXING_BATCH = "INDEXING_BATCH",
+ BUILDING_TOKEN_OWNERS = "BUILDING_TOKEN_OWNERS",
+ PERSISTING_RESULTS = "PERSISTING_RESULTS",
+ COMPLETED = "COMPLETED",
+ FAILED = "FAILED",
+}
+
export interface DistributionPlanTokenPoolDownload {
readonly contract: string;
readonly tokenIds?: string | undefined;
@@ -194,6 +207,23 @@ export interface DistributionPlanTokenPoolDownload {
readonly allowlistId: string;
readonly blockNo: number;
readonly status: DistributionPlanTokenPoolDownloadStatus;
+ readonly rawStatus: DistributionPlanTokenPoolDownloadStatus;
+ readonly stale: boolean;
+ readonly retryable: boolean;
+ readonly errorReason?: string | null;
+ readonly stage?: DistributionPlanTokenPoolDownloadStage | undefined;
+ readonly progress?: Record | undefined;
+ readonly attemptCount: number;
+ readonly failureCount: number;
+ readonly createdAt?: number | undefined;
+ readonly updatedAt?: number | undefined;
+ readonly claimedAt?: number | undefined;
+ readonly lastHeartbeatAt?: number | undefined;
+ readonly completedAt?: number | undefined;
+ readonly failedAt?: number | undefined;
+ readonly lastFailureAt?: number | undefined;
+ readonly lastFailureReason?: string | null;
+ readonly consolidateBlockNo?: number | null;
}
export interface DistributionPlanSnapshotToken {
diff --git a/components/distribution-plan-tool/common/DistributionPlanNextStepBtn.tsx b/components/distribution-plan-tool/common/DistributionPlanNextStepBtn.tsx
index 17b2c76952..84f990d6b2 100644
--- a/components/distribution-plan-tool/common/DistributionPlanNextStepBtn.tsx
+++ b/components/distribution-plan-tool/common/DistributionPlanNextStepBtn.tsx
@@ -10,12 +10,14 @@ export default function DistributionPlanNextStepBtn({
loading,
showNextBtn,
showSkipBtn,
+ disableNextBtn = false,
}: {
showRunAnalysisBtn: boolean;
onNextStep: () => void;
loading: boolean;
showNextBtn: boolean;
showSkipBtn: boolean;
+ disableNextBtn?: boolean;
}) {
const { runOperations } = useContext(DistributionPlanToolContext);
return (
@@ -24,7 +26,8 @@ export default function DistributionPlanNextStepBtn({
+ className="tw-cursor-pointer tw-rounded-lg tw-border-2 tw-border-solid tw-border-iron-700 tw-bg-transparent tw-px-4 tw-py-3 tw-text-sm tw-font-medium tw-text-white tw-transition tw-duration-300 tw-ease-out hover:tw-bg-iron-800/80"
+ >
Skip
)}
@@ -32,16 +35,18 @@ export default function DistributionPlanNextStepBtn({
+ className="tw-inline-flex tw-cursor-pointer tw-items-center tw-justify-center tw-rounded-lg tw-border-2 tw-border-solid tw-border-iron-700 tw-bg-transparent tw-px-4 tw-py-3 tw-text-sm tw-font-medium tw-text-white tw-transition tw-duration-300 tw-ease-out hover:tw-bg-iron-800/80"
+ >
Run analysis
)}
{!showRunAnalysisBtn && showNextBtn && (
+ onClick={onNextStep}
+ >
Next
)}
diff --git a/components/distribution-plan-tool/create-snapshots/CreateSnapshots.tsx b/components/distribution-plan-tool/create-snapshots/CreateSnapshots.tsx
index 0b537e530c..eaaaf499b6 100644
--- a/components/distribution-plan-tool/create-snapshots/CreateSnapshots.tsx
+++ b/components/distribution-plan-tool/create-snapshots/CreateSnapshots.tsx
@@ -19,6 +19,16 @@ import {
import CreateSnapshotForm from "./form/CreateSnapshotForm";
import CreateSnapshotTable from "./table/CreateSnapshotTable";
+interface CreateTokenPoolOperationParams {
+ readonly id: string;
+ readonly name: string;
+ readonly description: string;
+ readonly tokenIds?: string | undefined;
+ readonly contract?: string | undefined;
+ readonly blockNo?: number | undefined;
+ readonly consolidateBlockNo?: number | null | undefined;
+}
+
export interface CreateSnapshotSnapshot {
id: string;
name: string;
@@ -29,118 +39,196 @@ export interface CreateSnapshotSnapshot {
contract: string | null;
blockNo: number | null;
consolidateBlockNo: number | null;
- downloaderStatus: DistributionPlanTokenPoolDownloadStatus | null;
+ download: DistributionPlanTokenPoolDownload | null;
allowlistId: string;
order: number;
}
+const isObjectRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null;
+
+const toCreateTokenPoolOperationParams = (
+ value: unknown
+): CreateTokenPoolOperationParams | null => {
+ if (!isObjectRecord(value)) {
+ return null;
+ }
+
+ const {
+ id,
+ name,
+ description,
+ tokenIds,
+ contract,
+ blockNo,
+ consolidateBlockNo,
+ } = value;
+
+ if (
+ typeof id !== "string" ||
+ typeof name !== "string" ||
+ typeof description !== "string"
+ ) {
+ return null;
+ }
+
+ return {
+ id,
+ name,
+ description,
+ tokenIds: typeof tokenIds === "string" ? tokenIds : undefined,
+ contract: typeof contract === "string" ? contract : undefined,
+ blockNo: typeof blockNo === "number" ? blockNo : undefined,
+ consolidateBlockNo:
+ typeof consolidateBlockNo === "number" || consolidateBlockNo === null
+ ? consolidateBlockNo
+ : undefined,
+ };
+};
+
+const loadTokenPoolDownloads = async (
+ allowlistId: string
+): Promise => {
+ const endpoint = `/allowlists/${allowlistId}/token-pool-downloads`;
+ const { success, data } =
+ await distributionPlanApiFetch(
+ endpoint
+ );
+ if (!success) {
+ return null;
+ }
+ return data;
+};
+
export default function CreateSnapshots() {
const { setStep, distributionPlan, operations, tokenPools } = useContext(
DistributionPlanToolContext
);
useEffect(() => {
- if (!distributionPlan) setStep(DistributionPlanToolStep.CREATE_PLAN);
+ if (distributionPlan === null) {
+ setStep(DistributionPlanToolStep.CREATE_PLAN);
+ }
}, [distributionPlan, setStep]);
- const [haveUnRunOperations, setHaveUnRunOperations] = useState(false);
-
- useEffect(() => {
- setHaveUnRunOperations(
- !!operations.filter(
- (operation) =>
- operation.code === AllowlistOperationCode.CREATE_TOKEN_POOL &&
- operation.hasRan === false
- ).length
- );
- }, [operations]);
-
- const [snapshots, setSnapshots] = useState([]);
const [tokenPoolDownloads, setTokenPoolDownloads] = useState<
DistributionPlanTokenPoolDownload[]
>([]);
- useEffect(() => {
- const generateSnapshots = () => {
- const createTokenPoolOperations = operations.filter(
- (operation) =>
- operation.code === AllowlistOperationCode.CREATE_TOKEN_POOL
- );
- return createTokenPoolOperations.map((createTokenPoolOperation) => {
- const tokenPool =
- tokenPools.find(
- (tp) => tp.id === createTokenPoolOperation.params["id"]
- ) ?? null;
-
- const tokenPoolDownload = tokenPoolDownloads.find(
- (tokenPoolDownload) =>
- tokenPoolDownload.tokenPoolId ===
- createTokenPoolOperation.params["id"]
- );
- return {
- id: createTokenPoolOperation.params["id"],
- name: createTokenPoolOperation.params["name"],
- description: createTokenPoolOperation.params["description"],
- tokenIds: createTokenPoolOperation.params["tokenIds"],
- walletsCount: tokenPool?.walletsCount ?? null,
- tokensCount: tokenPool?.tokensCount ?? null,
- contract: createTokenPoolOperation?.params["contract"] ?? null,
- blockNo: createTokenPoolOperation?.params["blockNo"] ?? null,
- consolidateBlockNo:
- createTokenPoolOperation?.params["consolidateBlockNo"] ?? null,
- downloaderStatus: tokenPoolDownload?.status ?? null,
- allowlistId: createTokenPoolOperation.allowlistId,
- order: createTokenPoolOperation.order,
- };
- });
- };
- setSnapshots(generateSnapshots());
- }, [operations, tokenPools, tokenPoolDownloads]);
+ const haveUnRunOperations = operations.some(
+ (operation) =>
+ operation.code === AllowlistOperationCode.CREATE_TOKEN_POOL &&
+ operation.hasRan === false
+ );
- const [haveSnapshots, setHaveSnapshots] = useState(false);
- const [shouldRunDownloadCheck, setShouldRunDownloadCheck] = useState(false);
+ const snapshots = operations
+ .filter(
+ (operation) => operation.code === AllowlistOperationCode.CREATE_TOKEN_POOL
+ )
+ .map((createTokenPoolOperation) => {
+ const params = toCreateTokenPoolOperationParams(
+ createTokenPoolOperation.params
+ );
+ if (params === null) {
+ return null;
+ }
- useEffect(() => {
- if (!distributionPlan) {
- setShouldRunDownloadCheck(false);
- }
+ const tokenPool = tokenPools.find((tp) => tp.id === params.id) ?? null;
+ const tokenPoolDownload =
+ tokenPoolDownloads.find(
+ (download) => download.tokenPoolId === params.id
+ ) ?? null;
- const haveActiveDownloads = snapshots.some(
- (snapshot) =>
- !snapshot.downloaderStatus ||
- [
- DistributionPlanTokenPoolDownloadStatus.PENDING,
- DistributionPlanTokenPoolDownloadStatus.CLAIMED,
- ].includes(snapshot.downloaderStatus)
+ return {
+ id: params.id,
+ name: params.name,
+ description: params.description,
+ tokenIds: params.tokenIds ?? null,
+ walletsCount: tokenPool?.walletsCount ?? null,
+ tokensCount: tokenPool?.tokensCount ?? null,
+ contract: params.contract ?? null,
+ blockNo: params.blockNo ?? null,
+ consolidateBlockNo: params.consolidateBlockNo ?? null,
+ download: tokenPoolDownload,
+ allowlistId: createTokenPoolOperation.allowlistId,
+ order: createTokenPoolOperation.order,
+ };
+ })
+ .filter(
+ (snapshot): snapshot is CreateSnapshotSnapshot => snapshot !== null
);
- setShouldRunDownloadCheck(haveActiveDownloads);
- }, [distributionPlan, snapshots]);
-
- useEffect(() => {
- setHaveSnapshots(!!snapshots.length);
- }, [snapshots]);
const fetchTokenPoolStatuses = async () => {
- if (!distributionPlan) return;
- const endpoint = `/allowlists/${distributionPlan.id}/token-pool-downloads`;
- const { success, data } =
- await distributionPlanApiFetch(
- endpoint
- );
- if (success && data) {
+ if (distributionPlan === null) {
+ return;
+ }
+ const data = await loadTokenPoolDownloads(distributionPlan.id);
+ if (data !== null) {
setTokenPoolDownloads(data);
}
};
useEffect(() => {
- fetchTokenPoolStatuses();
- }, []);
+ if (distributionPlan === null) {
+ return;
+ }
+
+ const timeoutId = globalThis.setTimeout(() => {
+ void (async () => {
+ const data = await loadTokenPoolDownloads(distributionPlan.id);
+ if (data !== null) {
+ setTokenPoolDownloads(data);
+ }
+ })();
+ }, 0);
+
+ return () => {
+ globalThis.clearTimeout(timeoutId);
+ };
+ }, [distributionPlan]);
+
+ const activeSnapshots = snapshots.filter((snapshot) => {
+ const rawStatus = snapshot.download?.rawStatus ?? null;
+ return (
+ !snapshot.download ||
+ rawStatus === DistributionPlanTokenPoolDownloadStatus.PENDING ||
+ rawStatus === DistributionPlanTokenPoolDownloadStatus.CLAIMED
+ );
+ });
+
+ const unresolvedSnapshots = snapshots.filter(
+ (snapshot) =>
+ snapshot.download?.status !==
+ DistributionPlanTokenPoolDownloadStatus.COMPLETED
+ );
+
+ const failedSnapshots = unresolvedSnapshots.filter(
+ (snapshot) =>
+ snapshot.download?.status ===
+ DistributionPlanTokenPoolDownloadStatus.FAILED
+ );
+
+ const haveSnapshots = !!snapshots.length;
+ const allSnapshotsCompleted =
+ haveSnapshots &&
+ snapshots.every(
+ (snapshot) =>
+ snapshot.download?.status ===
+ DistributionPlanTokenPoolDownloadStatus.COMPLETED
+ );
+
+ let pollingDelay: number | null = null;
+ if (activeSnapshots.length > 0) {
+ pollingDelay = 2000;
+ } else if (unresolvedSnapshots.length > 0) {
+ pollingDelay = 10000;
+ }
useInterval(
- async () => {
- await fetchTokenPoolStatuses();
+ () => {
+ void fetchTokenPoolStatuses();
},
- shouldRunDownloadCheck ? 2000 : null
+ distributionPlan?.id ? pollingDelay : null
);
return (
@@ -150,12 +238,28 @@ export default function CreateSnapshots() {
* Please note: During this stage, some processes may take a moment to
load.
+ {!!failedSnapshots.length && (
+
+
+ Snapshot attention required
+
+
+ {failedSnapshots.length} snapshot
+ {failedSnapshots.length > 1 ? "s are" : " is"} unresolved. Retry or
+ delete failed snapshots before moving on. EMMA will keep checking in
+ the background for late completions.
+
+
+ )}
{haveSnapshots ? (
-
+
) : (
setStep(DistributionPlanToolStep.CREATE_CUSTOM_SNAPSHOT)
}
loading={false}
- showNextBtn={!shouldRunDownloadCheck && haveSnapshots}
+ showNextBtn={haveSnapshots}
showSkipBtn={false}
+ disableNextBtn={!allSnapshotsCompleted}
/>
diff --git a/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTable.tsx b/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTable.tsx
index d9cc76e929..20dccc29e7 100644
--- a/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTable.tsx
+++ b/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTable.tsx
@@ -5,13 +5,18 @@ import type { CreateSnapshotSnapshot } from "../CreateSnapshots";
export default function CreateSnapshotTable({
snapshots,
+ refreshDownloads,
}: {
snapshots: CreateSnapshotSnapshot[];
+ refreshDownloads: () => Promise;
}) {
return (
-
+
);
}
diff --git a/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableBody.tsx b/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableBody.tsx
index 1b380843db..ab0fbc00b2 100644
--- a/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableBody.tsx
+++ b/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableBody.tsx
@@ -6,13 +6,19 @@ import type { CreateSnapshotSnapshot } from "../CreateSnapshots";
export default function CreateSnapshotTableBody({
snapshots,
+ refreshDownloads,
}: {
snapshots: CreateSnapshotSnapshot[];
+ refreshDownloads: () => Promise;
}) {
return (
{snapshots.map((snapshot) => (
-
+
))}
);
diff --git a/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRow.tsx b/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRow.tsx
index 6c41914647..f3e4ed789a 100644
--- a/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRow.tsx
+++ b/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRow.tsx
@@ -1,166 +1,417 @@
"use client";
-import { DistributionPlanTokenPoolDownloadStatus } from "@/components/allowlist-tool/allowlist-tool.types";
+import type { DistributionPlanTokenPoolDownload } from "@/components/allowlist-tool/allowlist-tool.types";
+import {
+ DistributionPlanTokenPoolDownloadStage,
+ DistributionPlanTokenPoolDownloadStatus,
+} from "@/components/allowlist-tool/allowlist-tool.types";
+import AllowlistToolLoader, {
+ AllowlistToolLoaderSize,
+} from "@/components/allowlist-tool/common/AllowlistToolLoader";
import DistributionPlanTableRowWrapper from "@/components/distribution-plan-tool/common/DistributionPlanTableRowWrapper";
import DistributionPlanDeleteOperationButton from "@/components/distribution-plan-tool/common/DistributionPlanDeleteOperationButton";
import { truncateTextMiddle } from "@/helpers/AllowlistToolHelpers";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Tooltip } from "react-tooltip";
import { useCopyToClipboard } from "react-use";
import type { CreateSnapshotSnapshot } from "../CreateSnapshots";
import CreateSnapshotTableRowDownload from "./CreateSnapshotTableRowDownload";
+import CreateSnapshotTableRowRetry from "./CreateSnapshotTableRowRetry";
+
+type CopyableField = "contract" | "blockNo" | "consolidatedBlockNo" | null;
+type ExecutionPath = "FAST" | "SLOW";
+
+const truncateMessage = (message: string, max = 110): string =>
+ message.length > max ? `${message.slice(0, max - 3)}...` : message;
+
+const getStatusLabel = (
+ download: DistributionPlanTokenPoolDownload | null
+): string => {
+ if (!download) {
+ return "Starting";
+ }
+ if (download.status === DistributionPlanTokenPoolDownloadStatus.COMPLETED) {
+ return "Completed";
+ }
+ if (download.stale) {
+ return "Stalled";
+ }
+ if (download.rawStatus === DistributionPlanTokenPoolDownloadStatus.FAILED) {
+ return "Failed";
+ }
+ if (
+ download.failureCount > 0 &&
+ [
+ DistributionPlanTokenPoolDownloadStatus.PENDING,
+ DistributionPlanTokenPoolDownloadStatus.CLAIMED,
+ ].includes(download.rawStatus)
+ ) {
+ return "Retrying";
+ }
+ if (download.rawStatus === DistributionPlanTokenPoolDownloadStatus.CLAIMED) {
+ return "Processing";
+ }
+ return "Queued";
+};
+
+const getStatusClasses = (
+ download: DistributionPlanTokenPoolDownload | null
+): string => {
+ if (!download) {
+ return "tw-bg-primary-400/10 tw-text-primary-300";
+ }
+ if (download.status === DistributionPlanTokenPoolDownloadStatus.COMPLETED) {
+ return "tw-bg-[#EAFAE4]/10 tw-text-success";
+ }
+ if (
+ download.stale ||
+ download.rawStatus === DistributionPlanTokenPoolDownloadStatus.FAILED
+ ) {
+ return "tw-bg-[#312524] tw-text-[#FF6A55]";
+ }
+ if (download.failureCount > 0) {
+ return "tw-bg-[#4C3A19] tw-text-[#F5C66D]";
+ }
+ return "tw-bg-primary-400/10 tw-text-primary-300";
+};
+
+const getStageLabel = (
+ download: DistributionPlanTokenPoolDownload | null
+): string => {
+ if (!download) {
+ return "Waiting for first status update";
+ }
+ switch (download.stage) {
+ case undefined:
+ if (
+ download.rawStatus === DistributionPlanTokenPoolDownloadStatus.CLAIMED
+ ) {
+ return "Worker is processing the snapshot";
+ }
+ if (
+ download.rawStatus === DistributionPlanTokenPoolDownloadStatus.PENDING
+ ) {
+ return "Queued for processing";
+ }
+ return "No stage available yet";
+ case DistributionPlanTokenPoolDownloadStage.PREPARING:
+ return "Preparing snapshot job";
+ case DistributionPlanTokenPoolDownloadStage.REQUEUED:
+ return "Re-queued for another pass";
+ case DistributionPlanTokenPoolDownloadStage.CLAIMED:
+ return "Worker claimed snapshot job";
+ case DistributionPlanTokenPoolDownloadStage.CHECKING_ALCHEMY:
+ return "Checking archive-node availability";
+ case DistributionPlanTokenPoolDownloadStage.INDEXING_SINGLE:
+ return "Indexing single transfers";
+ case DistributionPlanTokenPoolDownloadStage.INDEXING_BATCH:
+ return "Indexing batch transfers";
+ case DistributionPlanTokenPoolDownloadStage.BUILDING_TOKEN_OWNERS:
+ return "Building holder state";
+ case DistributionPlanTokenPoolDownloadStage.PERSISTING_RESULTS:
+ return "Saving snapshot results";
+ case DistributionPlanTokenPoolDownloadStage.COMPLETED:
+ return "Snapshot ready";
+ case DistributionPlanTokenPoolDownloadStage.FAILED:
+ return "Snapshot failed";
+ }
+};
+
+const getProgressNumber = (
+ download: DistributionPlanTokenPoolDownload | null,
+ key: string
+): number | null => {
+ if (!download?.progress) {
+ return null;
+ }
+ const value = download.progress[key];
+ return typeof value === "number" ? value : null;
+};
+
+const getExecutionPath = (
+ download: DistributionPlanTokenPoolDownload | null
+): ExecutionPath | null => {
+ if (!download?.progress) {
+ return null;
+ }
+ const value = download.progress["executionPath"];
+ if (value === "FAST" || value === "SLOW") {
+ return value;
+ }
+ return null;
+};
+
+const getExecutionPathLabel = (
+ executionPath: ExecutionPath | null
+): string | null => {
+ if (executionPath === "FAST") {
+ return "Fast path";
+ }
+ if (executionPath === "SLOW") {
+ return "Slow path";
+ }
+ return null;
+};
+
+const getExecutionPathClasses = (
+ executionPath: ExecutionPath | null
+): string => {
+ if (executionPath === "FAST") {
+ return "tw-bg-[#203425] tw-text-[#8CE8A4]";
+ }
+ if (executionPath === "SLOW") {
+ return "tw-bg-[#332819] tw-text-[#F5C66D]";
+ }
+ return "";
+};
+
+const getProgressSummary = (
+ download: DistributionPlanTokenPoolDownload | null
+): string => {
+ if (!download) {
+ return "Waiting for the snapshot job to be created";
+ }
+ const currentBlockNo = getProgressNumber(download, "currentBlockNo");
+ const targetBlockNo = getProgressNumber(download, "targetBlockNo");
+ if (currentBlockNo !== null && targetBlockNo !== null) {
+ return `Current block ${currentBlockNo.toLocaleString()} / ${targetBlockNo.toLocaleString()}`;
+ }
+ if (currentBlockNo !== null) {
+ return `Current block ${currentBlockNo.toLocaleString()}`;
+ }
+ const latestFetchedBlockNo = getProgressNumber(
+ download,
+ "latestFetchedBlockNo"
+ );
+ if (latestFetchedBlockNo !== null && targetBlockNo !== null) {
+ return `Indexed block ${latestFetchedBlockNo.toLocaleString()} / ${targetBlockNo.toLocaleString()}`;
+ }
+ const tokenOwnershipsCount = getProgressNumber(
+ download,
+ "tokenOwnershipsCount"
+ );
+ if (tokenOwnershipsCount !== null) {
+ return `Prepared ${tokenOwnershipsCount.toLocaleString()} token ownerships`;
+ }
+ const transfersPersisted = getProgressNumber(download, "transfersPersisted");
+ if (transfersPersisted !== null) {
+ return `Saved ${transfersPersisted.toLocaleString()} transfers in the latest batch`;
+ }
+ const blockNo = getProgressNumber(download, "blockNo");
+ if (blockNo !== null) {
+ return `Target block ${blockNo.toLocaleString()}`;
+ }
+ if (download.status === DistributionPlanTokenPoolDownloadStatus.COMPLETED) {
+ return "Snapshot holders are ready to use";
+ }
+ if (download.retryable) {
+ return "Needs retry or removal before the plan can continue";
+ }
+ return "Progress data will appear here as the job advances";
+};
+
+const getReferenceTime = (
+ download: DistributionPlanTokenPoolDownload | null
+): number | null =>
+ download?.lastHeartbeatAt ??
+ download?.updatedAt ??
+ download?.claimedAt ??
+ download?.createdAt ??
+ null;
+
+const getActivitySummary = (
+ download: DistributionPlanTokenPoolDownload | null
+): string | null => {
+ if (!download) {
+ return null;
+ }
+ const pieces: string[] = [];
+ const referenceTime = getReferenceTime(download);
+ if (referenceTime !== null) {
+ pieces.push(`Updated ${formatActivityTime(referenceTime)}`);
+ }
+ if (download.attemptCount > 0) {
+ pieces.push(`Attempt ${download.attemptCount}`);
+ }
+ if (download.failureCount > 0) {
+ pieces.push(`Failed ${download.failureCount}x`);
+ }
+ return pieces.length ? pieces.join(" • ") : null;
+};
+
+const getCurrentIssue = (
+ download: DistributionPlanTokenPoolDownload | null
+): string | null => {
+ if (!download?.errorReason) {
+ return null;
+ }
+ if (
+ download.retryable ||
+ download.rawStatus === DistributionPlanTokenPoolDownloadStatus.FAILED
+ ) {
+ return truncateMessage(download.errorReason);
+ }
+ return null;
+};
+
+const getPreviousFailure = (
+ download: DistributionPlanTokenPoolDownload | null
+): string | null => {
+ if (
+ !download ||
+ download.failureCount < 1 ||
+ typeof download.lastFailureReason !== "string" ||
+ download.lastFailureReason.length === 0
+ ) {
+ return null;
+ }
+ const failureTime =
+ download.lastFailureAt === undefined
+ ? ""
+ : ` at ${formatActivityTime(download.lastFailureAt)}`;
+ return `Previous failure${failureTime}: ${truncateMessage(
+ download.lastFailureReason
+ )}`;
+};
+
+const formatActivityTime = (timestamp: number): string =>
+ new Intl.DateTimeFormat(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(timestamp));
export default function CreateSnapshotTableRow({
snapshot,
+ refreshDownloads,
}: {
snapshot: CreateSnapshotSnapshot;
+ refreshDownloads: () => Promise;
}) {
const [_, copyToClipboard] = useCopyToClipboard();
- const getContractTruncated = () => {
- if (!snapshot.contract) {
- return "";
- }
- return truncateTextMiddle(snapshot.contract, 11);
+ const download = snapshot.download;
+ const [copiedField, setCopiedField] = useState(null);
+
+ let contractText = "";
+ if (copiedField === "contract") {
+ contractText = "Copied";
+ } else if (snapshot.contract !== null) {
+ contractText = truncateTextMiddle(snapshot.contract, 11);
+ }
+ const blockNo = snapshot.blockNo?.toString() ?? "";
+ const blockNoText = copiedField === "blockNo" ? "Copied" : blockNo;
+ const consolidatedBlockNo = snapshot.consolidateBlockNo?.toString() ?? "";
+ const haveConsolidatedBlockNo = consolidatedBlockNo.length > 0;
+ const consolidatedBlockNoText =
+ copiedField === "consolidatedBlockNo" ? "Copied" : consolidatedBlockNo;
+ const tokenIdsTooltip = snapshot.tokenIds ?? "All";
+ let tokenIdsTruncated = "All";
+ if (snapshot.tokenIds !== null) {
+ tokenIdsTruncated =
+ snapshot.tokenIds.length > 20
+ ? truncateTextMiddle(snapshot.tokenIds, 20)
+ : snapshot.tokenIds;
+ }
+
+ const setCopied = (field: Exclude) => {
+ setCopiedField(field);
+ globalThis.setTimeout(() => {
+ setCopiedField((currentField) =>
+ currentField === field ? null : currentField
+ );
+ }, 3000);
};
- const [contractText, setContractText] = useState(
- getContractTruncated()
- );
const copyContract = () => {
- if (!snapshot.contract) {
+ if (snapshot.contract === null) {
return;
}
copyToClipboard(snapshot.contract);
- setContractText("Copied");
- setTimeout(() => setContractText(getContractTruncated()), 3000);
+ setCopied("contract");
};
- const [blockNo, setBlockNo] = useState(
- snapshot.blockNo?.toString() ?? ""
- );
-
- useEffect(() => {
- setBlockNo(snapshot.blockNo?.toString() ?? "");
- }, [snapshot.blockNo]);
-
- const [blockNoText, setBlockNoText] = useState(blockNo);
const copyBlockNumber = () => {
+ if (blockNo.length === 0) {
+ return;
+ }
copyToClipboard(blockNo);
- setBlockNoText("Copied");
- setTimeout(() => setBlockNoText(blockNo), 3000);
+ setCopied("blockNo");
};
- const [consolidatedBlockNo, setConsolidatedBlockNo] = useState(
- snapshot.consolidateBlockNo?.toString() ?? ""
- );
-
- const [haveConsolidatedBlockNo, setHaveConsolidatedBlockNo] =
- useState(false);
-
- useEffect(
- () => setHaveConsolidatedBlockNo(!!consolidatedBlockNo.length),
- [consolidatedBlockNo]
- );
-
- useEffect(() => {
- setConsolidatedBlockNo(snapshot.consolidateBlockNo?.toString() ?? "");
- }, [snapshot.consolidateBlockNo]);
-
- const [consolidatedBlockNoText, setConsolidatedBlockNoText] =
- useState(consolidatedBlockNo);
-
const copyConsolidatedBlockNumber = () => {
if (!haveConsolidatedBlockNo) {
return;
}
copyToClipboard(consolidatedBlockNo);
- setConsolidatedBlockNoText("Copied");
- setTimeout(() => setConsolidatedBlockNoText(consolidatedBlockNo), 3000);
+ setCopied("consolidatedBlockNo");
};
- const [tokenIdsTruncated, setTokenIdsTruncated] = useState("");
- const [tokenIdsTooltip, setTokenIdsTooltip] = useState("");
- useEffect(() => {
- if (!snapshot.tokenIds) {
- setTokenIdsTruncated("All");
- setTokenIdsTooltip("All");
- return;
- }
- setTokenIdsTooltip(snapshot.tokenIds);
- if (snapshot.tokenIds.length > 20) {
- setTokenIdsTruncated(truncateTextMiddle(snapshot.tokenIds, 20));
- return;
- }
- setTokenIdsTruncated(snapshot.tokenIds);
- }, [snapshot.tokenIds]);
-
- const [isGeneratingSnapshot, setIsGeneratingSnapshot] =
- useState(false);
-
- useEffect(() => {
- if (
- !snapshot.downloaderStatus ||
- ![
- DistributionPlanTokenPoolDownloadStatus.COMPLETED,
- DistributionPlanTokenPoolDownloadStatus.FAILED,
- ].includes(snapshot.downloaderStatus)
- ) {
- setIsGeneratingSnapshot(true);
- return;
- }
- setIsGeneratingSnapshot(false);
- }, [snapshot.downloaderStatus]);
-
- const [isCompleted, setIsCompleted] = useState(false);
- useEffect(() => {
- if (
- !snapshot.downloaderStatus ||
- snapshot.downloaderStatus !==
- DistributionPlanTokenPoolDownloadStatus.COMPLETED
- ) {
- setIsCompleted(false);
- return;
- }
- setIsCompleted(true);
- }, [snapshot.downloaderStatus]);
+ const isCompleted =
+ download?.status === DistributionPlanTokenPoolDownloadStatus.COMPLETED;
+ const showSpinner =
+ !download ||
+ (!download.stale &&
+ [
+ DistributionPlanTokenPoolDownloadStatus.PENDING,
+ DistributionPlanTokenPoolDownloadStatus.CLAIMED,
+ ].includes(download.rawStatus));
+ const currentIssue = getCurrentIssue(download);
+ const previousFailure = getPreviousFailure(download);
+ const activitySummary = getActivitySummary(download);
+ const executionPath = getExecutionPath(download);
+ const executionPathLabel = getExecutionPathLabel(executionPath);
return (
{snapshot.name}
-
- {isGeneratingSnapshot ? (
-
-
-
-
- ) : (
-
- {snapshot.downloaderStatus}
-
- )}
+
+
+
+ {showSpinner && (
+
+ )}
+
+ {getStatusLabel(download)}
+
+ {executionPathLabel && (
+
+ {executionPathLabel}
+
+ )}
+
+
+ {getStageLabel(download)}
+
+
+ {getProgressSummary(download)}
+
+ {activitySummary && (
+
+ {activitySummary}
+
+ )}
+ {previousFailure && (
+
+ {previousFailure}
+
+ )}
+ {currentIssue && (
+
+ {currentIssue}
+
+ )}
+
{haveConsolidatedBlockNo && (
-
{consolidatedBlockNoText}
+
{consolidatedBlockNoText}
)}
+ {download?.retryable && (
+
+ )}
Promise;
+}>;
+
+export default function CreateSnapshotTableRowRetry({
+ allowlistId,
+ tokenPoolId,
+ refreshDownloads,
+}: CreateSnapshotTableRowRetryProps) {
+ const { setToasts } = useContext(DistributionPlanToolContext);
+ const [loading, setLoading] = useState(false);
+
+ const onRetry = async () => {
+ setLoading(true);
+ const endpoint = `/allowlists/${allowlistId}/token-pool-downloads/token-pool/${tokenPoolId}/retry`;
+ const { success } = await distributionPlanApiPost({
+ endpoint,
+ body: {},
+ });
+ if (success) {
+ setToasts({
+ messages: ["Snapshot retry started"],
+ type: "success",
+ });
+ await refreshDownloads();
+ }
+ setLoading(false);
+ };
+
+ return (
+ {
+ e.stopPropagation();
+ void onRetry();
+ }}
+ disabled={loading}
+ aria-label={loading ? "Retrying snapshot" : "Retry snapshot"}
+ aria-busy={loading}
+ type="button"
+ title="Retry"
+ className="tw-group tw-rounded-full tw-border-none tw-bg-primary-400/10 tw-p-2 tw-text-xs tw-font-medium tw-text-primary-300 tw-ring-1 tw-ring-inset tw-ring-primary-400/20"
+ >
+ {loading ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+}