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({ )} @@ -32,16 +35,18 @@ export default function DistributionPlanNextStepBtn({ )} {!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 ( + + ); +}