From 1487a2bec455a368f712f15606b23f3644c00475 Mon Sep 17 00:00:00 2001 From: Eugene Panteleymonchuk Date: Wed, 7 Feb 2024 16:52:53 +0200 Subject: [PATCH] feat: download report for Chrysalis. Signed-off-by: Eugene Panteleymonchuk --- api/src/routes.ts | 8 + .../transactionhistory/download/post.ts | 147 ++++++++++++++++++ .../utils/chrysalis/chrysalisTangleHelper.ts | 17 ++ .../components/chrysalis/DownloadModal.scss | 115 ++++++++++++++ .../components/chrysalis/DownloadModal.tsx | 117 ++++++++++++++ .../app/components/chrysalis/Transaction.scss | 8 + client/src/app/routes/chrysalis/Addr.scss | 8 + client/src/app/routes/chrysalis/Addr.tsx | 15 +- .../hooks/useTransactionHistoryDownload.ts | 7 +- .../services/chrysalis/chrysalisApiClient.ts | 5 + 10 files changed, 440 insertions(+), 7 deletions(-) create mode 100644 api/src/routes/chrysalis/transactionhistory/download/post.ts create mode 100644 client/src/app/components/chrysalis/DownloadModal.scss create mode 100644 client/src/app/components/chrysalis/DownloadModal.tsx diff --git a/api/src/routes.ts b/api/src/routes.ts index 1836ae714..3b5c285b8 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -37,6 +37,14 @@ export const routes: IRoute[] = [ folder: "chrysalis/transactionhistory", func: "get", }, + { + path: "/transactionhistory/download/:network/:address", + method: "post", + folder: "chrysalis/transactionhistory/download", + func: "post", + dataBody: true, + dataResponse: true, + }, { path: "/chrysalis/did/:network/:did/document", method: "get", diff --git a/api/src/routes/chrysalis/transactionhistory/download/post.ts b/api/src/routes/chrysalis/transactionhistory/download/post.ts new file mode 100644 index 000000000..5c5669cfa --- /dev/null +++ b/api/src/routes/chrysalis/transactionhistory/download/post.ts @@ -0,0 +1,147 @@ +import { UnitsHelper } from "@iota/iota.js"; +import JSZip from "jszip"; +import moment from "moment"; +import { ServiceFactory } from "../../../../factories/serviceFactory"; +import logger from "../../../../logger"; +import { IDataResponse } from "../../../../models/api/IDataResponse"; +import { ITransactionHistoryDownloadBody } from "../../../../models/api/stardust/chronicle/ITransactionHistoryDownloadBody"; +import { ITransactionHistoryRequest } from "../../../../models/api/stardust/chronicle/ITransactionHistoryRequest"; +import { IConfiguration } from "../../../../models/configuration/IConfiguration"; +import { CHRYSALIS } from "../../../../models/db/protocolVersion"; +import { NetworkService } from "../../../../services/networkService"; +import { ChrysalisTangleHelper } from "../../../../utils/chrysalis/chrysalisTangleHelper"; +import { ValidationHelper } from "../../../../utils/validationHelper"; + +interface IParsedRow { + messageId: string; + transactionId: string; + referencedByMilestoneIndex: string; + milestoneTimestampReferenced: string; + timestampFormatted: string; + ledgerInclusionState: string; + conflictReason: string; + inputsCount: string; + outputsCount: string; + addressBalanceChange: string; + addressBalanceChangeFormatted: string; +} + +/** + * Download the transaction history from chronicle stardust. + * @param _ The configuration. + * @param request The request. + * @param body The request body + * @returns The response. + */ +export async function post( + _: IConfiguration, + request: ITransactionHistoryRequest, + body: ITransactionHistoryDownloadBody, +): Promise { + const networkService = ServiceFactory.get("network"); + ValidationHelper.oneOf(request.network, networkService.networkNames(), "network"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== CHRYSALIS || !networkConfig.permaNodeEndpoint || !request.address) { + return null; + } + + const transactionHistoryDownload = await ChrysalisTangleHelper.transactionHistoryDownload(networkConfig, request.address); + + const parsed = parseResponse(transactionHistoryDownload); + + let csvContent = `${["Timestamp", "TransactionId", "Balance changes"].join(",")}\n`; + + const filtered = parsed.body.filter((row) => { + return moment(row.milestoneTimestampReferenced).isAfter(body.targetDate); + }); + + for (const i of filtered) { + const row = [i.timestampFormatted, i.transactionId, i.addressBalanceChangeFormatted].join(","); + csvContent += `${row}\n`; + } + + const jsZip = new JSZip(); + let response: IDataResponse = null; + + try { + jsZip.file("history.csv", csvContent); + const content = await jsZip.generateAsync({ type: "nodebuffer" }); + + response = { + data: content, + contentType: "application/octet-stream", + }; + } catch (e) { + logger.error(`Failed to zip transaction history for download. Cause: ${e}`); + } + + return response; +} + +/** + * Split response into lines, format each line + * @param response The response from endpoint to parse. + * @returns Object with headers and body. + */ +function parseResponse(response: string) { + const lines = response.split("\n"); + let isHeadersSet = false; + let headers: IParsedRow; // Headers: "MessageID", "TransactionID", "ReferencedByMilestoneIndex", "MilestoneTimestampReferenced", "LedgerInclusionState", "ConflictReason", "InputsCount", "OutputsCount", "AddressBalanceChange" + const body: IParsedRow[] = []; + + for (const line of lines) { + const row = parseRow(line); + + if (row) { + if (isHeadersSet) { + body.push(row); + } else { + headers = row; + isHeadersSet = true; + } + } + } + + return { headers, body }; +} + +/** + * @param row The row to parse. + * @returns Object with parsed and formatted values. + */ +function parseRow(row: string): IParsedRow { + const cols = row.split(","); + if (!cols || cols.length < 9) { + return null; + } + + const [ + messageId, + transactionId, + referencedByMilestoneIndex, + milestoneTimestampReferenced, + ledgerInclusionState, + conflictReason, + inputsCount, + outputsCount, + addressBalanceChange, + ] = cols; + + const timestamp = milestoneTimestampReferenced.replaceAll("\"", ""); + + return { + messageId, + transactionId, + referencedByMilestoneIndex, + milestoneTimestampReferenced: timestamp, + timestampFormatted: moment(timestamp).format("YYYY-MM-DD HH:mm:ss"), + ledgerInclusionState, + conflictReason, + inputsCount, + outputsCount, + addressBalanceChange, + addressBalanceChangeFormatted: UnitsHelper.formatBest(Number(addressBalanceChange)), + }; +} diff --git a/api/src/utils/chrysalis/chrysalisTangleHelper.ts b/api/src/utils/chrysalis/chrysalisTangleHelper.ts index 4f0c9ff35..07c76bce9 100644 --- a/api/src/utils/chrysalis/chrysalisTangleHelper.ts +++ b/api/src/utils/chrysalis/chrysalisTangleHelper.ts @@ -260,6 +260,23 @@ export class ChrysalisTangleHelper { } catch {} } + /** + * Download the transaction history as csv + * @param network The network to find the items on. + * @param address Address for exporting + */ + public static async transactionHistoryDownload(network: INetwork, address: string): Promise { + try { + const csvResp = await fetch(`${network.permaNodeEndpoint}/api/core/v1/addresses/${address}/tx-history`, { + method: "GET", + headers: { + Accept: "text/csv", + }, + }); + return await csvResp.text(); + } catch {} + } + /** * Get the milestone details. * @param network The network to find the items on. diff --git a/client/src/app/components/chrysalis/DownloadModal.scss b/client/src/app/components/chrysalis/DownloadModal.scss new file mode 100644 index 000000000..7aee50709 --- /dev/null +++ b/client/src/app/components/chrysalis/DownloadModal.scss @@ -0,0 +1,115 @@ +@import "../../../scss/fonts"; +@import "../../../scss/mixins"; +@import "../../../scss/media-queries"; +@import "../../../scss/variables"; +@import "../../../scss/themes"; + +.download-modal { + display: inline-block; + + button { + border: none; + background-color: transparent; + + &:focus { + box-shadow: none; + } + } + + .modal--icon { + span { + color: #b0bfd9; + font-size: 20px; + } + } + + .modal--bg { + position: fixed; + z-index: 2000; + top: 0; + left: 0; + width: 100%; + height: 100vh; + background: rgba(19, 31, 55, 0.75); + } + + .modal--content { + position: fixed; + z-index: 3000; + top: 50%; + left: 50%; + width: 100%; + max-width: 660px; + max-height: 100%; + padding: 32px; + transform: translate(-50%, -50%); + border: 1px solid #e8eefb; + border-radius: 6px; + background-color: var(--body-background); + box-shadow: 0 4px 8px rgba(19, 31, 55, 0.04); + + @include tablet-down { + width: 100%; + overflow-y: auto; + } + + .modal--header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 22px; + border-bottom: 1px solid #e8eefb; + letter-spacing: 0.02em; + + .modal--title { + @include font-size(20px); + + color: var(--body-color); + font-family: $metropolis; + font-weight: 600; + } + + button { + color: var(--body-color); + } + } + + .modal--body { + margin-top: 24px; + + .input-container { + width: 100%; + + .date-label { + color: var(--body-color); + font-family: $inter; + font-weight: 500; + margin-bottom: 8px; + margin-right: 8px; + } + } + + .confirm-button { + cursor: pointer; + margin: 24px 0 8px 0; + align-self: center; + width: fit-content; + padding: 8px 12px; + border: 1px solid #ccc; + color: $mint-green-7; + + &.disabled { + color: $mint-green-1; + } + + .spinner-container { + display: flex; + align-items: center; + justify-content: center; + width: 57px; + height: 16px; + } + } + } + } +} diff --git a/client/src/app/components/chrysalis/DownloadModal.tsx b/client/src/app/components/chrysalis/DownloadModal.tsx new file mode 100644 index 000000000..87111d141 --- /dev/null +++ b/client/src/app/components/chrysalis/DownloadModal.tsx @@ -0,0 +1,117 @@ +import moment from "moment"; +import React, { useState } from "react"; +import Datetime from "react-datetime"; +// import { useTransactionHistoryDownload } from "~helpers/hooks/useTransactionHistoryDownload"; +import Spinner from "../Spinner"; +import Tooltip from "../Tooltip"; +import "./DownloadModal.scss"; +import "react-datetime/css/react-datetime.css"; +import { ServiceFactory } from "~factories/serviceFactory"; +import { ChrysalisApiClient } from "~services/chrysalis/chrysalisApiClient"; +import { CHRYSALIS } from "~models/config/protocolVersion"; +import { triggerDownload } from "~helpers/hooks/useTransactionHistoryDownload"; + +interface DownloadModalProps { + readonly network: string; + readonly address: string; +} + +const DOWNLOAD_INFO = "History will be downloaded from start date to present."; +const INITIAL_DATE = moment("2023-10-04"); + +const DownloadModal: React.FC = ({ network, address }) => { + const [apiClient] = useState(ServiceFactory.get(`api-client-${CHRYSALIS}`)); + const [isDownloading, setIsDownloading] = useState(false); + const [error, setError] = useState(); + + const [showModal, setShowModal] = useState(false); + const [date, setDate] = useState(INITIAL_DATE); + + const onDownload = async () => { + if (!date) { + setError("Please select a date"); + return; + } + + setIsDownloading(true); + try { + const response = await apiClient.transactionHistoryDownload(network, address, (date as moment.Moment).format("YYYY-MM-DD")); + + if (response.raw) { + const responseBlob = await response.raw.blob(); + + triggerDownload(responseBlob, address); + } else if (response.error) { + setError(response.error); + } + } finally { + setIsDownloading(false); + } + }; + + const onModalOpen = () => { + setShowModal(true); + }; + + const onModalClose = () => { + setDate(""); + setShowModal(false); + }; + + return ( +
+ + {showModal && ( + +
+
+
Transaction History Download
+ +
+
+
+
+
Select start date
+ +
+ info +
+
+
+
+ calendar_month + current.isBefore(moment("2023-10-05"))} + inputProps={{ placeholder: "MM/DD/YYYY" }} + timeFormat={false} + onChange={(value) => setDate(value)} + /> +
+
+ {isDownloading ? ( + + ) : ( + + )} + {error &&
{error}
} +
+
+
+ + )} +
+ ); +}; + +export default DownloadModal; diff --git a/client/src/app/components/chrysalis/Transaction.scss b/client/src/app/components/chrysalis/Transaction.scss index 12d07939e..7b8b93622 100644 --- a/client/src/app/components/chrysalis/Transaction.scss +++ b/client/src/app/components/chrysalis/Transaction.scss @@ -27,6 +27,10 @@ font-weight: 600; text-align: left; text-transform: uppercase; + + &:last-child { + text-align: right; + } } td { @@ -62,6 +66,10 @@ color: var(--expanded-color); } } + + &:last-child { + text-align: right; + } } } } diff --git a/client/src/app/routes/chrysalis/Addr.scss b/client/src/app/routes/chrysalis/Addr.scss index fa1e66853..68ac4eca4 100644 --- a/client/src/app/routes/chrysalis/Addr.scss +++ b/client/src/app/routes/chrysalis/Addr.scss @@ -233,4 +233,12 @@ flex-direction: column; } } + + .justify-between { + justify-content: space-between; + } + + .w-full { + width: 100%; + } } diff --git a/client/src/app/routes/chrysalis/Addr.tsx b/client/src/app/routes/chrysalis/Addr.tsx index 258d54362..4337c7c98 100644 --- a/client/src/app/routes/chrysalis/Addr.tsx +++ b/client/src/app/routes/chrysalis/Addr.tsx @@ -25,6 +25,7 @@ import Pagination from "../../components/Pagination"; import Spinner from "../../components/Spinner"; import { AddressRouteProps } from "../AddressRouteProps"; import "./Addr.scss"; +import DownloadModal from "~app/components/chrysalis/DownloadModal"; /** * Component which will show the address page for chrysalis and older. @@ -243,9 +244,17 @@ class Addr extends AsyncComponent, AddrSt {this.txsHistory.length > 0 && (
-
-

Transaction History

- +
+
+

Transaction History

+ +
+
+ +
{this.state.status && (
diff --git a/client/src/helpers/hooks/useTransactionHistoryDownload.ts b/client/src/helpers/hooks/useTransactionHistoryDownload.ts index 7cc729e43..56e0d9549 100644 --- a/client/src/helpers/hooks/useTransactionHistoryDownload.ts +++ b/client/src/helpers/hooks/useTransactionHistoryDownload.ts @@ -29,7 +29,7 @@ export function useTransactionHistoryDownload(network: string, address: string, // eslint-disable-next-line no-void void response.raw.blob().then((blob) => { if (isMounted) { - triggerDownload(blob, address); + triggerDownload(blob, `txhistory-${address}`); } }); } else if (response.error && isMounted) { @@ -48,13 +48,12 @@ export function useTransactionHistoryDownload(network: string, address: string, return [isDownloading, error]; } -const triggerDownload = (blob: Blob, address: string) => { +export const triggerDownload = (blob: Blob, filename: string) => { const url = window.URL.createObjectURL(blob); - const filename = `txhistory-${address}.zip`; const tempDlElement = document.createElement("a"); tempDlElement.href = url; - tempDlElement.download = filename; + tempDlElement.download = `${filename}.zip`; document.body.append(tempDlElement); tempDlElement.click(); tempDlElement.remove(); diff --git a/client/src/services/chrysalis/chrysalisApiClient.ts b/client/src/services/chrysalis/chrysalisApiClient.ts index c536d8fb8..b6e43fc7d 100644 --- a/client/src/services/chrysalis/chrysalisApiClient.ts +++ b/client/src/services/chrysalis/chrysalisApiClient.ts @@ -20,6 +20,7 @@ import { IOutputDetailsRequest } from "~models/api/IOutputDetailsRequest"; import { IStatsGetRequest } from "~models/api/stats/IStatsGetRequest"; import { IStatsGetResponse } from "~models/api/stats/IStatsGetResponse"; import { ApiClient } from "../apiClient"; +import { IRawResponse } from "~models/api/IRawResponse"; /** * Class to handle api communications on chrystalis. @@ -139,4 +140,8 @@ export class ChrysalisApiClient extends ApiClient { payload, ); } + + public async transactionHistoryDownload(network: string, address: string, targetDate: string): Promise { + return this.callApiRaw(`transactionhistory/download/${network}/${address}`, "post", { targetDate }); + } }