diff options
Diffstat (limited to 'packages/bank-ui/src/pages/admin/DownloadStats.tsx')
-rw-r--r-- | packages/bank-ui/src/pages/admin/DownloadStats.tsx | 585 |
1 files changed, 585 insertions, 0 deletions
diff --git a/packages/bank-ui/src/pages/admin/DownloadStats.tsx b/packages/bank-ui/src/pages/admin/DownloadStats.tsx new file mode 100644 index 000000000..fba366676 --- /dev/null +++ b/packages/bank-ui/src/pages/admin/DownloadStats.tsx @@ -0,0 +1,585 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AccessToken, + AmountString, + TalerCoreBankHttpClient, + TalerCorebankApi, + TalerError, +} from "@gnu-taler/taler-util"; +import { + Attention, + LocalNotificationBanner, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useSessionState } from "../../hooks/session.js"; +import { EmptyObject, RouteDefinition } from "../../route.js"; +import { getTimeframesForDate } from "./AdminHome.js"; + +interface Props { + routeCancel: RouteDefinition; +} + +type Options = { + dayMetric: boolean; + hourMetric: boolean; + monthMetric: boolean; + yearMetric: boolean; + compareWithPrevious: boolean; + endOnFirstFail: boolean; + includeHeader: boolean; +}; + +/** + * Show histories of public accounts. + */ +export function DownloadStats({ routeCancel }: Props): VNode { + const { i18n } = useTranslationContext(); + + const { state: credentials } = useSessionState(); + const creds = + credentials.status !== "loggedIn" || !credentials.isUserAdministrator + ? undefined + : credentials; + const { bank: api } = useBankCoreApiContext(); + + 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, , handleError] = useLocalNotification(); + + if (!creds) { + return <div>only admin can download stats</div>; + } + + return ( + <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" + name={`hour switch`} + 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" + name={`day switch`} + 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" + name={`month switch`} + 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" + name={`year switch`} + 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" + name={`header switch`} + 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" + name={`compare switch`} + 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" + name={`fail switch`} + 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"> + <a name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="download" + 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) + } + name="save file" + 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, + options: Options, + references: Date[], + progress: (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); + } + + /** + * convert request into frames + */ + const allFrames = allMetrics.flatMap((timeframe) => + references.map((reference) => ({ + reference, + timeframe, + moment: getTimeframesForDate(reference, timeframe), + })), + ); + const total = allFrames.length; + + /** + * call API for info + */ + const allInfo = await allFrames.reduce( + async (prev, frame, index) => { + const accumulatedMap = await prev; + progress(index, total); + // await delay() + const previous = options.compareWithPrevious + ? await api.getMonitor(token, { + timeframe: frame.timeframe, + which: frame.moment.previous, + }) + : 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, + }); + + if (current.type === "fail" && options.endOnFirstFail) { + throw TalerError.fromUncheckedDetail(current.detail); + } + + const metricName = + TalerCorebankApi.MonitorTimeframeParam[allMetrics[index]]; + 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>), + ); + progress(total, total); + + /** + * convert 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; +}; |