summaryrefslogtreecommitdiff
path: root/packages/bank-ui/src/pages/admin/DownloadStats.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/bank-ui/src/pages/admin/DownloadStats.tsx')
-rw-r--r--packages/bank-ui/src/pages/admin/DownloadStats.tsx585
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;
+};