taler-typescript-core

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

commit 08486ccc020e58a541e0ff50411c298b87429d8b
parent f25f1b39f12de27d8ea34fca90324cd3ec9f8be1
Author: Florian Dold <dold@taler.net>
Date:   Mon, 31 Aug 2026 19:14:34 +0200

auditor web UI: add current-issues overview

Diffstat:
Mpackages/taler-auditor-webui/src/InstanceRoutes.tsx | 7++++++-
Mpackages/taler-auditor-webui/src/components/DashboardTableRow.tsx | 19++++++++++++++++---
Apackages/taler-auditor-webui/src/components/TalerLogo.tsx | 46++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-auditor-webui/src/components/menu/NavigationBar.tsx | 16+++++++++-------
Mpackages/taler-auditor-webui/src/components/menu/SideBar.tsx | 23+++++++++++++++++------
Mpackages/taler-auditor-webui/src/declaration.d.ts | 1+
Apackages/taler-auditor-webui/src/hooks/overview.ts | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-auditor-webui/src/paths/finance/ListPage.tsx | 62++++++++++++++++++++++++++++++++++++++++++++++----------------
Mpackages/taler-auditor-webui/src/paths/login/index.tsx | 2+-
Mpackages/taler-auditor-webui/src/paths/operations/ListPage.tsx | 34+++++++++++++++++++++++++++++++---
Apackages/taler-auditor-webui/src/paths/overview/ListPage.tsx | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-auditor-webui/src/paths/overview/index.tsx | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-auditor-webui/src/paths/overview/model.test.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-auditor-webui/src/paths/overview/model.ts | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-auditor-webui/src/paths/security/ListPage.tsx | 14+++++++++++++-
Mpackages/taler-auditor-webui/src/routing/monitoringRoutes.test.ts | 5+++++
Mpackages/taler-auditor-webui/src/routing/monitoringRoutes.ts | 1+
Apackages/taler-auditor-webui/src/utils/issues.test.ts | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-auditor-webui/src/utils/issues.ts | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
19 files changed, 789 insertions(+), 38 deletions(-)

diff --git a/packages/taler-auditor-webui/src/InstanceRoutes.tsx b/packages/taler-auditor-webui/src/InstanceRoutes.tsx @@ -17,6 +17,7 @@ import DetailsDashboard from "./paths/details/index.js"; import FinanceDashboard from "./paths/finance/index.js"; import NotFoundPage from "./paths/notfound/index.js"; import OperationsDashboard from "./paths/operations/index.js"; +import OverviewDashboard from "./paths/overview/index.js"; import SecurityDashboard from "./paths/security/index.js"; import { Settings } from "./paths/settings/index.js"; import { @@ -27,6 +28,7 @@ import type { HttpError } from "./utils/http.js"; import type { AuditorBackend } from "./declaration.js"; const dashboardTitles: Partial<Record<Paths, string>> = { + [Paths.overview]: "Overview", [Paths.key_figures]: "Key figures", [Paths.critical_errors]: "Critical errors", [Paths.operating_status]: "Operating status", @@ -79,7 +81,10 @@ export function InstanceRoutes(): VNode { <main class="min-w-0 flex-1 md:ml-64"> <Switch> <Route path="/"> - <Redirect to={Paths.key_figures} replace /> + <Redirect to={Paths.overview} replace /> + </Route> + <Route path={Paths.overview}> + <OverviewDashboard onUnauthorized={notFound} onNotFound={notFound} /> </Route> <Route path={Paths.key_figures}> <FinanceDashboard onUnauthorized={notFound} onNotFound={notFound} onSelect={() => undefined} onCreate={() => undefined} /> diff --git a/packages/taler-auditor-webui/src/components/DashboardTableRow.tsx b/packages/taler-auditor-webui/src/components/DashboardTableRow.tsx @@ -6,14 +6,27 @@ import type { ComponentChildren, JSX, VNode } from "preact"; import { useLocation } from "wouter-preact"; import { pathForMonitoringEndpoint } from "../routing/monitoringRoutes.js"; +import type { IssueTone } from "../utils/issues.js"; interface Props { endpoint: string; label: string; children: ComponentChildren; + tone?: IssueTone; } -export function DashboardTableRow({ endpoint, label, children }: Props): VNode { +const toneClasses: Readonly<Record<IssueTone, string>> = { + neutral: "hover:bg-blue-50", + warning: "!bg-amber-50 hover:!bg-amber-100", + critical: "!bg-red-50 hover:!bg-red-100", +}; + +export function DashboardTableRow({ + endpoint, + label, + children, + tone = "neutral", +}: Props): VNode { const [, setLocation] = useLocation(); const path = pathForMonitoringEndpoint(endpoint); @@ -29,8 +42,8 @@ export function DashboardTableRow({ endpoint, label, children }: Props): VNode { return ( <tr - aria-label={`View ${label}`} - class="cursor-pointer transition hover:bg-blue-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-taler-brand" + aria-label={`${tone === "neutral" ? "" : `${tone === "critical" ? "Emergency" : "Warning"}: `}View ${label}`} + class={`cursor-pointer transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-taler-brand ${toneClasses[tone]}`} onClick={() => setLocation(path)} onKeyDown={onKeyDown} role="link" diff --git a/packages/taler-auditor-webui/src/components/TalerLogo.tsx b/packages/taler-auditor-webui/src/components/TalerLogo.tsx @@ -0,0 +1,46 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import type { VNode } from "preact"; + +interface Props { + class?: string; + color?: string; +} + +export function TalerLogo({ + class: className = "h-8 w-auto", + color = "#0042b3", +}: Props): VNode { + const { i18n } = useTranslationContext(); + return ( + <svg + aria-label={i18n.str`Taler Logo`} + class={className} + height="87.3" + role="img" + version="1.1" + viewBox="0 0 196.2 87.3" + width="196.2" + xmlns="http://www.w3.org/2000/svg" + > + <g + fill={color} + fill-rule="evenodd" + stroke-width="0.3" + transform="translate(-1,-1.1)" + > + <path d="m 86.7,1.1 c 15.6,0 29,9.4 36,23.2 h -5.9 A 35.1,35.1 0 0 0 86.7,6.5 C 67,6.5 51,23.6 51,44.7 c 0,10.4 3.8,19.7 10,26.6 a 31.4,31.4 0 0 1 -4.2,3 A 45.2,45.2 0 0 1 46,44.7 C 46,20.7 64.2,1.1 86.7,1.1 Z m 35.8,64.3 a 40.4,40.4 0 0 1 -39,22.8 c 3,-1.5 6,-3.5 8.6,-5.7 a 35.6,35.6 0 0 0 24.6,-17.1 z" /> + <path d="m 64.2,1.1 3.1,0.1 C 64.3,2.8 61.4,4.7 58.8,7 A 37.5,37.5 0 0 0 28.6,44.7 c 0,14.3 7.3,26.7 18,33.3 a 29.6,29.6 0 0 1 -8.5,0.2 c -9,-8 -14.6,-20 -14.6,-33.5 0,-24 18.2,-43.6 40.7,-43.6 z m 5.4,81.4 A 35.6,35.6 0 0 0 94.2,65.4 h 5.9 a 40.4,40.4 0 0 1 -39,22.8 c 3,-1.5 5.9,-3.5 8.5,-5.7 z M 94.4,24.3 A 37,37 0 0 0 81.8,11.5 29.6,29.6 0 0 1 90.3,11.3 c 4,3.6 7.4,8 9.9,13 z" /> + <path d="m 41.8,1.1 c 1,0 2,0 3.1,0.2 -3,1.5 -5.9,3.4 -8.5,5.6 A 37.5,37.5 0 0 0 6.1,44.7 c 0,21.1 16,38.3 35.7,38.3 12.6,0 23.6,-7 30,-17.6 h 5.8 a 40.4,40.4 0 0 1 -35.8,23 C 19.3,88.4 1,68.8 1,44.7 1,20.7 19.2,1.1 41.7,1.1 Z m 30.1,23.2 a 38.1,38.1 0 0 0 -4.5,-6.1 c 1.3,-1.2 2.7,-2.2 4.3,-3 2.3,2.7 4.4,5.8 6,9.1 z" /> + </g> + <path + fill={color} + d="m 75.1,33.3 h 9.2 v -5 H 60.9 v 5 H 70 v 26 h 5.1 z m 16.5,18.5 h 13.7 l 3,7.4 h 5.3 L 100.9,28 H 96.2 L 83.5,59.2 h 5.2 z m 11.8,-4.9 h -9.9 l 5,-12.4 z m 19.4,-18.6 h -4.6 v 31 h 20.6 v -5 h -16 z m 42.7,0 H 144 v 31 h 21.6 v -5 H 149 V 46 h 14.5 V 41.1 H 149 v -8 h 16.4 z m 24.7,10.1 c 0,1.6 -0.5,2.8 -1.6,3.8 -1.1,1 -2.6,1.4 -4.4,1.4 h -7.4 V 33.2 h 7.4 c 1.9,0 3.4,0.4 4.4,1.3 1,0.9 1.6,2.2 1.6,3.9 z m 6,20.8 -7.7,-11.7 c 1,-0.3 1.9,-0.7 2.7,-1.3 a 8.8,8.8 0 0 0 3.6,-4.6 c 0.4,-1 0.5,-2.2 0.5,-3.5 0,-1.5 -0.2,-2.9 -0.7,-4.1 a 8.4,8.4 0 0 0 -2.1,-3.1 c -1,-0.8 -2,-1.5 -3.4,-2 -1.3,-0.4 -2.8,-0.6 -4.5,-0.6 h -12.9 v 31 h 5 v -11 h 6.5 l 7,10.8 z" + /> + </svg> + ); +} diff --git a/packages/taler-auditor-webui/src/components/menu/NavigationBar.tsx b/packages/taler-auditor-webui/src/components/menu/NavigationBar.tsx @@ -36,13 +36,15 @@ export function NavigationBar({ <h1 class="min-w-0 flex-1 truncate text-xl font-bold text-gray-900 md:text-2xl"> {title} </h1> - <a - class="ml-4 shrink-0 rounded hover:opacity-80" - href="https://taler.net" - aria-label="GNU Taler website" - > - <img src={logo} class="h-9 w-auto" alt="GNU Taler" /> - </a> + {!withSidebar && ( + <a + class="ml-4 shrink-0 rounded hover:opacity-80" + href="https://taler.net" + aria-label="GNU Taler website" + > + <img src={logo} class="h-9 w-auto" alt="GNU Taler" /> + </a> + )} </header> ); } diff --git a/packages/taler-auditor-webui/src/components/menu/SideBar.tsx b/packages/taler-auditor-webui/src/components/menu/SideBar.tsx @@ -8,6 +8,7 @@ import type { VNode } from "preact"; import { Link, useLocation } from "wouter-preact"; import { useConfigContext } from "../../context/config.js"; import { Paths } from "../../routing/monitoringRoutes.js"; +import { TalerLogo } from "../TalerLogo.js"; interface Props { mobile?: boolean; @@ -19,6 +20,7 @@ export function Sidebar({ mobile = false, onClose }: Props): VNode { const { i18n } = useTranslationContext(); const [location] = useLocation(); const entries = [ + { path: Paths.overview, label: <i18n.Translate>Overview</i18n.Translate> }, { 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> }, @@ -28,12 +30,21 @@ export function Sidebar({ mobile = false, onClose }: Props): VNode { const content = ( <aside class="flex h-full w-64 flex-col border-r border-gray-800 bg-gray-900 p-4 text-gray-200"> - <div class="mb-8 flex items-start justify-between px-2"> - <div> - <div class="text-xs font-semibold uppercase tracking-widest text-gray-400">GNU Taler</div> - <div class="text-lg font-bold text-white">Auditor</div> - <div class="mt-1 text-xs text-gray-500">API version {config.version}</div> - </div> + <div class="mb-6 flex items-start justify-between px-2"> + <Link + href={Paths.overview} + onClick={onClose} + class="flex items-center gap-3 text-white transition hover:opacity-90" + > + <TalerLogo class="h-8 w-auto shrink-0" color="#ffffff" /> + <span> + <span class="block text-xs font-semibold text-gray-400">Taler</span> + <span class="block text-sm font-bold leading-tight text-white">Auditor</span> + <span class="mt-0.5 block text-xs text-gray-500"> + API version {config.version} + </span> + </span> + </Link> {mobile && ( <button type="button" diff --git a/packages/taler-auditor-webui/src/declaration.d.ts b/packages/taler-auditor-webui/src/declaration.d.ts @@ -31,6 +31,7 @@ export interface RecordsResponse<T> { export interface MonitoringSummary<T = Record<string, unknown>> { endpoint: string; label: string; + description: string; records: T[]; } diff --git a/packages/taler-auditor-webui/src/hooks/overview.ts b/packages/taler-auditor-webui/src/hooks/overview.ts @@ -0,0 +1,82 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + */ + +import type { + AuditorBackend, + MonitoringSummary, + RecordsResponse, +} from "../declaration.js"; +import { monitoringReports } from "../routing/monitoringRoutes.js"; +import type { HttpResponse, HttpResponseOk, RequestError } from "../utils/http.js"; +import { useBackendRequest } from "./backend.js"; +import _useSWR, { type SWRHook } from "swr"; + +const useSWR = _useSWR as unknown as SWRHook; + +export interface AuditorBalance { + balance_key: string; + balance_value: string; +} + +export interface OverviewData { + summaries: MonitoringSummary[]; + balances: AuditorBalance[]; +} + +export function useOverviewData(): HttpResponse< + OverviewData, + AuditorBackend.ErrorDetail +> { + const { multiFetcher } = useBackendRequest(); + const reports = monitoringReports.filter((report) => report.dashboard !== undefined); + const endpoints = [ + ...reports.map((report) => ({ + endpoint: report.endpoint, + label: report.title, + description: report.description, + })), + { + endpoint: "balances", + label: "Auditor balances", + description: "Auditor-wide accounting totals.", + }, + ]; + const paths = endpoints.map(({ endpoint }) => `monitoring/${endpoint}`); + + const { data: list, error: listError } = useSWR< + HttpResponseOk<RecordsResponse<Record<string, unknown>>>[], + RequestError<AuditorBackend.ErrorDetail> + >(paths, multiFetcher, { + refreshInterval: 60000, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (listError) return listError.cause; + if (!list) return { loading: true }; + + const summaries: MonitoringSummary[] = reports.map((report, index) => ({ + endpoint: report.endpoint, + label: report.title, + description: report.description, + records: list[index].data.records, + })); + const balanceRecords = list[reports.length]?.data.records ?? []; + const balances: AuditorBalance[] = balanceRecords.flatMap((record) => + typeof record.balance_key === "string" && + typeof record.balance_value === "string" + ? [ + { + balance_key: record.balance_key, + balance_value: record.balance_value, + }, + ] + : [], + ); + + return { ok: true, data: { summaries, balances } }; +} diff --git a/packages/taler-auditor-webui/src/paths/finance/ListPage.tsx b/packages/taler-auditor-webui/src/paths/finance/ListPage.tsx @@ -3,8 +3,13 @@ (C) 2021-2026 Taler Systems S.A. */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import type { VNode } from "preact"; import { DashboardTableRow } from "../../components/DashboardTableRow.js"; +import { + activeSummaries, + balanceIssueTone, +} from "../../utils/issues.js"; interface Balance { balance_key: string; @@ -120,6 +125,10 @@ function BalanceTable({ endpoint="balances" key={balance.balance_key} label={balanceName(balance.balance_key)} + tone={balanceIssueTone( + balance.balance_key, + balance.balance_value, + )} > <td>{balanceName(balance.balance_key)}</td> <td class="whitespace-nowrap text-right font-mono"> @@ -135,6 +144,7 @@ function BalanceTable({ } export function ListPage(data: any): VNode { + const { i18n } = useTranslationContext(); const summaries = data.data.data[0] as Array<{ endpoint: string; label: string; @@ -142,7 +152,9 @@ export function ListPage(data: any): VNode { records: Array<Record<string, unknown>>; }>; const balances = (summaries.find((item) => item.endpoint === "balances")?.records ?? []) as unknown as Balance[]; - const findings = summaries.filter((item) => item.endpoint !== "balances"); + const findings = activeSummaries( + summaries.filter((item) => item.endpoint !== "balances"), + ); const groupedBalances = balanceGroups.map((group) => { const matching = balances .filter((balance) => balanceGroupId(balance.balance_key) === group.id) @@ -171,16 +183,22 @@ export function ListPage(data: any): VNode { <div class="border-b border-gray-200 px-5 py-4"> <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. + <i18n.Translate> + Only active, unsuppressed differences between exchange, auditor and + bank records are shown. + </i18n.Translate> </p> </div> - <div class="overflow-x-auto"> - <table class="auditor-table"> - <thead><tr><th>Finding</th><th class="text-right">Count</th></tr></thead> - <tbody> - {findings.map((finding) => { - const count = finding.records.length; - return ( + {findings.length === 0 ? ( + <div class="px-5 py-8 text-sm font-semibold text-taler-success"> + <i18n.Translate>No financial integrity findings</i18n.Translate> + </div> + ) : ( + <div class="overflow-x-auto"> + <table class="auditor-table"> + <thead><tr><th>Finding</th><th class="text-right">Count</th></tr></thead> + <tbody> + {findings.map((finding) => ( <DashboardTableRow endpoint={finding.endpoint} key={finding.endpoint} @@ -192,15 +210,15 @@ export function ListPage(data: any): VNode { {finding.description} </span> </td> - <td class={`text-right font-bold ${count === 0 ? "text-taler-success" : "text-taler-error"}`}> - {count} + <td class="text-right font-bold text-taler-error"> + {finding.records.length} </td> </DashboardTableRow> - ); - })} - </tbody> - </table> - </div> + ))} + </tbody> + </table> + </div> + )} </div> <section class="space-y-4"> <header> @@ -209,6 +227,18 @@ export function ListPage(data: any): VNode { All accounting totals reported by the auditor are shown below. New balance keys appear automatically instead of being silently omitted. </p> + <p class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-gray-600"> + <span class="inline-flex items-center gap-2"> + <span class="h-3 w-3 rounded-sm border border-amber-200 bg-amber-50" /> + <i18n.Translate> + Yellow rows are expected to be zero during normal operation. + </i18n.Translate> + </span> + <span class="inline-flex items-center gap-2"> + <span class="h-3 w-3 rounded-sm border border-red-200 bg-red-50" /> + <i18n.Translate>Red rows indicate emergency conditions.</i18n.Translate> + </span> + </p> </header> <div class="grid items-start gap-5 xl:grid-cols-2"> {groupedBalances diff --git a/packages/taler-auditor-webui/src/paths/login/index.tsx b/packages/taler-auditor-webui/src/paths/login/index.tsx @@ -8,5 +8,5 @@ import { Redirect } from "wouter-preact"; import { Paths } from "../../routing/monitoringRoutes.js"; export function LoginPage(): VNode { - return <Redirect to={Paths.key_figures} replace />; + return <Redirect to={Paths.overview} replace />; } diff --git a/packages/taler-auditor-webui/src/paths/operations/ListPage.tsx b/packages/taler-auditor-webui/src/paths/operations/ListPage.tsx @@ -3,21 +3,33 @@ (C) 2021-2026 Taler Systems S.A. */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import type { VNode } from "preact"; import { DashboardTableRow } from "../../components/DashboardTableRow.js"; +import { + reportIssueTone, + unexpectedRecordCount, +} from "../../utils/issues.js"; export function ListPage(data: any): VNode { + const { i18n } = useTranslationContext(); const summaries = data.data.data[0] as Array<{ endpoint: string; label: string; description: string; - records: unknown[]; + records: Array<Record<string, unknown>>; }>; return ( <div class="auditor-card overflow-hidden"> <div class="border-b border-gray-200 px-5 py-4"> <h2 class="text-lg font-bold">Operational findings</h2> <p class="text-sm text-gray-500">Processing and data-quality diagnostics</p> + <p class="mt-3 inline-flex items-center gap-2 text-xs text-gray-600"> + <span class="h-3 w-3 rounded-sm border border-amber-200 bg-amber-50" /> + <i18n.Translate> + Yellow rows contain results that require attention. + </i18n.Translate> + </p> </div> <div class="overflow-x-auto"> <table class="auditor-table"> @@ -25,11 +37,17 @@ export function ListPage(data: any): VNode { <tbody> {summaries.map((finding) => { const count = finding.records.length; + const unexpectedCount = unexpectedRecordCount( + finding.endpoint, + finding.records, + ); + const tone = reportIssueTone(finding.endpoint, finding.records); return ( <DashboardTableRow endpoint={finding.endpoint} key={finding.endpoint} label={finding.label} + tone={tone} > <td> <span class="block font-medium text-gray-900">{finding.label}</span> @@ -37,8 +55,18 @@ export function ListPage(data: any): VNode { {finding.description} </span> </td> - <td class={`text-right font-bold ${count === 0 ? "text-taler-success" : "text-taler-error"}`}> - {count} + <td + class={`whitespace-nowrap text-right font-bold ${ + count === 0 + ? "text-taler-success" + : tone === "warning" + ? "text-amber-900" + : "text-gray-900" + }`} + > + {finding.endpoint === "aml-holds" && unexpectedCount > 0 + ? i18n.str`${count} total · ${unexpectedCount} unexplained` + : count} </td> </DashboardTableRow> ); diff --git a/packages/taler-auditor-webui/src/paths/overview/ListPage.tsx b/packages/taler-auditor-webui/src/paths/overview/ListPage.tsx @@ -0,0 +1,153 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import type { VNode } from "preact"; +import { DashboardTableRow } from "../../components/DashboardTableRow.js"; +import type { OverviewData } from "../../hooks/overview.js"; +import { balanceName } from "../finance/ListPage.js"; +import { overviewBalanceIssues, overviewReportIssues } from "./model.js"; + +export function ListPage({ data }: { data: OverviewData }): VNode { + const { i18n } = useTranslationContext(); + const reportIssues = overviewReportIssues(data.summaries); + const balanceIssues = overviewBalanceIssues(data.balances); + const hasIssues = reportIssues.length > 0 || balanceIssues.length > 0; + const hasEmergency = + reportIssues.some((issue) => issue.tone === "critical") || + balanceIssues.some((issue) => issue.tone === "critical"); + + if (!hasIssues) { + return ( + <div class="auditor-card border-green-200 bg-green-50 p-8 text-green-900"> + <h2 class="text-lg font-bold"> + <i18n.Translate>No current auditor issues</i18n.Translate> + </h2> + <p class="mt-1 text-sm text-green-800"> + <i18n.Translate> + All monitored diagnostics and expected-zero balances are clear. + </i18n.Translate> + </p> + </div> + ); + } + + return ( + <div class="space-y-6"> + <div + class={`rounded-xl border p-5 ${ + hasEmergency + ? "border-red-200 bg-red-50 text-red-950" + : "border-amber-200 bg-amber-50 text-amber-950" + }`} + role="status" + > + <h2 class="text-lg font-bold"> + <i18n.Translate>Current auditor issues</i18n.Translate> + </h2> + <p class="mt-1 text-sm"> + {i18n.str`${reportIssues.length} active reports · ${balanceIssues.length} unexpected balance totals`} + </p> + <p class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs"> + <span class="inline-flex items-center gap-2"> + <span class="h-3 w-3 rounded-sm border border-amber-200 bg-amber-50" /> + <i18n.Translate>Yellow entries require attention.</i18n.Translate> + </span> + <span class="inline-flex items-center gap-2"> + <span class="h-3 w-3 rounded-sm border border-red-200 bg-red-50" /> + <i18n.Translate>Red entries indicate emergency conditions.</i18n.Translate> + </span> + </p> + </div> + + {reportIssues.length > 0 && ( + <section class="auditor-card overflow-hidden"> + <header class="border-b border-gray-200 px-5 py-4"> + <h2 class="text-lg font-bold"> + <i18n.Translate>Active reports</i18n.Translate> + </h2> + <p class="text-sm text-gray-500"> + <i18n.Translate> + Unsuppressed findings that currently require review. + </i18n.Translate> + </p> + </header> + <div class="overflow-x-auto"> + <table class="auditor-table"> + <thead> + <tr> + <th><i18n.Translate>Finding</i18n.Translate></th> + <th class="text-right"><i18n.Translate>Count</i18n.Translate></th> + </tr> + </thead> + <tbody> + {reportIssues.map((issue) => ( + <DashboardTableRow + endpoint={issue.endpoint} + key={issue.endpoint} + label={issue.label} + tone={issue.tone} + > + <td> + <span class="block font-medium text-gray-900">{issue.label}</span> + <span class="mt-1 block max-w-3xl text-xs leading-5 text-gray-600"> + {issue.description} + </span> + </td> + <td class="whitespace-nowrap text-right font-bold"> + {issue.endpoint === "aml-holds" + ? i18n.str`${issue.unexpectedCount} unexplained of ${issue.records.length} holds` + : issue.records.length} + </td> + </DashboardTableRow> + ))} + </tbody> + </table> + </div> + </section> + )} + + {balanceIssues.length > 0 && ( + <section class="auditor-card overflow-hidden"> + <header class="border-b border-gray-200 px-5 py-4"> + <h2 class="text-lg font-bold"> + <i18n.Translate>Unexpected balance totals</i18n.Translate> + </h2> + <p class="text-sm text-gray-500"> + <i18n.Translate> + Accounting totals that are expected to be zero during normal operation. + </i18n.Translate> + </p> + </header> + <div class="overflow-x-auto"> + <table class="auditor-table"> + <thead> + <tr> + <th><i18n.Translate>Balance</i18n.Translate></th> + <th class="text-right"><i18n.Translate>Value</i18n.Translate></th> + </tr> + </thead> + <tbody> + {balanceIssues.map((issue) => ( + <DashboardTableRow + endpoint="balances" + key={issue.balance_key} + label={balanceName(issue.balance_key)} + tone={issue.tone} + > + <td>{balanceName(issue.balance_key)}</td> + <td class="whitespace-nowrap text-right font-mono font-semibold"> + {issue.balance_value.replace(":", " ")} + </td> + </DashboardTableRow> + ))} + </tbody> + </table> + </div> + </section> + )} + </div> + ); +} diff --git a/packages/taler-auditor-webui/src/paths/overview/index.tsx b/packages/taler-auditor-webui/src/paths/overview/index.tsx @@ -0,0 +1,57 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + */ + +import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import type { VNode } from "preact"; +import { Loading } from "../../components/exception/loading.js"; +import { NotificationCard } from "../../components/menu/index.js"; +import { useOverviewData } from "../../hooks/overview.js"; +import { ErrorType } from "../../utils/http.js"; +import { ListPage } from "./ListPage.js"; + +interface Props { + onUnauthorized: () => VNode; + onNotFound: () => VNode; +} + +export default function OverviewDashboard({ + onUnauthorized, + onNotFound, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const result = useOverviewData(); + + if (result.loading) return <Loading />; + if (!result.ok) { + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.Unauthorized + ) { + return onUnauthorized(); + } + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.NotFound + ) { + return onNotFound(); + } + return ( + <NotificationCard + notification={{ + type: "ERROR", + message: i18n.str`The auditor overview could not be loaded`, + description: result.message, + }} + /> + ); + } + + return ( + <section class="px-4 pb-8 pt-24 md:px-8"> + <ListPage data={result.data} /> + </section> + ); +} diff --git a/packages/taler-auditor-webui/src/paths/overview/model.test.ts b/packages/taler-auditor-webui/src/paths/overview/model.test.ts @@ -0,0 +1,60 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + */ + +import assert from "node:assert"; +import test from "node:test"; +import { overviewBalanceIssues, overviewReportIssues } from "./model.js"; + +test("overview keeps active reports and sorts emergencies first", () => { + const issues = overviewReportIssues([ + { endpoint: "row-inconsistencies", label: "Rows", description: "Rows", records: [{}] }, + { endpoint: "emergencies", label: "Emergencies", description: "Emergency", records: [{}] }, + { endpoint: "coin-inconsistencies", label: "Coins", description: "Coins", records: [] }, + ]); + assert.deepEqual(issues.map((issue) => issue.endpoint), [ + "emergencies", + "row-inconsistencies", + ]); +}); + +test("overview excludes legitimate AML holds and counts unexplained holds", () => { + const issues = overviewReportIssues([ + { + endpoint: "aml-holds", + label: "Holds", + description: "Holds", + records: [ + { deferral_reason: "KYC" }, + { deferral_reason: "AMOUNT_TOO_SMALL" }, + ], + }, + ]); + assert.deepEqual(issues, []); + + const unexpected = overviewReportIssues([ + { + endpoint: "aml-holds", + label: "Holds", + description: "Holds", + records: [ + { deferral_reason: "KYC" }, + { deferral_reason: "NONE" }, + ], + }, + ]); + assert.equal(unexpected[0].unexpectedCount, 1); +}); + +test("overview includes only unexpected balances and sorts emergencies first", () => { + const issues = overviewBalanceIssues([ + { balance_key: "total_amount_lag", balance_value: "TESTKUDOS:2" }, + { balance_key: "coins_emergencies_loss", balance_value: "TESTKUDOS:1" }, + { balance_key: "reserves_reserve_total_balance", balance_value: "TESTKUDOS:20" }, + ]); + assert.deepEqual(issues.map((issue) => issue.balance_key), [ + "coins_emergencies_loss", + "total_amount_lag", + ]); +}); diff --git a/packages/taler-auditor-webui/src/paths/overview/model.ts b/packages/taler-auditor-webui/src/paths/overview/model.ts @@ -0,0 +1,59 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + */ + +import type { MonitoringSummary } from "../../declaration.js"; +import type { AuditorBalance } from "../../hooks/overview.js"; +import { + balanceIssueTone, + reportIssueTone, + unexpectedRecordCount, + type IssueTone, +} from "../../utils/issues.js"; + +export interface OverviewReportIssue extends MonitoringSummary { + tone: Exclude<IssueTone, "neutral">; + unexpectedCount: number; +} + +export interface OverviewBalanceIssue extends AuditorBalance { + tone: Exclude<IssueTone, "neutral">; +} + +function bySeverity<T extends { tone: Exclude<IssueTone, "neutral"> }>( + issues: readonly T[], +): T[] { + return [ + ...issues.filter((issue) => issue.tone === "critical"), + ...issues.filter((issue) => issue.tone === "warning"), + ]; +} + +export function overviewReportIssues( + summaries: readonly MonitoringSummary[], +): OverviewReportIssue[] { + const issues = summaries.flatMap((summary) => { + const tone = reportIssueTone(summary.endpoint, summary.records); + if (tone === "neutral") return []; + return [ + { + ...summary, + tone, + unexpectedCount: unexpectedRecordCount(summary.endpoint, summary.records), + }, + ]; + }); + return bySeverity(issues); +} + +export function overviewBalanceIssues( + balances: readonly AuditorBalance[], +): OverviewBalanceIssue[] { + const issues = balances.flatMap((balance) => { + const tone = balanceIssueTone(balance.balance_key, balance.balance_value); + if (tone === "neutral") return []; + return [{ ...balance, tone }]; + }); + return bySeverity(issues); +} diff --git a/packages/taler-auditor-webui/src/paths/security/ListPage.tsx b/packages/taler-auditor-webui/src/paths/security/ListPage.tsx @@ -3,21 +3,28 @@ (C) 2021-2026 Taler Systems S.A. */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import type { VNode } from "preact"; import { DashboardTableRow } from "../../components/DashboardTableRow.js"; +import { reportIssueTone } from "../../utils/issues.js"; export function ListPage(data: any): VNode { + const { i18n } = useTranslationContext(); const summaries = data.data.data[0] as Array<{ endpoint: string; label: string; description: string; - records: unknown[]; + records: Array<Record<string, unknown>>; }>; return ( <div class="auditor-card overflow-hidden"> <div class="border-b border-gray-200 px-5 py-4"> <h2 class="text-lg font-bold">Critical findings</h2> <p class="text-sm text-gray-500">Security-sensitive conditions requiring attention</p> + <p class="mt-3 inline-flex items-center gap-2 text-xs text-gray-600"> + <span class="h-3 w-3 rounded-sm border border-red-200 bg-red-50" /> + <i18n.Translate>Red rows indicate emergency conditions.</i18n.Translate> + </p> </div> <div class="overflow-x-auto"> <table class="auditor-table"> @@ -25,11 +32,16 @@ export function ListPage(data: any): VNode { <tbody> {summaries.map((finding) => { const count = finding.records.length; + const issueTone = reportIssueTone( + finding.endpoint, + finding.records, + ); return ( <DashboardTableRow endpoint={finding.endpoint} key={finding.endpoint} label={finding.label} + tone={issueTone === "critical" ? "critical" : "neutral"} > <td> <span class="block font-medium text-gray-900">{finding.label}</span> diff --git a/packages/taler-auditor-webui/src/routing/monitoringRoutes.test.ts b/packages/taler-auditor-webui/src/routing/monitoringRoutes.test.ts @@ -6,12 +6,17 @@ import assert from "node:assert"; import test from "node:test"; import { + Paths, monitoringReports, pathForMonitoringEndpoint, reportForMonitoringEndpoint, reportsForDashboard, } from "./monitoringRoutes.js"; +test("overview is the dedicated dashboard entry route", () => { + assert.equal(Paths.overview, "/overview"); +}); + const documentedMonitoringEndpoints = [ "aml-holds", "amount-arithmetic-inconsistencies", diff --git a/packages/taler-auditor-webui/src/routing/monitoringRoutes.ts b/packages/taler-auditor-webui/src/routing/monitoringRoutes.ts @@ -6,6 +6,7 @@ export enum Paths { error = "/error", settings = "/settings", + overview = "/overview", key_figures = "/key-figures", critical_errors = "/critical-errors", operating_status = "/operating-status", diff --git a/packages/taler-auditor-webui/src/utils/issues.test.ts b/packages/taler-auditor-webui/src/utils/issues.test.ts @@ -0,0 +1,91 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + */ + +import assert from "node:assert"; +import test from "node:test"; +import { + activeSummaries, + balanceIssueTone, + reportIssueTone, + unexpectedRecordCount, +} from "./issues.js"; + +test("active summaries omit zero-count findings", () => { + const summaries = [ + { endpoint: "empty", records: [] }, + { endpoint: "active", records: [{}] }, + ]; + assert.deepEqual(activeSummaries(summaries), [summaries[1]]); +}); + +test("diagnostic reports are warnings when they contain records", () => { + assert.equal(reportIssueTone("row-inconsistencies", []), "neutral"); + assert.equal(reportIssueTone("row-inconsistencies", [{}]), "warning"); +}); + +test("emergency reports use the critical tone", () => { + assert.equal(reportIssueTone("emergencies", [{}]), "critical"); + assert.equal(reportIssueTone("emergencies-by-count", [{}]), "critical"); +}); + +test("only unexplained AML holds are unexpected", () => { + const legitimate = [ + { deferral_reason: "KYC" }, + { deferral_reason: "AMOUNT_TOO_SMALL" }, + ]; + assert.equal(unexpectedRecordCount("aml-holds", legitimate), 0); + assert.equal(reportIssueTone("aml-holds", legitimate), "neutral"); + + const withUnexplained = [...legitimate, { deferral_reason: "NONE" }]; + assert.equal(unexpectedRecordCount("aml-holds", withUnexplained), 1); + assert.equal(reportIssueTone("aml-holds", withUnexplained), "warning"); +}); + +test("expected-zero balances become warnings only when non-zero", () => { + assert.equal( + balanceIssueTone("aggregation_total_arithmetic_delta_plus", "TESTKUDOS:0"), + "neutral", + ); + assert.equal( + balanceIssueTone("aggregation_total_arithmetic_delta_plus", "TESTKUDOS:0.1"), + "warning", + ); + assert.equal(balanceIssueTone("total_amount_lag", "TESTKUDOS:2"), "warning"); +}); + +test("emergency balances use the critical tone", () => { + assert.equal( + balanceIssueTone("coins_emergencies_loss", "TESTKUDOS:1"), + "critical", + ); + assert.equal( + balanceIssueTone("coins_reported_emergency_risk_by_count", "TESTKUDOS:0"), + "neutral", + ); +}); + +test("normal and legitimate exceptional balances stay neutral", () => { + const neutralKeys = [ + "reserves_reserve_total_balance", + "total_wire_credit_fees", + "total_aml_hold", + "total_small_aggregate", + "total_recoup_loss", + "coin_deposit_fee_loss", + "total_missed_deposit_confirmations", + "total_refresh_hanging", + "future_balance_key", + ]; + for (const key of neutralKeys) { + assert.equal(balanceIssueTone(key, "TESTKUDOS:5"), "neutral", key); + } +}); + +test("malformed expected-zero balances stay neutral", () => { + assert.equal( + balanceIssueTone("coins_total_arithmetic_delta_minus", "not-an-amount"), + "neutral", + ); +}); diff --git a/packages/taler-auditor-webui/src/utils/issues.ts b/packages/taler-auditor-webui/src/utils/issues.ts @@ -0,0 +1,95 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + */ + +import { Amounts } from "@gnu-taler/taler-util"; + +export type IssueTone = "neutral" | "warning" | "critical"; + +export const emergencyReportEndpoints = new Set([ + "emergencies", + "emergencies-by-count", +]); + +export const emergencyBalanceKeys = new Set([ + "coins_reported_emergency_risk_by_amount", + "coins_reported_emergency_risk_by_count", + "coins_emergencies_loss", + "coins_emergencies_loss_by_count", +]); + +const expectedZeroBalanceKeys = new Set([ + "aggregation_total_arithmetic_delta_minus", + "aggregation_total_arithmetic_delta_plus", + "aggregation_total_bad_sig_loss", + "aggregation_total_coin_delta_minus", + "aggregation_total_coin_delta_plus", + "aggregation_total_wire_out_delta_minus", + "aggregation_total_wire_out_delta_plus", + "coin_irregular_loss", + "coins_emergencies_loss", + "coins_emergencies_loss_by_count", + "coins_reported_emergency_risk_by_amount", + "coins_reported_emergency_risk_by_count", + "coins_total_arithmetic_delta_minus", + "coins_total_arithmetic_delta_plus", + "purse_total_arithmetic_delta_minus", + "purse_total_arithmetic_delta_plus", + "purse_total_bad_sig_loss", + "purse_total_balance_insufficient_loss", + "purse_total_balance_purse_not_closed", + "purse_total_delayed_decisions", + "reserves_reserve_loss", + "reserves_total_arithmetic_delta_minus", + "reserves_total_arithmetic_delta_plus", + "reserves_total_bad_sig_loss", + "total_amount_lag", + "total_bad_amount_in_minus", + "total_bad_amount_in_plus", + "total_bad_amount_out_minus", + "total_bad_amount_out_plus", + "total_balance_reserve_not_closed", + "total_balance_summary_delta_minus", + "total_balance_summary_delta_plus", + "total_closure_amount_lag", + "total_early_aggregation", + "total_misattribution_in", + "total_transfer_lag", + "wire_debit_duplicate_transfer_subject_total", +]); + +export function unexplainedAmlHoldCount( + records: readonly Record<string, unknown>[], +): number { + return records.filter((record) => record.deferral_reason === "NONE").length; +} + +export function unexpectedRecordCount( + endpoint: string, + records: readonly Record<string, unknown>[], +): number { + if (endpoint === "aml-holds") return unexplainedAmlHoldCount(records); + return records.length; +} + +export function reportIssueTone( + endpoint: string, + records: readonly Record<string, unknown>[], +): IssueTone { + if (unexpectedRecordCount(endpoint, records) === 0) return "neutral"; + return emergencyReportEndpoints.has(endpoint) ? "critical" : "warning"; +} + +export function balanceIssueTone(key: string, value: string): IssueTone { + if (!expectedZeroBalanceKeys.has(key)) return "neutral"; + const amount = Amounts.parse(value); + if (!amount || Amounts.isZero(amount)) return "neutral"; + return emergencyBalanceKeys.has(key) ? "critical" : "warning"; +} + +export function activeSummaries<T extends { records: readonly unknown[] }>( + summaries: readonly T[], +): T[] { + return summaries.filter((summary) => summary.records.length > 0); +}