diff options
author | Sebastian <sebasjm@gmail.com> | 2023-12-12 13:58:29 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2023-12-12 13:58:44 -0300 |
commit | f7e01690b44e42b7088457374a2a8606fd94f84e (patch) | |
tree | 6f9e57e69f119786320d8f9b08f5ccb2c9477946 | |
parent | b448b77eb4e8dc0157d1780e11bc0f38d7b636cf (diff) |
resolve #7751
-rw-r--r-- | packages/demobank-ui/src/Routing.tsx | 6 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/DownloadStats.tsx | 398 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/admin/AdminHome.tsx | 4 |
3 files changed, 344 insertions, 64 deletions
diff --git a/packages/demobank-ui/src/Routing.tsx b/packages/demobank-ui/src/Routing.tsx index 711b7f871..4a250a0d5 100644 --- a/packages/demobank-ui/src/Routing.tsx +++ b/packages/demobank-ui/src/Routing.tsx @@ -122,7 +122,11 @@ export function Routing(): VNode { /> <Route path="/download-stats" - component={() => <DownloadStats />} + component={() => <DownloadStats + onCancel={() => { + route("/account") + }} + />} /> <Route diff --git a/packages/demobank-ui/src/pages/DownloadStats.tsx b/packages/demobank-ui/src/pages/DownloadStats.tsx index cd3e6d875..596539e7e 100644 --- a/packages/demobank-ui/src/pages/DownloadStats.tsx +++ b/packages/demobank-ui/src/pages/DownloadStats.tsx @@ -14,109 +14,383 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AccessToken, Logger, RefreshReason, TalerCoreBankHttpClient, TalerCorebankApi, TalerError } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { AccessToken, AmountString, Logger, TalerCoreBankHttpClient, TalerCorebankApi, TalerError } from "@gnu-taler/taler-util"; +import { Attention, LocalNotificationBanner, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { Loading } from "@gnu-taler/web-util/browser"; -import { Transactions } from "../components/Transactions/index.js"; -import { usePublicAccounts } from "../hooks/access.js"; -import { useBackendState } from "../hooks/backend.js"; import { useBankCoreApiContext } from "../context/config.js"; +import { useBackendState } from "../hooks/backend.js"; import { getTimeframesForDate } from "./admin/AdminHome.js"; const logger = new Logger("PublicHistoriesPage"); -interface Props { } +interface Props { + onCancel: () => void; +} + +type Options = { + dayMetric: boolean; + hourMetric: boolean; + monthMetric: boolean; + yearMetric: boolean; + compareWithPrevious: boolean; + endOnFirstFail: boolean; + includeHeader: boolean; +} /** * Show histories of public accounts. */ -export function DownloadStats({ }: Props): VNode { +export function DownloadStats({ onCancel }: Props): VNode { const { i18n } = useTranslationContext(); const { state: credentials } = useBackendState(); const creds = credentials.status !== "loggedIn" || !credentials.isUserAdministrator ? undefined : credentials - const { api, config } = useBankCoreApiContext(); + const { api } = useBankCoreApiContext(); - const [state, setState] = useState<number>() + const [options, setOptions] = useState<Options>({ + compareWithPrevious: true, + dayMetric: true, + endOnFirstFail: false, + hourMetric: true, + includeHeader: true, + monthMetric: true, + yearMetric: true, + }) + const [lastStep, setLastStep] = useState<{ step: number, total: number }>() + const [downloaded, setDownloaded] = useState<string>() + const referenceDates = [new Date()] + const [notification, notify, handleError] = useLocalNotification() if (!creds) { return <div>only admin can download stats</div> } return ( - <Fragment> - <h1 class="nav">{i18n.str`Stats`}</h1> - <button type="button" - class="inline-flex items-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - onClick={() => { - fetchAllStatus(api, creds.token, new Date(), (p, total) => { - console.log("doing...", total - p) - setState(total - p) - }) - }} - > - start - </button> - progress {state} - </Fragment> + <div> + + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <LocalNotificationBanner notification={notification} /> + + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Download bank stats</i18n.Translate> + </h2> + </div> + + + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={e => { + e.preventDefault() + }} + > + <div class="px-4 py-6 sm:p-8"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span class="text-sm text-black font-medium leading-6 " id="availability-label"> + <i18n.Translate>Include hour metric</i18n.Translate> + </span> + </span> + <button type="button" data-enabled={options.hourMetric} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" + + onClick={() => { setOptions({ ...options, hourMetric: !options.hourMetric }) }}> + <span aria-hidden="true" data-enabled={options.hourMetric} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span> + </button> + </div> + </div> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span class="text-sm text-black font-medium leading-6 " id="availability-label"> + <i18n.Translate>Include day metric</i18n.Translate> + </span> + </span> + <button type="button" data-enabled={!!options.dayMetric} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" + + onClick={() => { setOptions({ ...options, dayMetric: !options.dayMetric }) }}> + <span aria-hidden="true" data-enabled={options.dayMetric} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span> + </button> + </div> + </div> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span class="text-sm text-black font-medium leading-6 " id="availability-label"> + <i18n.Translate>Include month metric</i18n.Translate> + </span> + </span> + <button type="button" data-enabled={!!options.monthMetric} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" + + onClick={() => { setOptions({ ...options, monthMetric: !options.monthMetric }) }}> + <span aria-hidden="true" data-enabled={options.monthMetric} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span> + </button> + </div> + </div> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span class="text-sm text-black font-medium leading-6 " id="availability-label"> + <i18n.Translate>Include year metric</i18n.Translate> + </span> + </span> + <button type="button" data-enabled={!!options.yearMetric} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" + + onClick={() => { setOptions({ ...options, yearMetric: !options.yearMetric }) }}> + <span aria-hidden="true" data-enabled={options.yearMetric} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span> + </button> + </div> + </div> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span class="text-sm text-black font-medium leading-6 " id="availability-label"> + <i18n.Translate>Include table header</i18n.Translate> + </span> + </span> + <button type="button" data-enabled={!!options.includeHeader} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" + + onClick={() => { setOptions({ ...options, includeHeader: !options.includeHeader }) }}> + <span aria-hidden="true" data-enabled={options.includeHeader} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span> + </button> + </div> + </div> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span class="text-sm text-black font-medium leading-6 " id="availability-label"> + <i18n.Translate>Add previous metric for compare</i18n.Translate> + </span> + </span> + <button type="button" data-enabled={!!options.compareWithPrevious} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" + + onClick={() => { setOptions({ ...options, compareWithPrevious: !options.compareWithPrevious }) }}> + <span aria-hidden="true" data-enabled={options.compareWithPrevious} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span> + </button> + </div> + </div> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span class="text-sm text-black font-medium leading-6 " id="availability-label"> + <i18n.Translate>Fail on first error</i18n.Translate> + </span> + </span> + <button type="button" data-enabled={!!options.endOnFirstFail} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" + + onClick={() => { setOptions({ ...options, endOnFirstFail: !options.endOnFirstFail }) }}> + <span aria-hidden="true" data-enabled={options.endOnFirstFail} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span> + </button> + </div> + </div> + </div> + </div> + + + + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <button type="button" class="text-sm font-semibold leading-6 text-gray-900" + onClick={onCancel} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + <button type="submit" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={lastStep !== undefined} + onClick={async () => { + setDownloaded(undefined) + await handleError(async () => { + const csv = await fetchAllStatus(api, creds.token, options, referenceDates, (step, total) => { + setLastStep({ step, total }) + }) + setDownloaded(csv) + }) + setLastStep(undefined) + }} + > + <i18n.Translate>Download</i18n.Translate> + </button> + </div> + </form> + + </div> + {!lastStep || lastStep.step === lastStep.total ? <div class="h-5 mb-5"/> : <div> + <div class="relative mb-5 h-5 rounded-full bg-gray-200"> + <div class="h-full animate-pulse rounded-full bg-blue-500" style={{ + width: `${Math.round((((lastStep.step / lastStep.total))* 100) )}%` + }}> + <span class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white"> + <i18n.Translate>downloading... {Math.round((((lastStep.step / lastStep.total))* 100) )}</i18n.Translate> + </span> + </div> + </div> + </div>} + {!downloaded ? <div class="h-5 mb-5"/> : + <a href={"data:text/plain;charset=utf-8," + encodeURIComponent(downloaded)} download={"bank-stats.csv"}> + <Attention title={i18n.str`Download completed`}> + <i18n.Translate>click here to save the file in your computer</i18n.Translate> + </Attention> + </a> + } + </div> ); } -async function fetchAllStatus(api: TalerCoreBankHttpClient, token: AccessToken, reference: Date, progres: (current: number, total: number) => void) { - const allMetrics = [ - TalerCorebankApi.MonitorTimeframeParam.day, - TalerCorebankApi.MonitorTimeframeParam.hour, - // TalerCorebankApi.MonitorTimeframeParam.month, - // TalerCorebankApi.MonitorTimeframeParam.year, - // TalerCorebankApi.MonitorTimeframeParam.decade, - ] - const allFrames = allMetrics.map(timeframe => ({ - timeframe, moment: getTimeframesForDate(reference, timeframe) - })) - - type Data = { - previous: TalerCorebankApi.MonitorResponse; - current: TalerCorebankApi.MonitorResponse; +async function fetchAllStatus(api: TalerCoreBankHttpClient, token: AccessToken, options: Options, references: Date[], progres: (current: number, total: number) => void): Promise<string> { + const allMetrics: TalerCorebankApi.MonitorTimeframeParam[] = []; + if (options.hourMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour) + } + if (options.dayMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.day) } + if (options.monthMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.month) + } + if (options.yearMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.year) + } + + /** + * conver request into frames + */ + const allFrames = allMetrics.flatMap(timeframe => references.map(reference => ({ + reference, + timeframe, + moment: getTimeframesForDate(reference, timeframe) + })) + ) const total = allFrames.length - - const dataResolvers = allFrames.map((frame, index) => async function getData(): Promise<Data | undefined> { - const previous = await api.getMonitor(token, { + + /** + * call API for info + */ + const allInfo = await allFrames.reduce(async (prev, frame, index) => { + const accumulatedMap = await prev + progres(index, total) + // await delay() + const previous = options.compareWithPrevious ? (await api.getMonitor(token, { timeframe: frame.timeframe, which: frame.moment.previous - }) - await delay() - if (previous.type !== "ok") return undefined; + })) : undefined + + if (previous && previous.type === "fail" && options.endOnFirstFail) { + throw TalerError.fromUncheckedDetail(previous.detail) + } + const current = await api.getMonitor(token, { timeframe: frame.timeframe, which: frame.moment.current }) - await delay() - if (current.type !== "ok") return undefined; - return { previous: previous.body, current: current.body } - }); - const csv = await dataResolvers.reduce(async (prev, resolver, index) => { - const accumulatedMap = await prev - console.log(index) - progres(index, total) - const data = await resolver() - if (!data) return accumulatedMap + if (current.type === "fail" && options.endOnFirstFail) { + throw TalerError.fromUncheckedDetail(current.detail) + } const metricName = TalerCorebankApi.MonitorTimeframeParam[allMetrics[index]] - accumulatedMap[metricName] = data + accumulatedMap[metricName] = { + reference: frame.reference, + current: current.type !== "ok" ? undefined : current.body, + previous: !previous || previous.type !== "ok" ? undefined : previous.body, + } return accumulatedMap }, Promise.resolve({} as Record<string, Data>)) progres(total, total) - console.log(csv) + + /** + * conver into table format + * + */ + const table: Array<string[]> = []; + if (options.includeHeader) { + table.push(["date", + "metric", + "reference", + "talerInCount", + "talerInVolume", + "talerOutCount", + "talerOutVolume", + "cashinCount", + "cashinFiatVolume", + "cashinRegionalVolume", + "cashoutCount", + "cashoutFiatVolume", + "cashoutRegionalVolume",]) + } + Object.entries(allInfo).forEach(([name, data]) => { + if (data.current) { + const row: TableRow = { + date: data.reference.getTime(), + metric: name, + reference: "current", + ...dataToRow(data.current) + } + table.push((Object.values(row) as string[])) + } + + if (data.previous) { + const row: TableRow = { + date: data.reference.getTime(), + metric: name, + reference: "previous", + ...dataToRow(data.previous) + } + table.push((Object.values(row) as string[])) + } + }) + + const csv = table.reduce((acc, row) => { + return acc + row.join(",") + "\n" + }, "") + + return csv +} + +type JustData = Omit<Omit<Omit<TableRow, "metric">, "date">, "reference"> +function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData { + return { + talerInCount: info.talerInCount, + talerInVolume: info.talerInVolume, + talerOutCount: info.talerOutCount, + talerOutVolume: info.talerOutVolume, + cashinCount: info.type === "no-conversions" ? undefined : info.cashinCount, + cashinFiatVolume: info.type === "no-conversions" ? undefined : info.cashinFiatVolume, + cashinRegionalVolume: info.type === "no-conversions" ? undefined : info.cashinRegionalVolume, + cashoutCount: info.type === "no-conversions" ? undefined : info.cashoutCount, + cashoutFiatVolume: info.type === "no-conversions" ? undefined : info.cashoutFiatVolume, + cashoutRegionalVolume: info.type === "no-conversions" ? undefined : info.cashoutRegionalVolume, + } +} + +type Data = { + reference: Date, + previous: TalerCorebankApi.MonitorResponse | undefined; + current: TalerCorebankApi.MonitorResponse | undefined; +} +type TableRow = { + date: number, + metric: string, + reference: "current" | "previous", + cashinCount?: number; + cashinRegionalVolume?: AmountString; + cashinFiatVolume?: AmountString; + cashoutCount?: number; + cashoutRegionalVolume?: AmountString; + cashoutFiatVolume?: AmountString; + talerInCount: number; + talerInVolume: AmountString; + talerOutCount: number; + talerOutVolume: AmountString; } async function delay() { - return new Promise((res, rej) => { - setTimeout(() => { - res(null); - }, 1000) + return new Promise(res => { + setTimeout(( )=> { + res(null) + }, 500) }) }
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/admin/AdminHome.tsx b/packages/demobank-ui/src/pages/admin/AdminHome.tsx index 9c6e6cde6..82a341dbe 100644 --- a/packages/demobank-ui/src/pages/admin/AdminHome.tsx +++ b/packages/demobank-ui/src/pages/admin/AdminHome.tsx @@ -184,7 +184,9 @@ function Metrics(): VNode { </div> </dl> <div class="flex justify-end mt-2"> - <a href="#/download-stats" class="link"><i18n.Translate> + <a href="#/download-stats" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + ><i18n.Translate> download stats as csv </i18n.Translate></a> </div> |