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:
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;