commit ac0bcb6bbf7613fdd1dae783a65f1621d0ebf519 parent bc009847f578335db9ff972263a9a56a69c2a618 Author: Florian Dold <dold@taler.net> Date: Mon, 31 Aug 2026 19:14:33 +0200 auditor web UI: show every monitoring report and balance Diffstat:
17 files changed, 748 insertions(+), 407 deletions(-)
diff --git a/packages/taler-auditor-webui/src/InstanceRoutes.tsx b/packages/taler-auditor-webui/src/InstanceRoutes.tsx @@ -8,7 +8,7 @@ */ import type { VNode } from "preact"; -import { useErrorBoundary, useMemo } from "preact/hooks"; +import { useEffect, useErrorBoundary, useMemo } from "preact/hooks"; import { Redirect, Route, Switch, useLocation } from "wouter-preact"; import { Menu, NotificationCard } from "./components/menu/index.js"; import { EntityContextProvider } from "./context/entity.js"; @@ -19,154 +19,38 @@ import NotFoundPage from "./paths/notfound/index.js"; import OperationsDashboard from "./paths/operations/index.js"; import SecurityDashboard from "./paths/security/index.js"; import { Settings } from "./paths/settings/index.js"; -import { Paths } from "./routing/monitoringRoutes.js"; +import { + Paths, + reportForMonitoringPath, +} from "./routing/monitoringRoutes.js"; import type { HttpError } from "./utils/http.js"; import type { AuditorBackend } from "./declaration.js"; -interface EntityRoute { - title: string; - endpoint: string; - entity: unknown; -} - -const entityRoutes: Partial<Record<Paths, EntityRoute>> = { - [Paths.amount_arithmethic_inconsistency_list]: { - title: "Amount arithmetic inconsistencies", - endpoint: "amount-arithmetic-inconsistencies", - entity: null, - }, - [Paths.bad_sig_losses_list]: { - title: "Bad signature losses", - endpoint: "bad-sig-losses", - entity: null, - }, - [Paths.balance_list]: { title: "Balances", endpoint: "balances", entity: null }, - [Paths.closure_lag_list]: { - title: "Closure lags", - endpoint: "closure-lags", - entity: null, - }, - [Paths.coin_inconsistency_list]: { - title: "Coin inconsistencies", - endpoint: "coin-inconsistencies", - entity: null, - }, - [Paths.denomination_key_validity_withdraw_inconsistency_list]: { - title: "Invalid denomination keys at withdrawal", - endpoint: "denomination-key-validity-withdraw-inconsistencies", - entity: null, - }, - [Paths.denomination_without_sig_list]: { - title: "Denominations without signatures", - endpoint: "denominations-without-sigs", - entity: null, - }, - [Paths.deposit_confirmation_list]: { - title: "Deposit confirmations", - endpoint: "deposit-confirmations", - entity: null, - }, - [Paths.emergency_list]: { title: "Emergencies", endpoint: "emergencies", entity: null }, - [Paths.emergency_by_count_list]: { - title: "Emergencies by count", - endpoint: "emergencies-by-count", - entity: null, - }, - [Paths.exchange_signkey_list]: { - title: "Exchange signing keys", - endpoint: "exchange-sign-keys", - entity: null, - }, - [Paths.fee_time_inconsistency_list]: { - title: "Fee time inconsistencies", - endpoint: "fee-time-inconsistencies", - entity: null, - }, - [Paths.historic_denomination_revenue_list]: { - title: "Historic denomination revenues", - endpoint: "historic-denomination-revenues", - entity: null, - }, - [Paths.historic_reserve_summary_list]: { - title: "Historic reserve summaries", - endpoint: "historic-reserve-summaries", - entity: null, - }, - [Paths.misattribution_in_inconsistency_list]: { - title: "Misattribution in inconsistencies", - endpoint: "misattribution-in-inconsistencies", - entity: null, - }, - [Paths.progress_list]: { title: "Progress", endpoint: "progress", entity: null }, - [Paths.purse_not_closed_inconsistency_list]: { - title: "Purses not closed", - endpoint: "purse-not-closed-inconsistencies", - entity: null, - }, - [Paths.purse_list]: { title: "Purses", endpoint: "purses", entity: null }, - [Paths.reserve_balance_insufficient_inconsistency_list]: { - title: "Insufficient reserve balances", - endpoint: "reserve-balance-insufficient-inconsistencies", - entity: null, - }, - [Paths.reserve_balance_summary_wrong_inconsistency_list]: { - title: "Wrong reserve balance summaries", - endpoint: "reserve-balance-summary-wrong-inconsistencies", - entity: null, - }, - [Paths.reserve_in_inconsistency_list]: { - title: "Reserve-in inconsistencies", - endpoint: "reserve-in-inconsistencies", - entity: null, - }, - [Paths.reserve_not_closed_inconsistency_list]: { - title: "Reserves not closed", - endpoint: "reserve-not-closed-inconsistencies", - entity: null, - }, - [Paths.reserves_list]: { title: "Reserves", endpoint: "reserves", entity: null }, - [Paths.row_inconsistency_list]: { - title: "Row inconsistencies", - endpoint: "row-inconsistencies", - entity: null, - }, - [Paths.row_minor_inconsistency_list]: { - title: "Minor row inconsistencies", - endpoint: "row-minor-inconsistencies", - entity: null, - }, - [Paths.wire_format_inconsistency_list]: { - title: "Wire format inconsistencies", - endpoint: "wire-format-inconsistencies", - entity: null, - }, - [Paths.wire_out_inconsistency_list]: { - title: "Wire-out inconsistencies", - endpoint: "wire-out-inconsistencies", - entity: null, - }, -}; - const dashboardTitles: Partial<Record<Paths, string>> = { [Paths.key_figures]: "Key figures", [Paths.critical_errors]: "Critical errors", [Paths.operating_status]: "Operating status", - [Paths.detail_view]: "Inconsistencies", + [Paths.detail_view]: "All reports", [Paths.settings]: "Settings", }; export function InstanceRoutes(): VNode { const [path] = useLocation(); const [error] = useErrorBoundary(); - const entityRoute = entityRoutes[path as Paths]; + const report = reportForMonitoringPath(path); + + useEffect(() => window.scrollTo(0, 0), [path]); + const value = useMemo( () => ({ - title: entityRoute?.title ?? dashboardTitles[path as Paths] ?? "", + title: report?.title ?? dashboardTitles[path as Paths] ?? "", path, - endpoint: entityRoute?.endpoint ?? "", - entity: entityRoute?.entity ?? null, + endpoint: report?.endpoint ?? "", + description: report?.description ?? "", + suppressible: report?.suppressible ?? false, + rowIdField: report?.rowIdField ?? "row_id", }), - [entityRoute, path], + [report, path], ); const notFound = (): VNode => <NotFoundPage />; @@ -213,7 +97,7 @@ export function InstanceRoutes(): VNode { <Settings /> </Route> <Route> - {entityRoute ? ( + {report ? ( <DefaultList onNotFound={notFound} onLoadError={loadError} /> ) : ( <NotFoundPage /> diff --git a/packages/taler-auditor-webui/src/components/menu/SideBar.tsx b/packages/taler-auditor-webui/src/components/menu/SideBar.tsx @@ -22,7 +22,7 @@ export function Sidebar({ mobile = false, onClose }: Props): VNode { { path: Paths.key_figures, label: <i18n.Translate>Key figures</i18n.Translate> }, { path: Paths.critical_errors, label: <i18n.Translate>Critical errors</i18n.Translate> }, { path: Paths.operating_status, label: <i18n.Translate>Operating status</i18n.Translate> }, - { path: Paths.detail_view, label: <i18n.Translate>Inconsistencies</i18n.Translate> }, + { path: Paths.detail_view, label: <i18n.Translate>All reports</i18n.Translate> }, { path: Paths.settings, label: <i18n.Translate>Settings</i18n.Translate> }, ]; diff --git a/packages/taler-auditor-webui/src/components/modal/index.tsx b/packages/taler-auditor-webui/src/components/modal/index.tsx @@ -39,7 +39,11 @@ export function ConfirmModal({ <div class="px-6 py-5">{children}</div> <footer class="flex justify-end gap-3 border-t border-gray-200 bg-gray-50 px-6 py-4"> <button class="auditor-button" onClick={onCancel}> - <i18n.Translate>{onConfirm ? "Cancel" : "Close"}</i18n.Translate> + {onConfirm ? ( + <i18n.Translate>Cancel</i18n.Translate> + ) : ( + <i18n.Translate>Close</i18n.Translate> + )} </button> {onConfirm && ( <button diff --git a/packages/taler-auditor-webui/src/context/entity.ts b/packages/taler-auditor-webui/src/context/entity.ts @@ -27,7 +27,9 @@ interface EntityType { title: string; path: string; endpoint: string; - entity: any; + description: string; + suppressible: boolean; + rowIdField: string; } const EntityContext = createContext<EntityType>({} as any); diff --git a/packages/taler-auditor-webui/src/hooks/critical.ts b/packages/taler-auditor-webui/src/hooks/critical.ts @@ -18,6 +18,7 @@ import { MonitoringSummary, RecordsResponse, } from "../declaration.js"; +import { reportsForDashboard } from "../routing/monitoringRoutes.js"; import { HttpResponse, HttpResponseOk, RequestError } from "../utils/http.js"; import { useBackendRequest } from "./backend.js"; @@ -40,15 +41,11 @@ export function useCriticalData( updateFilter?: (d: Date) => void, ): HttpResponse<any, AuditorBackend.ErrorDetail> { const { multiFetcher } = useBackendRequest(); - const endpoints = [ - { endpoint: "fee-time-inconsistencies", label: "Fee time inconsistencies" }, - { endpoint: "emergencies", label: "Emergencies" }, - { endpoint: "emergencies-by-count", label: "Emergencies by count" }, - { - endpoint: "reserve-balance-insufficient-inconsistencies", - label: "Insufficient reserve balances", - }, - ]; + const endpoints = reportsForDashboard("critical").map((report) => ({ + endpoint: report.endpoint, + label: report.title, + description: report.description, + })); const paths = endpoints.map(({ endpoint }) => `monitoring/${endpoint}`); const { data: list, error: listError } = useSWR< diff --git a/packages/taler-auditor-webui/src/hooks/entity.ts b/packages/taler-auditor-webui/src/hooks/entity.ts @@ -29,17 +29,15 @@ const useSWR = _useSWR as unknown as SWRHook; interface Props { endpoint: string; - entity: any; } export function useEntityList({ endpoint, - entity, }: Props): HttpResponse<any, AuditorBackend.ErrorDetail> { const { fetcher } = useBackendRequest(); const { data: list, error: listError } = useSWR< - HttpResponseOk<RecordsResponse<typeof entity>>, + HttpResponseOk<RecordsResponse<Record<string, unknown>>>, RequestError<AuditorBackend.ErrorDetail> >([`monitoring/` + endpoint], fetcher, { refreshInterval: 0, diff --git a/packages/taler-auditor-webui/src/hooks/finance.ts b/packages/taler-auditor-webui/src/hooks/finance.ts @@ -22,6 +22,7 @@ import { MonitoringSummary, RecordsResponse, } from "../declaration.js"; +import { reportsForDashboard } from "../routing/monitoringRoutes.js"; import { HttpResponse, HttpResponseOk, RequestError } from "../utils/http.js"; import { useBackendRequest } from "./backend.js"; @@ -37,29 +38,15 @@ export function useKeyFiguresData(): HttpResponse< const { multiFetcher } = useBackendRequest(); const endpoints = [ { - endpoint: "misattribution-in-inconsistencies", - label: "Misattribution in inconsistencies", - }, - { endpoint: "coin-inconsistencies", label: "Coin inconsistencies" }, - { - endpoint: "reserve-in-inconsistencies", - label: "Reserve in inconsistencies", - }, - { endpoint: "bad-sig-losses", label: "Bad signature losses" }, - { endpoint: "balances", label: "Balances" }, - { - endpoint: "amount-arithmetic-inconsistencies", - label: "Amount arithmetic inconsistencies", - }, - { - endpoint: "wire-format-inconsistencies", - label: "Wire format inconsistencies", - }, - { endpoint: "wire-out-inconsistencies", label: "Wire out inconsistencies" }, - { - endpoint: "reserve-balance-summary-wrong-inconsistencies", - label: "Wrong reserve balance summaries", + endpoint: "balances", + label: "Auditor balances", + description: "Auditor-wide accounting totals.", }, + ...reportsForDashboard("finance").map((report) => ({ + endpoint: report.endpoint, + label: report.title, + description: report.description, + })), ]; const paths = endpoints.map(({ endpoint }) => `monitoring/${endpoint}`); diff --git a/packages/taler-auditor-webui/src/hooks/operational.ts b/packages/taler-auditor-webui/src/hooks/operational.ts @@ -18,6 +18,7 @@ import { MonitoringSummary, RecordsResponse, } from "../declaration.js"; +import { reportsForDashboard } from "../routing/monitoringRoutes.js"; import { HttpResponse, HttpResponseOk, RequestError } from "../utils/http.js"; import { useBackendRequest } from "./backend.js"; @@ -40,39 +41,11 @@ export function useOperationData( updateFilter?: (d: Date) => void, ): HttpResponse<any, AuditorBackend.ErrorDetail> { const { multiFetcher } = useBackendRequest(); - const endpoints = [ - { endpoint: "row-inconsistencies", label: "Row inconsistencies" }, - { - endpoint: "purse-not-closed-inconsistencies", - label: "Purses not closed", - }, - { - endpoint: "reserve-not-closed-inconsistencies", - label: "Reserves not closed", - }, - { - endpoint: "denominations-without-sigs", - label: "Denominations without signatures", - }, - { endpoint: "deposit-confirmations", label: "Deposit confirmations" }, - { - endpoint: "denomination-key-validity-withdraw-inconsistencies", - label: "Invalid denomination keys at withdrawal", - }, - { endpoint: "closure-lags", label: "Closure lags" }, - { - endpoint: "row-minor-inconsistencies", - label: "Minor row inconsistencies", - }, - { - endpoint: "historic-denomination-revenues", - label: "Historic denomination revenues", - }, - { - endpoint: "historic-reserve-summaries", - label: "Historic reserve summaries", - }, - ]; + const endpoints = reportsForDashboard("operations").map((report) => ({ + endpoint: report.endpoint, + label: report.title, + description: report.description, + })); const paths = endpoints.map(({ endpoint }) => `monitoring/${endpoint}`); const { data: list, error: listError } = useSWR< diff --git a/packages/taler-auditor-webui/src/paths/default/Table.tsx b/packages/taler-auditor-webui/src/paths/default/Table.tsx @@ -27,7 +27,10 @@ function displayValue(value: unknown): string { export function CardTable({ onSuppress }: Props): VNode { const { data } = useEntityDataContext(); const { i18n } = useTranslationContext(); - const { title } = useEntityContext(); + const { rowIdField, suppressible, title } = useEntityContext(); + const hasActions = + suppressible && + data.some((row: Record<string, unknown>) => row[rowIdField] !== undefined); return ( <div class="auditor-card overflow-hidden"> @@ -35,7 +38,12 @@ export function CardTable({ onSuppress }: Props): VNode { <h2 class="text-lg font-bold"><i18n.Translate>{title}</i18n.Translate></h2> </header> {data.length > 0 ? ( - <Table data={data} onSuppress={onSuppress} /> + <Table + data={data} + hasActions={hasActions} + onSuppress={onSuppress} + rowIdField={rowIdField} + /> ) : ( <EmptyTable /> )} @@ -45,10 +53,14 @@ export function CardTable({ onSuppress }: Props): VNode { function Table({ data, + hasActions, onSuppress, + rowIdField, }: { data: Array<Record<string, unknown>>; + hasActions: boolean; onSuppress: (id: string) => void; + rowIdField: string; }): VNode { const { i18n } = useTranslationContext(); const columns = Object.keys(data[0] ?? {}); @@ -60,28 +72,30 @@ function Table({ {columns.map((column) => ( <th key={column}><i18n.Translate>{columnTitle(column)}</i18n.Translate></th> ))} - <th><span class="sr-only">Actions</span></th> + {hasActions && <th><span class="sr-only">Actions</span></th>} </tr> </thead> <tbody> {data.map((row, index) => ( - <tr key={String(row.row_id ?? index)}> + <tr key={String(row[rowIdField] ?? index)}> {columns.map((column) => ( <td key={column} class="max-w-md break-words font-mono text-xs"> {displayValue(row[column])} </td> ))} - <td class="sticky right-0 bg-inherit text-right"> - {typeof row.row_id !== "undefined" && ( - <button - class="auditor-button py-1 text-xs" - type="button" - onClick={() => onSuppress(String(row.row_id))} - > - <i18n.Translate>Suppress</i18n.Translate> - </button> - )} - </td> + {hasActions && ( + <td class="sticky right-0 bg-inherit text-right"> + {row[rowIdField] !== undefined && ( + <button + class="auditor-button py-1 text-xs" + type="button" + onClick={() => onSuppress(String(row[rowIdField]))} + > + <i18n.Translate>Suppress</i18n.Translate> + </button> + )} + </td> + )} </tr> ))} </tbody> diff --git a/packages/taler-auditor-webui/src/paths/default/index.tsx b/packages/taler-auditor-webui/src/paths/default/index.tsx @@ -44,8 +44,8 @@ interface Props { } export default function DefaultList({ onLoadError, onNotFound }: Props): VNode { - const { endpoint, entity } = useEntityContext(); - const result = useEntityList({ endpoint, entity }); + const { endpoint, description } = useEntityContext(); + const result = useEntityList({ endpoint }); const { updateEntity } = useEntityAPI(); const [suppressing, setSuppressing] = useState<string | null>(null); const [, setLocation] = useLocation(); @@ -68,9 +68,11 @@ export default function DefaultList({ onLoadError, onNotFound }: Props): VNode { return ( <section class="space-y-5 px-4 pb-8 pt-24 md:px-8"> <button class="auditor-button" onClick={() => setLocation(Paths.detail_view)}> - ← Back to inconsistencies + ← Back to all reports </button> + <p class="max-w-4xl text-sm leading-6 text-gray-600">{description}</p> + <NotificationCard notification={notif} /> <EntityDataContextProvider value={value}> diff --git a/packages/taler-auditor-webui/src/paths/details/ListPage.tsx b/packages/taler-auditor-webui/src/paths/details/ListPage.tsx @@ -5,49 +5,55 @@ import type { VNode } from "preact"; import { Link } from "wouter-preact"; -import { Paths } from "../../routing/monitoringRoutes.js"; +import { + monitoringReports, + monitoringSections, +} from "../../routing/monitoringRoutes.js"; export function ListPage(): VNode { - const entries = [ - [Paths.amount_arithmethic_inconsistency_list, "Amount arithmetic inconsistencies"], - [Paths.bad_sig_losses_list, "Bad signature losses"], - [Paths.balance_list, "Balances"], - [Paths.closure_lag_list, "Closure lags"], - [Paths.coin_inconsistency_list, "Coin inconsistencies"], - [Paths.denomination_key_validity_withdraw_inconsistency_list, "Denomination key validity at withdrawal"], - [Paths.denomination_without_sig_list, "Denominations without signatures"], - [Paths.deposit_confirmation_list, "Deposit confirmations"], - [Paths.emergency_list, "Emergencies"], - [Paths.emergency_by_count_list, "Emergencies by count"], - [Paths.fee_time_inconsistency_list, "Fee time inconsistencies"], - [Paths.historic_denomination_revenue_list, "Historic denomination revenues"], - [Paths.historic_reserve_summary_list, "Historic reserve summaries"], - [Paths.misattribution_in_inconsistency_list, "Misattribution-in inconsistencies"], - [Paths.purse_not_closed_inconsistency_list, "Purses not closed"], - [Paths.purse_list, "Purses"], - [Paths.reserve_balance_insufficient_inconsistency_list, "Insufficient reserve balances"], - [Paths.reserve_balance_summary_wrong_inconsistency_list, "Wrong reserve balance summaries"], - [Paths.reserve_in_inconsistency_list, "Reserve-in inconsistencies"], - [Paths.reserve_not_closed_inconsistency_list, "Reserves not closed"], - [Paths.reserves_list, "Reserves"], - [Paths.row_inconsistency_list, "Row inconsistencies"], - [Paths.row_minor_inconsistency_list, "Minor row inconsistencies"], - [Paths.wire_format_inconsistency_list, "Wire format inconsistencies"], - [Paths.wire_out_inconsistency_list, "Wire-out inconsistencies"], - ] as const; - return ( - <div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> - {entries.map(([path, label]) => ( - <Link - key={path} - href={path} - class="auditor-card flex min-h-20 items-center justify-between gap-4 p-5 font-semibold text-gray-800 transition hover:-translate-y-0.5 hover:border-blue-300 hover:shadow-md" - > - <span>{label}</span> - <span class="text-taler-brand" aria-hidden="true">→</span> - </Link> - ))} + <div class="space-y-10"> + <div class="max-w-4xl"> + <p class="text-base leading-7 text-gray-600"> + This is the complete set of data exposed by the auditor monitoring API. + Diagnostic reports show unsuppressed findings by default; state and history + reports show the auditor's current accounting data and processing position. + </p> + </div> + {monitoringSections.map((section) => { + const reports = monitoringReports.filter( + (report) => report.section === section.id, + ); + return ( + <section class="space-y-4" key={section.id}> + <header> + <h2 class="text-xl font-bold text-gray-900">{section.title}</h2> + <p class="mt-1 max-w-4xl text-sm leading-6 text-gray-600"> + {section.description} + </p> + </header> + <div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> + {reports.map((report) => ( + <Link + key={report.endpoint} + href={report.path} + class="auditor-card group flex min-h-36 flex-col justify-between gap-4 p-5 text-gray-800 transition hover:-translate-y-0.5 hover:border-blue-300 hover:shadow-md" + > + <span> + <span class="block font-semibold">{report.title}</span> + <span class="mt-2 block text-sm leading-5 text-gray-600"> + {report.description} + </span> + </span> + <span class="text-sm font-semibold text-taler-brand"> + View report <span aria-hidden="true">→</span> + </span> + </Link> + ))} + </div> + </section> + ); + })} </div> ); } diff --git a/packages/taler-auditor-webui/src/paths/finance/ListPage.test.ts b/packages/taler-auditor-webui/src/paths/finance/ListPage.test.ts @@ -0,0 +1,27 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + */ + +import assert from "node:assert"; +import test from "node:test"; +import { balanceGroupId, balanceName } from "./ListPage.js"; + +test("incoming wire fees are shown as a wire balance", () => { + assert.equal(balanceGroupId("total_wire_credit_fees"), "wire"); + assert.equal(balanceName("total_wire_credit_fees"), "Incoming wire transfer fees"); +}); + +test("the total reserve balance is shown as a reserve balance", () => { + assert.equal(balanceGroupId("reserves_reserve_total_balance"), "reserves"); + assert.equal(balanceGroupId("total_balance_summary_delta_plus"), "reserves"); + assert.equal( + balanceName("reserves_reserve_total_balance"), + "Total balance held in reserves", + ); +}); + +test("unknown future balances remain visible", () => { + assert.equal(balanceGroupId("future_balance_key"), "other"); + assert.equal(balanceName("future_balance_key"), "Future balance key"); +}); diff --git a/packages/taler-auditor-webui/src/paths/finance/ListPage.tsx b/packages/taler-auditor-webui/src/paths/finance/ListPage.tsx @@ -6,47 +6,112 @@ import type { VNode } from "preact"; import { DashboardTableRow } from "../../components/DashboardTableRow.js"; -const coinBalances = new Set([ - "Total recoup loss", - "Coin refund fee revenue", - "Coin deposit fee revenue", - "Coin melt fee revenue", - "Coin irregular loss", - "Coins reported emergency risk by amount", - "Coins emergencies loss by count", - "Coins emergencies loss", - "Coins total arithmetic delta minus", - "Coins total arithmetic delta plus", - "Total escrowed", - "Total refresh hanging", -]); +interface Balance { + balance_key: string; + balance_value: string; +} + +interface BalanceGroup { + id: string; + title: string; + description: string; + matches: (key: string) => boolean; +} -const reserveBalances = new Set([ - "Total balance summary delta minus", - "Total balance reserve not closed", - "Reserves total arithmetic delta minus", - "Reserves total arithmetic delta plus", - "Reserves total bad signature loss", - "Reserves history fee revenue", - "Reserves open fee revenue", -]); +const balanceGroups: readonly BalanceGroup[] = [ + { + id: "coins", + title: "Coins", + description: "Coin holdings, fees, losses and emergency exposure.", + matches: (key) => + key.startsWith("coin") || + ["total_escrowed", "total_recoup_loss", "total_refresh_hanging"].includes(key), + }, + { + id: "reserves", + title: "Reserves", + description: "Money held in reserves, reserve fees and reserve-related losses.", + matches: (key) => + key.startsWith("reserves_") || + key.startsWith("total_balance_reserve_") || + key.startsWith("total_balance_summary_"), + }, + { + id: "purses", + title: "Purses", + description: "Money held in purses and purse-related discrepancies.", + matches: (key) => key.startsWith("purse_"), + }, + { + id: "aggregation", + title: "Deposit aggregation", + description: "Merchant aggregation totals, fees and arithmetic differences.", + matches: (key) => key.startsWith("aggregation_"), + }, + { + id: "wire", + title: "Wire and settlement", + description: + "Incoming and outgoing bank transfers, transfer fees, delays and settlement differences.", + matches: (key) => + key.startsWith("total_") || key.startsWith("wire_debit_"), + }, +]; + +const balanceLabels: Readonly<Record<string, string>> = { + purse_global_balance: "Total balance held in purses", + reserves_reserve_total_balance: "Total balance held in reserves", + total_aml_hold: "Total amount in held outgoing transfers", + total_amount_lag: "Deposits not settled by the wire deadline", + total_bad_amount_in_minus: "Incoming transfers below exchange records", + total_bad_amount_in_plus: "Incoming transfers above exchange records", + total_bad_amount_out_minus: "Outgoing transfers below exchange records", + total_bad_amount_out_plus: "Outgoing transfers above exchange records", + total_balance_reserve_not_closed: "Total balance in expired reserves", + total_closure_amount_lag: "Overdue reserve closure transfers", + total_drained: "Profits drained from the exchange account", + total_early_aggregation: "Deposits aggregated before eligibility", + total_kycauth_in: "KYC authentication credits reported by the bank", + total_kycauth_revenue: "KYC authentication revenue booked by the exchange", + total_misattribution_in: "Incoming transfers credited to the wrong reserve", + total_missed_deposit_confirmations: "Deposits missing merchant confirmation", + total_small_aggregate: "Total held below the wire-fee threshold", + total_transfer_lag: "Aggregated transfers withheld without a reason", + total_wire_credit_fees: "Incoming wire transfer fees", + total_wire_in: "Total incoming wire transfers", + total_wire_out: "Total outgoing wire transfers", + wire_debit_duplicate_transfer_subject_total: + "Outgoing transfers with duplicate transfer subjects", +}; + +export function balanceGroupId(key: string): string { + return balanceGroups.find((group) => group.matches(key))?.id ?? "other"; +} -function balanceName(key: string): string { +export function balanceName(key: string): string { + const known = balanceLabels[key]; + if (known) return known; const words = key.replaceAll("_", " ").replaceAll("-", " "); - return words.charAt(0).toUpperCase() + words.slice(1); + return (words.charAt(0).toUpperCase() + words.slice(1)) + .replaceAll(" aml ", " AML ") + .replaceAll(" kycauth ", " KYC authentication ") + .replaceAll(" sig ", " signature "); } function BalanceTable({ - title, + group, balances, }: { - title: string; - balances: Array<{ balance_key: string; balance_value: string }>; + group: Pick<BalanceGroup, "title" | "description">; + balances: Balance[]; }): VNode { return ( - <div> - <h2 class="mb-3 text-sm font-bold uppercase tracking-wide text-gray-500">{title}</h2> - <div class="overflow-x-auto rounded-lg border border-gray-200"> + <div class="auditor-card overflow-hidden"> + <header class="border-b border-gray-200 px-5 py-4"> + <h3 class="font-bold text-gray-900">{group.title}</h3> + <p class="mt-1 text-sm text-gray-500">{group.description}</p> + </header> + <div class="overflow-x-auto"> <table class="auditor-table"> <thead><tr><th>Balance</th><th class="text-right">Value</th></tr></thead> <tbody> @@ -73,20 +138,41 @@ export function ListPage(data: any): VNode { const summaries = data.data.data[0] as Array<{ endpoint: string; label: string; + description: string; records: Array<Record<string, unknown>>; }>; - const balances = (summaries.find((item) => item.endpoint === "balances")?.records ?? []) as Array<{ - balance_key: string; - balance_value: string; - }>; + const balances = (summaries.find((item) => item.endpoint === "balances")?.records ?? []) as unknown as Balance[]; const findings = summaries.filter((item) => item.endpoint !== "balances"); + const groupedBalances = balanceGroups.map((group) => { + const matching = balances + .filter((balance) => balanceGroupId(balance.balance_key) === group.id) + .sort((a, b) => balanceName(a.balance_key).localeCompare(balanceName(b.balance_key))); + return { group, balances: matching }; + }); + const otherBalances = balances + .filter((balance) => balanceGroupId(balance.balance_key) === "other") + .sort((a, b) => balanceName(a.balance_key).localeCompare(balanceName(b.balance_key))); + + if (otherBalances.length > 0) { + groupedBalances.push({ + group: { + id: "other", + title: "Other balances", + description: "Additional accounting totals reported by the auditor.", + matches: () => false, + }, + balances: otherBalances, + }); + } return ( - <div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(28rem,1fr)]"> + <div class="space-y-8"> <div class="auditor-card overflow-hidden"> <div class="border-b border-gray-200 px-5 py-4"> - <h2 class="text-lg font-bold">Financial findings</h2> - <p class="text-sm text-gray-500">Current unsuppressed auditor diagnostics</p> + <h2 class="text-lg font-bold">Financial integrity checks</h2> + <p class="text-sm text-gray-500"> + Unsuppressed differences between exchange, auditor and bank records. + </p> </div> <div class="overflow-x-auto"> <table class="auditor-table"> @@ -100,7 +186,12 @@ export function ListPage(data: any): VNode { key={finding.endpoint} label={finding.label} > - <td>{finding.label}</td> + <td> + <span class="block font-medium text-gray-900">{finding.label}</span> + <span class="mt-1 block max-w-3xl text-xs leading-5 text-gray-500"> + {finding.description} + </span> + </td> <td class={`text-right font-bold ${count === 0 ? "text-taler-success" : "text-taler-error"}`}> {count} </td> @@ -111,16 +202,22 @@ export function ListPage(data: any): VNode { </table> </div> </div> - <div class="auditor-card space-y-7 p-5"> - <BalanceTable - title="Coin balances" - balances={balances.filter((balance) => coinBalances.has(balanceName(balance.balance_key)))} - /> - <BalanceTable - title="Reserve balances" - balances={balances.filter((balance) => reserveBalances.has(balanceName(balance.balance_key)))} - /> - </div> + <section class="space-y-4"> + <header> + <h2 class="text-xl font-bold text-gray-900">Auditor balances</h2> + <p class="mt-1 max-w-4xl text-sm leading-6 text-gray-600"> + All accounting totals reported by the auditor are shown below. New balance + keys appear automatically instead of being silently omitted. + </p> + </header> + <div class="grid items-start gap-5 xl:grid-cols-2"> + {groupedBalances + .filter(({ balances: groupBalances }) => groupBalances.length > 0) + .map(({ group, balances: groupBalances }) => ( + <BalanceTable group={group} balances={groupBalances} key={group.id} /> + ))} + </div> + </section> </div> ); } diff --git a/packages/taler-auditor-webui/src/paths/operations/ListPage.tsx b/packages/taler-auditor-webui/src/paths/operations/ListPage.tsx @@ -10,6 +10,7 @@ export function ListPage(data: any): VNode { const summaries = data.data.data[0] as Array<{ endpoint: string; label: string; + description: string; records: unknown[]; }>; return ( @@ -30,7 +31,12 @@ export function ListPage(data: any): VNode { key={finding.endpoint} label={finding.label} > - <td>{finding.label}</td> + <td> + <span class="block font-medium text-gray-900">{finding.label}</span> + <span class="mt-1 block max-w-3xl text-xs leading-5 text-gray-500"> + {finding.description} + </span> + </td> <td class={`text-right font-bold ${count === 0 ? "text-taler-success" : "text-taler-error"}`}> {count} </td> diff --git a/packages/taler-auditor-webui/src/paths/security/ListPage.tsx b/packages/taler-auditor-webui/src/paths/security/ListPage.tsx @@ -10,6 +10,7 @@ export function ListPage(data: any): VNode { const summaries = data.data.data[0] as Array<{ endpoint: string; label: string; + description: string; records: unknown[]; }>; return ( @@ -30,7 +31,12 @@ export function ListPage(data: any): VNode { key={finding.endpoint} label={finding.label} > - <td>{finding.label}</td> + <td> + <span class="block font-medium text-gray-900">{finding.label}</span> + <span class="mt-1 block max-w-3xl text-xs leading-5 text-gray-500"> + {finding.description} + </span> + </td> <td class={`text-right font-bold ${count === 0 ? "text-taler-success" : "text-taler-error"}`}> {count} </td> diff --git a/packages/taler-auditor-webui/src/routing/monitoringRoutes.test.ts b/packages/taler-auditor-webui/src/routing/monitoringRoutes.test.ts @@ -5,48 +5,96 @@ import assert from "node:assert"; import test from "node:test"; -import { pathForMonitoringEndpoint, Paths } from "./monitoringRoutes.js"; +import { + monitoringReports, + pathForMonitoringEndpoint, + reportForMonitoringEndpoint, + reportsForDashboard, +} from "./monitoringRoutes.js"; -test("dashboard monitoring endpoints have detail routes", () => { - const dashboardEndpoints = [ - "misattribution-in-inconsistencies", - "coin-inconsistencies", - "reserve-in-inconsistencies", - "bad-sig-losses", +const documentedMonitoringEndpoints = [ + "aml-holds", + "amount-arithmetic-inconsistencies", + "bad-sig-losses", + "balances", + "closure-lags", + "coin-inconsistencies", + "denomination-key-validity-withdraw-inconsistencies", + "denominations-without-sigs", + "deposit-confirmations", + "early-aggregations", + "emergencies", + "emergencies-by-count", + "fee-time-inconsistencies", + "historic-denomination-revenues", + "historic-reserve-summaries", + "kycauth-in-inconsistencies", + "misattribution-in-inconsistencies", + "pending-deposits", + "progress", + "purse-not-closed-inconsistencies", + "purses", + "reserve-balance-insufficient-inconsistencies", + "reserve-balance-summary-wrong-inconsistencies", + "reserve-in-inconsistencies", + "reserve-not-closed-inconsistencies", + "reserves", + "row-inconsistencies", + "row-minor-inconsistencies", + "wire-format-inconsistencies", + "wire-out-inconsistencies", +]; + +test("catalog covers every documented monitoring endpoint", () => { + assert.deepEqual( + monitoringReports.map((report) => report.endpoint).sort(), + documentedMonitoringEndpoints, + ); +}); + +test("monitoring reports have unique routes and explanatory copy", () => { + assert.equal(new Set(monitoringReports.map((report) => report.path)).size, 30); + for (const report of monitoringReports) { + assert.ok(report.title.length > 0, `missing title for ${report.endpoint}`); + assert.ok( + report.description.length > 30, + `missing useful description for ${report.endpoint}`, + ); + assert.equal(pathForMonitoringEndpoint(report.endpoint), report.path); + } +}); + +test("new protocol-v2 diagnostics appear on dashboards", () => { + const financial = reportsForDashboard("finance").map((report) => report.endpoint); + const operations = reportsForDashboard("operations").map((report) => report.endpoint); + assert.ok(financial.includes("kycauth-in-inconsistencies")); + assert.ok(operations.includes("aml-holds")); + assert.ok(operations.includes("early-aggregations")); + assert.ok(operations.includes("pending-deposits")); +}); + +test("read-only monitoring resources do not offer suppression", () => { + const readOnlyEndpoints = [ "balances", - "amount-arithmetic-inconsistencies", - "wire-format-inconsistencies", - "wire-out-inconsistencies", - "reserve-balance-summary-wrong-inconsistencies", - "fee-time-inconsistencies", - "emergencies", - "emergencies-by-count", - "reserve-balance-insufficient-inconsistencies", - "row-inconsistencies", - "purse-not-closed-inconsistencies", - "reserve-not-closed-inconsistencies", - "denominations-without-sigs", - "deposit-confirmations", - "denomination-key-validity-withdraw-inconsistencies", - "closure-lags", - "row-minor-inconsistencies", "historic-denomination-revenues", "historic-reserve-summaries", + "progress", + "purses", + "reserves", ]; - - for (const endpoint of dashboardEndpoints) { - assert.ok(pathForMonitoringEndpoint(endpoint), `missing route for ${endpoint}`); + for (const endpoint of readOnlyEndpoints) { + assert.equal(reportForMonitoringEndpoint(endpoint)?.suppressible, false); } + assert.equal( + reportForMonitoringEndpoint("deposit-confirmations")?.rowIdField, + "deposit_confirmation_serial_id", + ); }); -test("monitoring endpoints with non-matching paths resolve correctly", () => { - assert.equal(pathForMonitoringEndpoint("balances"), Paths.balance_list); +test("monitoring endpoints with legacy paths resolve correctly", () => { + assert.equal(pathForMonitoringEndpoint("balances"), "/balance"); assert.equal( pathForMonitoringEndpoint("denominations-without-sigs"), - Paths.denomination_without_sig_list, - ); - assert.equal( - pathForMonitoringEndpoint("historic-reserve-summaries"), - Paths.historic_reserve_summary_list, + "/denominations-without-sig", ); }); diff --git a/packages/taler-auditor-webui/src/routing/monitoringRoutes.ts b/packages/taler-auditor-webui/src/routing/monitoringRoutes.ts @@ -10,69 +10,359 @@ export enum Paths { critical_errors = "/critical-errors", operating_status = "/operating-status", detail_view = "/detail-view", - amount_arithmethic_inconsistency_list = "/amount-arithmetic-inconsistencies", - bad_sig_losses_list = "/bad-sig-losses", - balance_list = "/balance", - closure_lag_list = "/closure-lags", - coin_inconsistency_list = "/coin-inconsistencies", - denomination_key_validity_withdraw_inconsistency_list = "/denomination-key-validity-withdraw-inconsistencies", - denomination_without_sig_list = "/denominations-without-sig", - deposit_confirmation_list = "/deposit-confirmations", - deposit_confirmation_update = "/deposit-confirmation/:rowid/update", - emergency_list = "/emergencies", - emergency_by_count_list = "/emergencies-by-count", - exchange_signkey_list = "/exchange-sign-keys", - fee_time_inconsistency_list = "/fee-time-inconsistencies", - historic_denomination_revenue_list = "/historic-denomination-revenues", - historic_reserve_summary_list = "/historic-reserve-summaries", - misattribution_in_inconsistency_list = "/misattribution-in-inconsistencies", - progress_list = "/progress", - purse_not_closed_inconsistency_list = "/purse-not-closed-inconsistencies", - purse_list = "/purses", - reserve_balance_insufficient_inconsistency_list = "/reserve-balance-insufficient-inconsistencies", - reserve_balance_summary_wrong_inconsistency_list = "/reserve-balance-summary-wrong-inconsistencies", - reserve_in_inconsistency_list = "/reserve-in-inconsistencies", - reserve_not_closed_inconsistency_list = "/reserve-not-closed-inconsistencies", - reserves_list = "/reserves", - row_inconsistency_list = "/row-inconsistencies", - row_minor_inconsistency_list = "/row-minor-inconsistencies", - wire_format_inconsistency_list = "/wire-format-inconsistencies", - wire_out_inconsistency_list = "/wire-out-inconsistencies", } -const monitoringPathByEndpoint: Readonly<Record<string, Paths>> = { - "amount-arithmetic-inconsistencies": Paths.amount_arithmethic_inconsistency_list, - "bad-sig-losses": Paths.bad_sig_losses_list, - balances: Paths.balance_list, - "closure-lags": Paths.closure_lag_list, - "coin-inconsistencies": Paths.coin_inconsistency_list, - "denomination-key-validity-withdraw-inconsistencies": - Paths.denomination_key_validity_withdraw_inconsistency_list, - "denominations-without-sigs": Paths.denomination_without_sig_list, - "deposit-confirmations": Paths.deposit_confirmation_list, - emergencies: Paths.emergency_list, - "emergencies-by-count": Paths.emergency_by_count_list, - "exchange-sign-keys": Paths.exchange_signkey_list, - "fee-time-inconsistencies": Paths.fee_time_inconsistency_list, - "historic-denomination-revenues": Paths.historic_denomination_revenue_list, - "historic-reserve-summaries": Paths.historic_reserve_summary_list, - "misattribution-in-inconsistencies": Paths.misattribution_in_inconsistency_list, - progress: Paths.progress_list, - "purse-not-closed-inconsistencies": Paths.purse_not_closed_inconsistency_list, - purses: Paths.purse_list, - "reserve-balance-insufficient-inconsistencies": - Paths.reserve_balance_insufficient_inconsistency_list, - "reserve-balance-summary-wrong-inconsistencies": - Paths.reserve_balance_summary_wrong_inconsistency_list, - "reserve-in-inconsistencies": Paths.reserve_in_inconsistency_list, - "reserve-not-closed-inconsistencies": Paths.reserve_not_closed_inconsistency_list, - reserves: Paths.reserves_list, - "row-inconsistencies": Paths.row_inconsistency_list, - "row-minor-inconsistencies": Paths.row_minor_inconsistency_list, - "wire-format-inconsistencies": Paths.wire_format_inconsistency_list, - "wire-out-inconsistencies": Paths.wire_out_inconsistency_list, -}; +export const monitoringSections = [ + { + id: "financial-integrity", + title: "Financial integrity", + description: + "Differences between the exchange, auditor and bank records that can affect balances or settlement amounts.", + }, + { + id: "critical-risks", + title: "Critical risks", + description: + "Conditions that indicate possible losses, invalid authorizations or emergency over-issuance.", + }, + { + id: "operations", + title: "Operations and settlement", + description: + "Delayed, incomplete or structurally inconsistent processing that requires operational review.", + }, + { + id: "state-and-history", + title: "State and history", + description: + "Current balances and objects, auditor progress, deposit evidence and finalized revenue history.", + }, +] as const; -export function pathForMonitoringEndpoint(endpoint: string): Paths | undefined { - return monitoringPathByEndpoint[endpoint]; +export type MonitoringSectionId = (typeof monitoringSections)[number]["id"]; +export type DashboardId = "finance" | "critical" | "operations"; + +export interface MonitoringReport { + endpoint: string; + path: string; + title: string; + description: string; + section: MonitoringSectionId; + dashboard?: DashboardId; + suppressible: boolean; + rowIdField?: string; +} + +export const monitoringReports: readonly MonitoringReport[] = [ + { + endpoint: "amount-arithmetic-inconsistencies", + path: "/amount-arithmetic-inconsistencies", + title: "Amount arithmetic inconsistencies", + description: + "Operations for which the exchange and auditor calculated different amounts.", + section: "financial-integrity", + dashboard: "finance", + suppressible: true, + }, + { + endpoint: "coin-inconsistencies", + path: "/coin-inconsistencies", + title: "Coin inconsistencies", + description: + "Coin operations for which the exchange and auditor calculated different totals.", + section: "financial-integrity", + dashboard: "finance", + suppressible: true, + }, + { + endpoint: "kycauth-in-inconsistencies", + path: "/kycauth-in-inconsistencies", + title: "KYC authentication credit inconsistencies", + description: + "KYC authentication credits for which the bank and exchange disagree about the amount, account or existence of the transfer.", + section: "financial-integrity", + dashboard: "finance", + suppressible: true, + }, + { + endpoint: "misattribution-in-inconsistencies", + path: "/misattribution-in-inconsistencies", + title: "Misattributed incoming transfers", + description: + "Incoming bank transfers that were credited to the wrong reserve account.", + section: "financial-integrity", + dashboard: "finance", + suppressible: true, + }, + { + endpoint: "reserve-balance-summary-wrong-inconsistencies", + path: "/reserve-balance-summary-wrong-inconsistencies", + title: "Wrong reserve balance summaries", + description: + "Reserve summaries for which the exchange and auditor calculated different balances.", + section: "financial-integrity", + dashboard: "finance", + suppressible: true, + }, + { + endpoint: "reserve-in-inconsistencies", + path: "/reserve-in-inconsistencies", + title: "Incoming reserve transfer inconsistencies", + description: + "Incoming reserve transfers for which the bank and exchange records disagree.", + section: "financial-integrity", + dashboard: "finance", + suppressible: true, + }, + { + endpoint: "wire-format-inconsistencies", + path: "/wire-format-inconsistencies", + title: "Wire format inconsistencies", + description: + "Malformed or duplicate bank transfer data reported through the bank revenue API.", + section: "financial-integrity", + dashboard: "finance", + suppressible: true, + }, + { + endpoint: "wire-out-inconsistencies", + path: "/wire-out-inconsistencies", + title: "Outgoing wire inconsistencies", + description: + "Outgoing transfers whose destination or amount differs from what the auditor expected.", + section: "financial-integrity", + dashboard: "finance", + suppressible: true, + }, + { + endpoint: "bad-sig-losses", + path: "/bad-sig-losses", + title: "Invalid signature losses", + description: + "Losses from exchange operations that were performed despite an invalid signature.", + section: "critical-risks", + dashboard: "critical", + suppressible: true, + }, + { + endpoint: "emergencies", + path: "/emergencies", + title: "Denomination emergencies by amount", + description: + "Denominations whose redeemed value exceeds issuance, including realized loss and maximum risk.", + section: "critical-risks", + dashboard: "critical", + suppressible: true, + }, + { + endpoint: "emergencies-by-count", + path: "/emergencies-by-count", + title: "Denomination emergencies by coin count", + description: + "Denominations for which more coins were redeemed than the exchange officially issued.", + section: "critical-risks", + dashboard: "critical", + suppressible: true, + }, + { + endpoint: "reserve-balance-insufficient-inconsistencies", + path: "/reserve-balance-insufficient-inconsistencies", + title: "Insufficient reserve balances", + description: + "Reserve operations that exceeded the balance calculated by the auditor, showing the possible loss or gain.", + section: "critical-risks", + dashboard: "critical", + suppressible: true, + }, + { + endpoint: "aml-holds", + path: "/aml-holds", + title: "Held outgoing transfers", + description: + "Wire transfers the exchange aggregated but has not executed, including KYC and below-fee deferrals.", + section: "operations", + dashboard: "operations", + suppressible: true, + }, + { + endpoint: "closure-lags", + path: "/closure-lags", + title: "Reserve closure delays", + description: + "Expired reserves whose remaining funds were not transferred by the closure deadline.", + section: "operations", + dashboard: "operations", + suppressible: true, + }, + { + endpoint: "denomination-key-validity-withdraw-inconsistencies", + path: "/denomination-key-validity-withdraw-inconsistencies", + title: "Withdrawals with invalid denomination keys", + description: + "Withdrawals that used denomination keys outside their valid withdrawal period.", + section: "operations", + dashboard: "operations", + suppressible: true, + }, + { + endpoint: "denominations-without-sigs", + path: "/denominations-without-sig", + title: "Denominations without auditor signatures", + description: + "Exchange denomination keys that are missing the auditor's signature.", + section: "operations", + dashboard: "operations", + suppressible: true, + }, + { + endpoint: "early-aggregations", + path: "/early-aggregations", + title: "Early deposit aggregations", + description: + "Deposit batches the exchange aggregated before they were eligible, without a justification.", + section: "operations", + dashboard: "operations", + suppressible: true, + }, + { + endpoint: "fee-time-inconsistencies", + path: "/fee-time-inconsistencies", + title: "Wire fee time inconsistencies", + description: + "Wire fee schedules with inconsistent validity periods for a wire method.", + section: "operations", + dashboard: "operations", + suppressible: true, + }, + { + endpoint: "pending-deposits", + path: "/pending-deposits", + title: "Pending deposit transfers", + description: + "Deposits still awaiting settlement, including their amount, target account hash and wire deadline.", + section: "operations", + dashboard: "operations", + suppressible: true, + }, + { + endpoint: "purse-not-closed-inconsistencies", + path: "/purse-not-closed-inconsistencies", + title: "Expired purses not closed", + description: + "Expired purses whose remaining balance should have been refunded.", + section: "operations", + dashboard: "operations", + suppressible: true, + }, + { + endpoint: "reserve-not-closed-inconsistencies", + path: "/reserve-not-closed-inconsistencies", + title: "Expired reserves not closed", + description: "Expired reserves that still carry a balance.", + section: "operations", + dashboard: "operations", + suppressible: true, + }, + { + endpoint: "row-inconsistencies", + path: "/row-inconsistencies", + title: "Exchange row inconsistencies", + description: + "Exchange database rows with a serious structural or semantic problem identified by the auditor.", + section: "operations", + dashboard: "operations", + suppressible: true, + }, + { + endpoint: "row-minor-inconsistencies", + path: "/row-minor-inconsistencies", + title: "Minor exchange row inconsistencies", + description: + "Exchange database rows with lower-severity discrepancies that still warrant review.", + section: "operations", + dashboard: "operations", + suppressible: true, + }, + { + endpoint: "balances", + path: "/balance", + title: "Auditor balances", + description: + "Auditor-wide totals for holdings, revenue, fees, losses and arithmetic differences.", + section: "state-and-history", + suppressible: false, + }, + { + endpoint: "deposit-confirmations", + path: "/deposit-confirmations", + title: "Deposit confirmations", + description: + "Exchange-signed deposit confirmations submitted by merchants for independent verification.", + section: "state-and-history", + suppressible: true, + rowIdField: "deposit_confirmation_serial_id", + }, + { + endpoint: "historic-denomination-revenues", + path: "/historic-denomination-revenues", + title: "Historic denomination revenue", + description: + "Fee revenue and losses finalized when a denomination expired.", + section: "state-and-history", + suppressible: false, + }, + { + endpoint: "historic-reserve-summaries", + path: "/historic-reserve-summaries", + title: "Historic reserve summaries", + description: + "Reserve profits summarized over completed reporting periods.", + section: "state-and-history", + suppressible: false, + }, + { + endpoint: "progress", + path: "/progress", + title: "Auditor processing progress", + description: + "Offsets showing how much exchange data each auditor helper has processed.", + section: "state-and-history", + suppressible: false, + }, + { + endpoint: "purses", + path: "/purses", + title: "Current purses", + description: + "Current purse balances, target amounts and expiration dates recorded by the auditor.", + section: "state-and-history", + suppressible: false, + }, + { + endpoint: "reserves", + path: "/reserves", + title: "Current reserves", + description: + "Current reserve balances, fee revenue, losses, expiration dates and originating accounts.", + section: "state-and-history", + suppressible: false, + }, +]; + +export function reportForMonitoringEndpoint( + endpoint: string, +): MonitoringReport | undefined { + return monitoringReports.find((report) => report.endpoint === endpoint); +} + +export function reportForMonitoringPath(path: string): MonitoringReport | undefined { + return monitoringReports.find((report) => report.path === path); +} + +export function pathForMonitoringEndpoint(endpoint: string): string | undefined { + return reportForMonitoringEndpoint(endpoint)?.path; +} + +export function reportsForDashboard(dashboard: DashboardId): MonitoringReport[] { + return monitoringReports.filter((report) => report.dashboard === dashboard); }