taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit db8877a68832c93f706802bbf75929190d95ebe0
parent 75aff9ad57999bb8f89725cb07fd863235becae6
Author: Florian Dold <dold@taler.net>
Date:   Mon, 24 Aug 2026 02:30:03 +0200

bank web UI: report partial statistics download failures

Diffstat:
Mpackages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx | 198+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
1 file changed, 114 insertions(+), 84 deletions(-)

diff --git a/packages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx b/packages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx @@ -18,6 +18,7 @@ import { AccessToken, AmountString, OperationOk, + TalerCoreBankErrorsByMethod, TalerCoreBankHttpClient, TalerCorebankApi, opFixedSuccess, @@ -28,6 +29,7 @@ import { AsyncButton, RouteDefinition, useBankCoreApiContext, + useNotificationContext, useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -77,7 +79,8 @@ export function DownloadStats({ routeCancel }: Props): VNode { yearMetric: true, }); const [lastStep, setLastStep] = useState<{ step: number; total: number }>(); - const [downloaded, setDownloaded] = useState<string>(); + const [downloaded, setDownloaded] = useState<StatsDownload>(); + const { showError } = useNotificationContext(); const referenceDates = [new Date()]; // i18n.str`download statistics`, const download = useNotifiedOperation< @@ -86,21 +89,28 @@ export function DownloadStats({ routeCancel }: Props): VNode { >( async (ct, token) => { setDownloaded(undefined); - return fetchAllStatus( - api, - token, - options, - referenceDates, - (step, total) => { - setLastStep({ step, total }); - }, - ); + try { + return await fetchAllStatus( + api, + token, + options, + referenceDates, + (step, total) => { + setLastStep({ step, total }); + }, + ); + } finally { + setLastStep(undefined); + } }, { onSuccess: (success) => { setDownloaded(success); - setLastStep(undefined); }, + onFail: showError( + i18n.str`Failed to download statistics.`, + () => i18n.str`A statistics request failed. Please try again.`, + ), }, ); @@ -132,7 +142,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <span class="flex flex-grow flex-col"> <span class="text-sm text-black font-medium leading-6 " - id="availability-label" + id="hour-metric-label" > <i18n.Translate>Include hour metric</i18n.Translate> </span> @@ -143,9 +153,8 @@ export function DownloadStats({ routeCancel }: Props): VNode { 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" + aria-checked={options.hourMetric} + aria-labelledby="hour-metric-label" onClick={() => { setOptions({ ...options, @@ -166,7 +175,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <span class="flex flex-grow flex-col"> <span class="text-sm text-black font-medium leading-6 " - id="availability-label" + id="day-metric-label" > <i18n.Translate>Include day metric</i18n.Translate> </span> @@ -177,9 +186,8 @@ export function DownloadStats({ routeCancel }: Props): VNode { 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" + aria-checked={options.dayMetric} + aria-labelledby="day-metric-label" onClick={() => { setOptions({ ...options, dayMetric: !options.dayMetric }); }} @@ -197,7 +205,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <span class="flex flex-grow flex-col"> <span class="text-sm text-black font-medium leading-6 " - id="availability-label" + id="month-metric-label" > <i18n.Translate>Include month metric</i18n.Translate> </span> @@ -208,9 +216,8 @@ export function DownloadStats({ routeCancel }: Props): VNode { 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" + aria-checked={options.monthMetric} + aria-labelledby="month-metric-label" onClick={() => { setOptions({ ...options, @@ -231,7 +238,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <span class="flex flex-grow flex-col"> <span class="text-sm text-black font-medium leading-6 " - id="availability-label" + id="year-metric-label" > <i18n.Translate>Include year metric</i18n.Translate> </span> @@ -242,9 +249,8 @@ export function DownloadStats({ routeCancel }: Props): VNode { 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" + aria-checked={options.yearMetric} + aria-labelledby="year-metric-label" onClick={() => { setOptions({ ...options, @@ -265,7 +271,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <span class="flex flex-grow flex-col"> <span class="text-sm text-black font-medium leading-6 " - id="availability-label" + id="include-header-label" > <i18n.Translate>Include table header</i18n.Translate> </span> @@ -276,9 +282,8 @@ export function DownloadStats({ routeCancel }: Props): VNode { 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" + aria-checked={options.includeHeader} + aria-labelledby="include-header-label" onClick={() => { setOptions({ ...options, @@ -299,7 +304,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <span class="flex flex-grow flex-col"> <span class="text-sm text-black font-medium leading-6 " - id="availability-label" + id="compare-previous-label" > <i18n.Translate> Add previous metric for comparison @@ -312,9 +317,8 @@ export function DownloadStats({ routeCancel }: Props): VNode { 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" + aria-checked={options.compareWithPrevious} + aria-labelledby="compare-previous-label" onClick={() => { setOptions({ ...options, @@ -335,7 +339,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { <span class="flex flex-grow flex-col"> <span class="text-sm text-black font-medium leading-6 " - id="availability-label" + id="end-on-failure-label" > <i18n.Translate>Fail on first error</i18n.Translate> </span> @@ -346,9 +350,8 @@ export function DownloadStats({ routeCancel }: Props): VNode { 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" + aria-checked={options.endOnFirstFail} + aria-labelledby="end-on-failure-label" onClick={() => { setOptions({ ...options, @@ -412,15 +415,31 @@ export function DownloadStats({ routeCancel }: Props): VNode { ) : ( <a href={ - "data:text/plain;charset=utf-8," + encodeURIComponent(downloaded) + "data:text/plain;charset=utf-8," + + encodeURIComponent(downloaded.csv) } name="save file" download={"bank-stats.csv"} > - <Attention title={i18n.str`Download completed`}> - <i18n.Translate> - Click here to save the file on your computer. - </i18n.Translate> + <Attention + type={downloaded.failures.length ? "warning" : undefined} + title={ + downloaded.failures.length + ? i18n.str`Download completed with missing data` + : i18n.str`Download completed` + } + > + {downloaded.failures.length ? ( + <i18n.Translate> + {downloaded.failures.length} statistics requests failed. The + file contains only the data that could be retrieved. Click here + to save it. + </i18n.Translate> + ) : ( + <i18n.Translate> + Click here to save the file on your computer. + </i18n.Translate> + )} </Attention> </a> )} @@ -434,7 +453,9 @@ async function fetchAllStatus( options: Options, references: Date[], progress: (current: number, total: number) => void, -): Promise<OperationOk<string>> { +): Promise< + OperationOk<StatsDownload> | TalerCoreBankErrorsByMethod<"getMonitor"> +> { const allMetrics: TalerCorebankApi.MonitorTimeframeParam[] = []; if (options.hourMetric) { allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour); @@ -464,43 +485,37 @@ async function fetchAllStatus( /** * 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, - date: frame.moment.previous, - }) - : undefined; - - if (previous && previous.type === "fail" && options.endOnFirstFail) { - return accumulatedMap; //skip - } - - const current = await api.getMonitor(token, { - timeframe: frame.timeframe, - date: frame.moment.current, - }); + const allInfo: Data[] = []; + const failures: FailedFrame[] = []; + for (const [index, frame] of allFrames.entries()) { + progress(index, total); + const previous = options.compareWithPrevious + ? await api.getMonitor(token, { + timeframe: frame.timeframe, + date: frame.moment.previous, + }) + : undefined; + if (previous?.type === "fail") { + failures.push({ frame, reference: "previous" }); + if (options.endOnFirstFail) return previous; + } - if (current.type === "fail" && options.endOnFirstFail) { - return accumulatedMap; //skip - } + const current = await api.getMonitor(token, { + timeframe: frame.timeframe, + date: frame.moment.current, + }); + if (current.type === "fail") { + failures.push({ frame, reference: "current" }); + if (options.endOnFirstFail) return current; + } - 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>), - ); + allInfo.push({ + name: TalerCorebankApi.MonitorTimeframeParam[frame.timeframe], + reference: frame.reference, + current: current.type === "ok" ? current.body : undefined, + previous: previous?.type === "ok" ? previous.body : undefined, + }); + } progress(total, total); /** @@ -525,11 +540,11 @@ async function fetchAllStatus( "cashoutRegionalVolume", ]); } - Object.entries(allInfo).forEach(([name, data]) => { + allInfo.forEach((data) => { if (data.current) { const row: TableRow = { date: data.reference.getTime(), - metric: name, + metric: data.name, reference: "current", ...dataToRow(data.current), }; @@ -539,7 +554,7 @@ async function fetchAllStatus( if (data.previous) { const row: TableRow = { date: data.reference.getTime(), - metric: name, + metric: data.name, reference: "previous", ...dataToRow(data.previous), }; @@ -551,7 +566,21 @@ async function fetchAllStatus( return acc + row.join(",") + "\n"; }, ""); - return opFixedSuccess(dummyHttpResponse, csv); + return opFixedSuccess(dummyHttpResponse, { csv, failures }); +} + +interface StatsDownload { + csv: string; + failures: FailedFrame[]; +} + +interface FailedFrame { + frame: { + reference: Date; + timeframe: TalerCorebankApi.MonitorTimeframeParam; + moment: ReturnType<typeof getTimeframesForDate>; + }; + reference: "current" | "previous"; } type JustData = Omit<Omit<Omit<TableRow, "metric">, "date">, "reference">; @@ -576,6 +605,7 @@ function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData { } type Data = { + name: string; reference: Date; previous: TalerCorebankApi.MonitorResponse | undefined; current: TalerCorebankApi.MonitorResponse | undefined;