diff options
Diffstat (limited to 'packages/taler-wallet-webextension/src/components/WalletActivity.tsx')
-rw-r--r-- | packages/taler-wallet-webextension/src/components/WalletActivity.tsx | 1100 |
1 files changed, 1100 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx new file mode 100644 index 000000000..69a2c0675 --- /dev/null +++ b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx @@ -0,0 +1,1100 @@ +/* + This file is part of GNU Taler + (C) 2022 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 { + AbsoluteTime, + NotificationType, + ObservabilityEventType, + RequestProgressNotification, + TalerErrorCode, + TalerErrorDetail, + TaskProgressNotification, + WalletNotification, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, JSX, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { Pages } from "../NavigationBar.js"; +import { useBackendContext } from "../context/backend.js"; +import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; +import { useSettings } from "../hooks/useSettings.js"; +import { Button } from "../mui/Button.js"; +import { WxApiType } from "../wxApi.js"; +import { Modal } from "./Modal.js"; +import { Time } from "./Time.js"; + +interface Props extends JSX.HTMLAttributes {} + +export function WalletActivity({}: Props): VNode { + const { i18n } = useTranslationContext(); + const [settings, updateSettings] = useSettings(); + const api = useBackendContext(); + useEffect(() => { + document.body.style.marginBottom = "250px"; + return () => { + document.body.style.marginBottom = "0px"; + }; + }); + const [table, setTable] = useState<"tasks" | "events">("tasks"); + return ( + <div + style={{ + position: "fixed", + bottom: 0, + background: "white", + zIndex: 1, + height: 250, + overflowY: "scroll", + width: "100%", + }} + > + <div + style={{ + display: "flex", + justifyContent: "space-between", + float: "right", + }} + > + <div /> + <div> + <div + style={{ padding: 4, margin: 2, border: "solid 1px black" }} + onClick={() => { + updateSettings("showWalletActivity", false); + }} + > + close + </div> + </div> + </div> + <div style={{ display: "flex", justifyContent: "space-around" }}> + <Button + variant={table === "tasks" ? "contained" : "outlined"} + style={{ margin: 4 }} + onClick={async () => { + setTable("tasks"); + }} + > + <i18n.Translate>Tasks</i18n.Translate> + </Button> + <Button + variant={table === "events" ? "contained" : "outlined"} + style={{ margin: 4 }} + onClick={async () => { + setTable("events"); + }} + > + <i18n.Translate>Events</i18n.Translate> + </Button> + </div> + {(function (): VNode { + switch (table) { + case "events": { + return <ObservabilityEventsTable />; + } + case "tasks": { + return <ActiveTasksTable />; + } + default: { + assertUnreachable(table); + } + } + })()} + </div> + ); +} + +interface MoreInfoPRops { + events: (WalletNotification & { when: AbsoluteTime })[]; + onClick: (content: VNode) => void; +} +type Notif = { + id: string; + events: (WalletNotification & { when: AbsoluteTime })[]; + description: string; + start: AbsoluteTime; + end: AbsoluteTime; + reference: + | { + eventType: NotificationType; + referenceType: "task" | "transaction" | "operation" | "exchange"; + id: string; + } + | undefined; + MoreInfo: (p: MoreInfoPRops) => VNode; +}; + +function ShowBalanceChange({ events }: MoreInfoPRops): VNode { + if (!events.length) return <Fragment />; + const not = events[0]; + if (not.type !== NotificationType.BalanceChange) return <Fragment />; + return ( + <Fragment> + <dt>Transaction</dt> + <dd> + <a + title={not.hintTransactionId} + href={Pages.balanceTransaction({ tid: not.hintTransactionId })} + > + {not.hintTransactionId.substring(0, 10)} + </a> + </dd> + </Fragment> + ); +} + +function ShowBackupOperationError({ events, onClick }: MoreInfoPRops): VNode { + if (!events.length) return <Fragment />; + const not = events[0]; + if (not.type !== NotificationType.BackupOperationError) return <Fragment />; + return ( + <Fragment> + <dt>Error</dt> + <dd> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + const error = not.error; + onClick( + <Fragment> + <dl> + <dt>Code</dt> + <dd> + {TalerErrorCode[error.code]} ({error.code}) + </dd> + <dt>Hint</dt> + <dd>{error.hint ?? "--"}</dd> + <dt>Time</dt> + <dd> + <Time timestamp={error.when} format="yyyy/MM/dd HH:mm:ss" /> + </dd> + </dl> + <pre + style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }} + > + {JSON.stringify(error, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {TalerErrorCode[not.error.code]} + </a> + </dd> + </Fragment> + ); +} + +function ShowTransactionStateTransition({ + events, + onClick, +}: MoreInfoPRops): VNode { + if (!events.length) return <Fragment />; + const not = events[0]; + if (not.type !== NotificationType.TransactionStateTransition) + return <Fragment />; + return ( + <Fragment> + <dt>Old state</dt> + <dd> + {not.oldTxState.major} - {not.oldTxState.minor ?? ""} + </dd> + <dt>New state</dt> + <dd> + {not.newTxState.major} - {not.newTxState.minor ?? ""} + </dd> + <dt>Transaction</dt> + <dd> + <a + title={not.transactionId} + href={Pages.balanceTransaction({ tid: not.transactionId })} + > + {not.transactionId.substring(0, 10)} + </a> + </dd> + {not.errorInfo ? ( + <Fragment> + <dt>Error</dt> + <dd> + <a + href="#" + onClick={(e) => { + if (!not.errorInfo) return; + e.preventDefault(); + const error = not.errorInfo; + onClick( + <Fragment> + <dl> + <dt>Code</dt> + <dd> + {TalerErrorCode[error.code]} ({error.code}) + </dd> + <dt>Hint</dt> + <dd>{error.hint ?? "--"}</dd> + <dt>Message</dt> + <dd>{error.message ?? "--"}</dd> + </dl> + </Fragment>, + ); + }} + > + {TalerErrorCode[not.errorInfo.code]} + </a> + </dd> + </Fragment> + ) : undefined} + <dt>Experimental</dt> + <dd> + <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}> + {JSON.stringify(not.experimentalUserData, undefined, 2)} + </pre> + </dd> + </Fragment> + ); +} +function ShowExchangeStateTransition({ + events, + onClick, +}: MoreInfoPRops): VNode { + if (!events.length) return <Fragment />; + const not = events[0]; + if (not.type !== NotificationType.ExchangeStateTransition) + return <Fragment />; + return ( + <Fragment> + <dt>Exchange</dt> + <dd>{not.exchangeBaseUrl}</dd> + {not.oldExchangeState && + not.newExchangeState.exchangeEntryStatus !== + not.oldExchangeState?.exchangeEntryStatus && ( + <Fragment> + <dt>Entry status</dt> + <dd> + from {not.oldExchangeState.exchangeEntryStatus} to{" "} + {not.newExchangeState.exchangeEntryStatus} + </dd> + </Fragment> + )} + {not.oldExchangeState && + not.newExchangeState.exchangeUpdateStatus !== + not.oldExchangeState?.exchangeUpdateStatus && ( + <Fragment> + <dt>Update status</dt> + <dd> + from {not.oldExchangeState.exchangeUpdateStatus} to{" "} + {not.newExchangeState.exchangeUpdateStatus} + </dd> + </Fragment> + )} + {not.oldExchangeState && + not.newExchangeState.tosStatus !== not.oldExchangeState?.tosStatus && ( + <Fragment> + <dt>Tos status</dt> + <dd> + from {not.oldExchangeState.tosStatus} to{" "} + {not.newExchangeState.tosStatus} + </dd> + </Fragment> + )} + </Fragment> + ); +} + +type ObservaNotifWithTime = ( + | TaskProgressNotification + | RequestProgressNotification +) & { + when: AbsoluteTime; +}; +function ShowObservabilityEvent({ events, onClick }: MoreInfoPRops): VNode { + // let prev: ObservaNotifWithTime; + const asd = events.map((not) => { + if ( + not.type !== NotificationType.RequestObservabilityEvent && + not.type !== NotificationType.TaskObservabilityEvent + ) + return <Fragment />; + + const title = (function () { + switch (not.event.type) { + case ObservabilityEventType.HttpFetchFinishError: + case ObservabilityEventType.HttpFetchFinishSuccess: + case ObservabilityEventType.HttpFetchStart: + return "HTTP Request"; + case ObservabilityEventType.DbQueryFinishSuccess: + case ObservabilityEventType.DbQueryFinishError: + case ObservabilityEventType.DbQueryStart: + return "Database"; + case ObservabilityEventType.RequestFinishSuccess: + case ObservabilityEventType.RequestFinishError: + case ObservabilityEventType.RequestStart: + return "Wallet"; + case ObservabilityEventType.CryptoFinishSuccess: + case ObservabilityEventType.CryptoFinishError: + case ObservabilityEventType.CryptoStart: + return "Crypto"; + case ObservabilityEventType.TaskStart: + return "Task start"; + case ObservabilityEventType.TaskStop: + return "Task stop"; + case ObservabilityEventType.TaskReset: + return "Task reset"; + case ObservabilityEventType.ShepherdTaskResult: + return "Schedule"; + case ObservabilityEventType.DeclareTaskDependency: + return "Task dependency"; + case ObservabilityEventType.Message: + return "Message"; + } + })(); + + return ( + <ShowObervavilityDetails title={title} notif={not} onClick={onClick} /> + ); + }); + return ( + <table> + <thead> + <td>Event</td> + <td>Info</td> + <td>Start</td> + <td>End</td> + </thead> + <tbody>{asd}</tbody> + </table> + ); +} + +function ShowObervavilityDetails({ + title, + notif, + onClick, + prev, +}: { + title: string; + notif: ObservaNotifWithTime; + prev?: ObservaNotifWithTime; + onClick: (content: VNode) => void; +}): VNode { + switch (notif.event.type) { + case ObservabilityEventType.HttpFetchStart: + case ObservabilityEventType.HttpFetchFinishError: + case ObservabilityEventType.HttpFetchFinishSuccess: { + return ( + <tr> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onClick( + <Fragment> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify({ event: notif, prev }, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {title} + </a> + </td> + <td> + {notif.event.url}{" "} + {prev?.event.type === + ObservabilityEventType.HttpFetchFinishSuccess ? ( + `(${prev.event.status})` + ) : prev?.event.type === + ObservabilityEventType.HttpFetchFinishError ? ( + <a + href="#" + onClick={(e) => { + e.preventDefault(); + if ( + prev.event.type !== + ObservabilityEventType.HttpFetchFinishError + ) + return; + const error = prev.event.error; + onClick( + <Fragment> + <dl> + <dt>Code</dt> + <dd> + {TalerErrorCode[error.code]} ({error.code}) + </dd> + <dt>Hint</dt> + <dd>{error.hint ?? "--"}</dd> + <dt>Time</dt> + <dd> + <Time + timestamp={error.when} + format="yyyy/MM/dd HH:mm:ss" + /> + </dd> + </dl> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify(error, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + fail + </a> + ) : undefined} + </td> + <td> + {" "} + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + {" "} + <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + </tr> + ); + } + case ObservabilityEventType.DbQueryStart: + case ObservabilityEventType.DbQueryFinishSuccess: + case ObservabilityEventType.DbQueryFinishError: { + return ( + <tr> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onClick( + <Fragment> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify({ event: notif, prev }, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {title} + </a> + </td> + <td> + {notif.event.location} {notif.event.name} + </td> + <td> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + </tr> + ); + } + + case ObservabilityEventType.TaskStart: + case ObservabilityEventType.TaskStop: + case ObservabilityEventType.DeclareTaskDependency: + case ObservabilityEventType.TaskReset: { + return ( + <tr> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onClick( + <Fragment> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify({ event: notif, prev }, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {title} + </a> + </td> + <td>{notif.event.taskId}</td> + <td> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + </tr> + ); + } + case ObservabilityEventType.ShepherdTaskResult: { + return ( + <tr> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onClick( + <Fragment> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify({ event: notif, prev }, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {title} + </a> + </td> + <td>{notif.event.resultType}</td> + <td> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + </tr> + ); + } + case ObservabilityEventType.CryptoStart: + case ObservabilityEventType.CryptoFinishSuccess: + case ObservabilityEventType.CryptoFinishError: { + return ( + <tr> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onClick( + <Fragment> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify({ event: notif, prev }, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {title} + </a> + </td> + <td>{notif.event.operation}</td> + <td> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + </tr> + ); + } + case ObservabilityEventType.RequestStart: + case ObservabilityEventType.RequestFinishSuccess: + case ObservabilityEventType.RequestFinishError: { + return ( + <tr> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onClick( + <Fragment> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify({ event: notif, prev }, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {title} + </a> + </td> + <td>{notif.event.type}</td> + <td> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + </tr> + ); + } + case ObservabilityEventType.Message: + // FIXME + return <></>; + } +} + +function getNotificationFor( + id: string, + event: WalletNotification, + start: AbsoluteTime, + list: Notif[], +): Notif | undefined { + const eventWithTime = { ...event, when: start }; + switch (event.type) { + case NotificationType.BalanceChange: { + return { + id, + events: [eventWithTime], + reference: { + eventType: event.type, + referenceType: "transaction", + id: event.hintTransactionId, + }, + description: "Balance change", + start, + end: AbsoluteTime.never(), + MoreInfo: ShowBalanceChange, + }; + } + case NotificationType.BackupOperationError: { + return { + id, + events: [eventWithTime], + reference: undefined, + description: "Backup error", + start, + end: AbsoluteTime.never(), + MoreInfo: ShowBackupOperationError, + }; + } + case NotificationType.TransactionStateTransition: { + const found = list.find( + (a) => + a.reference?.eventType === event.type && + a.reference.id === event.transactionId, + ); + if (found) { + found.end = start; + found.events.unshift(eventWithTime); + return undefined; + } + return { + id, + events: [eventWithTime], + reference: { + eventType: event.type, + referenceType: "transaction", + id: event.transactionId, + }, + description: event.type, + start, + end: AbsoluteTime.never(), + MoreInfo: ShowTransactionStateTransition, + }; + } + case NotificationType.ExchangeStateTransition: { + const found = list.find( + (a) => + a.reference?.eventType === event.type && + a.reference.id === event.exchangeBaseUrl, + ); + if (found) { + found.end = start; + found.events.unshift(eventWithTime); + return undefined; + } + return { + id, + events: [eventWithTime], + description: "Exchange update", + reference: { + eventType: event.type, + referenceType: "exchange", + id: event.exchangeBaseUrl, + }, + start, + end: AbsoluteTime.never(), + MoreInfo: ShowExchangeStateTransition, + }; + } + case NotificationType.TaskObservabilityEvent: { + const found = list.find( + (a) => + a.reference?.eventType === event.type && + a.reference.id === event.taskId, + ); + if (found) { + found.end = start; + found.events.unshift(eventWithTime); + return undefined; + } + return { + id, + events: [eventWithTime], + reference: { + eventType: event.type, + referenceType: "task", + id: event.taskId, + }, + description: `Task update ${event.taskId}`, + start, + end: AbsoluteTime.never(), + MoreInfo: ShowObservabilityEvent, + }; + } + case NotificationType.WithdrawalOperationTransition: { + const found = list.find( + (a) => + a.reference?.eventType === event.type && a.reference.id === event.uri, + ); + if (found) { + found.end = start; + found.events.unshift(eventWithTime); + return undefined; + } + return { + id, + events: [eventWithTime], + reference: { + eventType: event.type, + referenceType: "task", + id: event.uri, + }, + description: `Withdrawal operation updated`, + start, + end: AbsoluteTime.never(), + MoreInfo: ShowObservabilityEvent, + }; + } + case NotificationType.RequestObservabilityEvent: { + const found = list.find( + (a) => + a.reference?.eventType === event.type && + a.reference.id === event.requestId, + ); + if (found) { + found.end = start; + found.events.unshift(eventWithTime); + return undefined; + } + return { + id, + events: [eventWithTime], + reference: { + eventType: event.type, + referenceType: "operation", + id: event.requestId, + }, + description: `wallet.${event.operation}(${event.requestId})`, + start, + end: AbsoluteTime.never(), + MoreInfo: ShowObservabilityEvent, + }; + } + case NotificationType.Idle: + return undefined; + default: { + assertUnreachable(event); + } + } +} + +function refresh(api: WxApiType, onUpdate: (list: Notif[]) => void) { + api.background + .call("getNotifications", undefined) + .then((notif) => { + const list: Notif[] = []; + for (const n of notif) { + if ( + n.notification.type === NotificationType.RequestObservabilityEvent && + n.notification.operation === "getActiveTasks" + ) { + //ignore monitor request + continue; + } + const event = getNotificationFor( + String(list.length), + n.notification, + n.when, + list, + ); + // pepe. + if (event) { + list.unshift(event); + } + } + onUpdate(list); + }) + .catch((error) => { + console.log(error); + }); +} + +export function ObservabilityEventsTable({}: {}): VNode { + const { i18n } = useTranslationContext(); + const api = useBackendContext(); + + const [notifications, setNotifications] = useState<Notif[]>([]); + const [showDetails, setShowDetails] = useState<VNode>(); + + useEffect(() => { + let lastTimeout: ReturnType<typeof setTimeout>; + function periodicRefresh() { + refresh(api, setNotifications); + + lastTimeout = setTimeout(() => { + periodicRefresh(); + }, 1000); + + //clear on unload + return () => { + clearTimeout(lastTimeout); + }; + } + return periodicRefresh(); + }, [1]); + + return ( + <div> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div + style={{ padding: 4, margin: 2, border: "solid 1px black" }} + onClick={() => { + api.background.call("clearNotifications", undefined).then((d) => { + refresh(api, setNotifications); + }); + }} + > + clear + </div> + </div> + {showDetails && ( + <Modal + title="event details" + onClose={{ + onClick: (async () => { + setShowDetails(undefined); + }) as any, + }} + > + {showDetails} + </Modal> + )} + {notifications.map((not) => { + return ( + <details key={not.id}> + <summary> + <div + style={{ + width: "90%", + display: "inline-flex", + justifyContent: "space-between", + padding: 4, + }} + > + <div style={{ padding: 4 }}>{not.description}</div> + <div style={{ padding: 4 }}> + <Time timestamp={not.start} format="yyyy/MM/dd HH:mm:ss" /> + </div> + <div style={{ padding: 4 }}> + <Time timestamp={not.end} format="yyyy/MM/dd HH:mm:ss" /> + </div> + </div> + </summary> + <not.MoreInfo + events={not.events} + onClick={(details) => { + setShowDetails(details); + }} + /> + </details> + ); + })} + </div> + ); +} + +function ErroDetailModal({ + error, + onClose, +}: { + error: TalerErrorDetail; + onClose: () => void; +}): VNode { + return ( + <Modal + title="Full detail" + onClose={{ + onClick: onClose as any, + }} + > + <dl> + <dt>Code</dt> + <dd> + {TalerErrorCode[error.code]} ({error.code}) + </dd> + <dt>Hint</dt> + <dd>{error.hint ?? "--"}</dd> + <dt>Time</dt> + <dd> + <Time timestamp={error.when} format="yyyy/MM/dd HH:mm:ss" /> + </dd> + </dl> + <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}> + {JSON.stringify(error, undefined, 2)} + </pre> + </Modal> + ); +} + +export function ActiveTasksTable({}: {}): VNode { + const { i18n } = useTranslationContext(); + const api = useBackendContext(); + const state = useAsyncAsHook(() => { + return api.wallet.call(WalletApiOperation.GetActiveTasks, {}); + }); + const [showError, setShowError] = useState<TalerErrorDetail>(); + const tasks = state && !state.hasError ? state.response.tasks : []; + + useEffect(() => { + if (!state || state.hasError) return; + const lastTimeout = setTimeout(() => { + state.retry(); + }, 1000); + return () => { + clearTimeout(lastTimeout); + }; + }, [tasks]); + + // const listenAllEvents = Array.from<NotificationType>({ length: 1 }); + // listenAllEvents.includes = () => true + // useEffect(() => { + // return api.listener.onUpdateNotification(listenAllEvents, (notif) => { + // state?.retry() + // }); + // }); + return ( + <Fragment> + {showError && ( + <ErroDetailModal + error={showError} + onClose={async () => { + setShowError(undefined); + }} + /> + )} + + <table style={{ width: "100%" }}> + <thead> + <tr> + <th> + <i18n.Translate>Type</i18n.Translate> + </th> + <th> + <i18n.Translate>Id</i18n.Translate> + </th> + <th> + <i18n.Translate>Since</i18n.Translate> + </th> + <th> + <i18n.Translate>Next try</i18n.Translate> + </th> + <th> + <i18n.Translate>Error</i18n.Translate> + </th> + <th> + <i18n.Translate>Transaction</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + {tasks.map((task) => { + const [type, id] = task.taskId.split(":"); + return ( + <tr> + <td>{type}</td> + <td title={id}>{id.substring(0, 10)}</td> + <td> + <Time + timestamp={task.firstTry} + format="yyyy/MM/dd HH:mm:ss" + /> + </td> + <td> + <Time timestamp={task.nextTry} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + {!task.lastError?.code ? ( + "" + ) : ( + <a + href="#" + onClick={(e) => { + e.preventDefault(); + setShowError(task.lastError); + }} + > + {TalerErrorCode[task.lastError.code]} + </a> + )} + </td> + <td> + {task.transaction ? ( + <a + title={task.transaction} + href={Pages.balanceTransaction({ tid: task.transaction })} + > + {task.transaction.substring(0, 10)} + </a> + ) : ( + "--" + )} + </td> + </tr> + ); + })} + </tbody> + </table> + </Fragment> + ); +} |