commit 21fcb03521e5288403c4822dd1f297efb864ad02
parent e797e4e30f4a47175f17472b8f7ef3a29c8c97a4
Author: Florian Dold <dold@taler.net>
Date: Tue, 1 Sep 2026 19:47:22 +0200
wallet web UI: save JSON or YAML diagnostics
Diffstat:
6 files changed, 175 insertions(+), 29 deletions(-)
diff --git a/packages/wallet-webui/build.mjs b/packages/wallet-webui/build.mjs
@@ -24,6 +24,10 @@ const packageDirectory = fileURLToPath(new URL("./", import.meta.url));
const packageMetadata = JSON.parse(
await readFile(new URL("./package.json", import.meta.url), "utf8"),
);
+const defineBuildConstants = (extra = {}) => ({
+ __WALLET_WEBUI_VERSION__: JSON.stringify(packageMetadata.version),
+ ...extra,
+});
const dist = new URL("./dist/", import.meta.url);
const fontNames = [
"roboto-normal-400.ttf",
@@ -119,13 +123,13 @@ if (sqliteEnabled) {
}
await build({
...shared,
- define: {
+ define: defineBuildConstants({
__SQLITE_ENABLED__: JSON.stringify(sqliteEnabled),
__SQLITE_WASM_URL__: JSON.stringify(
sqliteEnabled ? `./sqlite3.wasm?v=${sqliteWasmVersion}` : "",
),
__DEMO_BUILD__: "false",
- },
+ }),
entryPoints: { "wallet-worker": "src/workers/wallet-worker.ts" },
outdir: pwa.pathname,
});
@@ -149,13 +153,13 @@ if (pwaSqliteTest) {
const workerVersion = await contentVersion(pwa, ["wallet-worker.js"]);
await build({
...shared,
- define: {
+ define: defineBuildConstants({
__SQLITE_ENABLED__: JSON.stringify(sqliteEnabled),
__DEMO_BUILD__: "false",
__WALLET_WORKER_URL__: JSON.stringify(
`./wallet-worker.js?v=${workerVersion}`,
),
- },
+ }),
entryPoints: { app: "src/pwa-entry.tsx" },
outdir: pwa.pathname,
});
@@ -235,10 +239,10 @@ await build({
"wallet-webui-sqlite-runtime": "./src/platform/sqlite-runtime-disabled.ts",
"wallet-webui-storage-faults": "./src/testing/storage-faults-disabled.ts",
},
- define: {
+ define: defineBuildConstants({
__SQLITE_ENABLED__: "false",
__DEMO_BUILD__: "true",
- },
+ }),
entryPoints: { app: "src/demo-entry.tsx" },
outdir: demo.pathname,
});
@@ -270,10 +274,10 @@ await html(demo, "index.html", "GNU Taler Wallet Demo", {
await build({
...shared,
- define: {
+ define: defineBuildConstants({
__STATIC_CACHE_VERSION__: JSON.stringify(pwaVersion),
__STATIC_ASSETS__: JSON.stringify(versionedPwaAssets),
- },
+ }),
entryPoints: { "service-worker": "src/platform/service-worker.ts" },
outdir: pwa.pathname,
});
@@ -283,11 +287,11 @@ for (const target of ["chrome", "firefox"]) {
await mkdir(directory, { recursive: true });
await build({
...shared,
- define: {
+ define: defineBuildConstants({
__SQLITE_ENABLED__: "false",
__DEMO_BUILD__: "false",
__WALLET_TARGET__: JSON.stringify(target),
- },
+ }),
entryPoints: {
app: "src/extension-entry.tsx",
popup: "src/popup-entry.tsx",
diff --git a/packages/wallet-webui/src/custom.d.ts b/packages/wallet-webui/src/custom.d.ts
@@ -3,6 +3,7 @@ declare const __WALLET_WORKER_URL__: string;
declare const __SQLITE_ENABLED__: boolean;
declare const __SQLITE_WASM_URL__: string;
declare const __DEMO_BUILD__: boolean;
+declare const __WALLET_WEBUI_VERSION__: string;
declare const __STATIC_CACHE_VERSION__: string;
declare const __STATIC_ASSETS__: string[];
declare module "*.css";
diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx
@@ -68,7 +68,10 @@ import {
import { ShoppingScreen } from "../screens/ShoppingScreen.js";
import { RecoveryScreen } from "../screens/RecoveryScreen.js";
import { QrScannerScreen } from "../screens/QrScannerScreen.js";
-import { SettingsScreen } from "../screens/SettingsScreen.js";
+import {
+ SettingsScreen,
+ type DiagnosticsExportOptions,
+} from "../screens/SettingsScreen.js";
import {
ImportRecoveryScreen,
type ImportStage,
@@ -220,6 +223,10 @@ const decodeQr = jsQR as unknown as (
) => jsQrTypes.QRCode | null;
const demoCompiled = typeof __DEMO_BUILD__ !== "undefined" && __DEMO_BUILD__;
+const walletWebUiVersion =
+ typeof __WALLET_WEBUI_VERSION__ !== "undefined"
+ ? __WALLET_WEBUI_VERSION__
+ : "unknown";
export function walletRoutePath(location: string): string {
return location.split("?", 1)[0] || "/";
@@ -4875,22 +4882,31 @@ function SettingsRoute() {
setBusy(false);
}
};
- const exportDiagnostics = async () => {
+ const exportDiagnostics = async (options: DiagnosticsExportOptions) => {
setBusy(true);
setMessage(undefined);
setSettingsError(undefined);
try {
const diagnostics = await connection.client.call(
WalletApiOperation.GetDiagnostics,
- {},
+ {
+ format: options.format,
+ transactionLimit: options.transactionLimit,
+ frontendInfo: {
+ name: "wallet-webui",
+ version: walletWebUiVersion,
+ platform: platform.target,
+ },
+ },
);
- const blob = new Blob([JSON.stringify(diagnostics, undefined, 2)], {
- type: "application/json",
+ const blob = new Blob([diagnostics], {
+ type:
+ options.format === "yaml" ? "application/yaml" : "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
- anchor.download = `taler-wallet-diagnostics-${new Date().toISOString().slice(0, 10)}.json`;
+ anchor.download = `taler-wallet-diagnostics-${new Date().toISOString().slice(0, 10)}.${options.format}`;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
setMessage(i18n.str`Diagnostics export created.`);
@@ -5200,7 +5216,7 @@ function SettingsRoute() {
browserIntegrationMessage={browserIntegrationMessage}
balanceHistoryEnabled={uiPreferences.balanceHistoryEnabled}
onExport={() => void exportDb()}
- onDiagnostics={() => void exportDiagnostics()}
+ onDiagnostics={(options) => void exportDiagnostics(options)}
onImport={(file) => void reviewImport(file)}
onOpenExchanges={() => navigate("/exchanges")}
onOpenAccounts={() => navigate("/accounts")}
diff --git a/packages/wallet-webui/src/screens/SettingsScreen.tsx b/packages/wallet-webui/src/screens/SettingsScreen.tsx
@@ -38,6 +38,11 @@ export interface StorageGenerationView {
generation: number;
}
+export interface DiagnosticsExportOptions {
+ format: "json" | "yaml";
+ transactionLimit: number;
+}
+
function storageBackendLabel(backend: string): string {
if (backend === "sqlite") return "SQLite";
if (backend === "indexeddb") return "IndexedDB";
@@ -186,7 +191,7 @@ export function SettingsScreen(props: {
browserIntegrationMessage?: string;
balanceHistoryEnabled: boolean;
onExport: () => void;
- onDiagnostics: () => void;
+ onDiagnostics: (options: DiagnosticsExportOptions) => void;
onImport: (file: File) => void;
onOpenExchanges: () => void;
onOpenAccounts: () => void;
@@ -209,6 +214,17 @@ export function SettingsScreen(props: {
const [confirmingClear, setConfirmingClear] = useState(false);
const [confirmingDeveloperMode, setConfirmingDeveloperMode] = useState(false);
const [confirmingStaleDeletion, setConfirmingStaleDeletion] = useState(false);
+ const [diagnosticsFormat, setDiagnosticsFormat] = useState<"json" | "yaml">(
+ "json",
+ );
+ const [diagnosticsTransactionLimit, setDiagnosticsTransactionLimit] =
+ useState("100");
+ const parsedDiagnosticsTransactionLimit = Number(diagnosticsTransactionLimit);
+ const diagnosticsTransactionLimitValid =
+ diagnosticsTransactionLimit.trim() !== "" &&
+ Number.isSafeInteger(parsedDiagnosticsTransactionLimit) &&
+ parsedDiagnosticsTransactionLimit >= 0 &&
+ parsedDiagnosticsTransactionLimit < Number.MAX_SAFE_INTEGER;
return (
<div class="space-y-5">
<div>
@@ -410,10 +426,55 @@ export function SettingsScreen(props: {
<Card>
<h2 class="font-semibold">{i18n.str`Diagnostics`}</h2>
<p class="my-3 text-sm text-secondary">{i18n.str`Save privacy-scrubbed wallet statistics for troubleshooting. This is not a wallet database backup.`}</p>
+ <div class="mb-4 grid gap-3 sm:grid-cols-2">
+ <label class="block">
+ <span class="mb-1 block text-sm font-medium">
+ {i18n.str`File format`}
+ </span>
+ <select
+ value={diagnosticsFormat}
+ onChange={(event) =>
+ setDiagnosticsFormat(
+ event.currentTarget.value === "yaml" ? "yaml" : "json",
+ )
+ }
+ class="w-full rounded-xl border border-outline bg-surface px-3 py-3"
+ >
+ <option value="json">JSON</option>
+ <option value="yaml">YAML</option>
+ </select>
+ </label>
+ <label class="block">
+ <span class="mb-1 block text-sm font-medium">
+ {i18n.str`Maximum transactions`}
+ </span>
+ <input
+ type="number"
+ min="0"
+ step="1"
+ value={diagnosticsTransactionLimit}
+ aria-invalid={!diagnosticsTransactionLimitValid}
+ onInput={(event) =>
+ setDiagnosticsTransactionLimit(event.currentTarget.value)
+ }
+ class="w-full rounded-xl border border-outline bg-surface px-3 py-3"
+ />
+ {!diagnosticsTransactionLimitValid && (
+ <span class="mt-1 block text-xs text-error">
+ {i18n.str`Enter a non-negative whole number.`}
+ </span>
+ )}
+ </label>
+ </div>
<Button
tone="secondary"
- onClick={props.onDiagnostics}
- disabled={props.busy}
+ onClick={() =>
+ props.onDiagnostics({
+ format: diagnosticsFormat,
+ transactionLimit: parsedDiagnosticsTransactionLimit,
+ })
+ }
+ disabled={props.busy || !diagnosticsTransactionLimitValid}
>{i18n.str`Save diagnostics`}</Button>
</Card>
{!props.demo && (
diff --git a/packages/wallet-webui/src/testing/demo-wallet.ts b/packages/wallet-webui/src/testing/demo-wallet.ts
@@ -13,6 +13,7 @@ import {
TransactionMinorState,
TransactionType,
WithdrawalType,
+ stringifyYaml,
type AmountString,
type ScopeInfo,
type Transaction,
@@ -1195,14 +1196,36 @@ export class DemoWalletConnection implements WalletConnection {
return {
transactionId: this.state.transactions[0].transactionId,
} as never;
- case WalletApiOperation.GetDiagnostics:
- return {
- walletManifestVersion: "demo",
- walletManifestDisplayVersion: "Demo mode",
- errors: [],
- firefoxIdbProblem: false,
- dbOutdated: false,
- } as never;
+ case WalletApiOperation.GetDiagnostics: {
+ const report = {
+ formatVersion: 1,
+ generatedAt: new Date(this.now()).toISOString(),
+ walletCoreVersion: {
+ implementationSemver: "demo",
+ implementationGitHash: "demo",
+ version: "10:0:0",
+ },
+ frontendInfo: request.frontendInfo,
+ database: { backend: "indexeddb", recordCounts: {} },
+ exchanges: [],
+ coins: { total: 0, groups: [] },
+ bankAccounts: [],
+ transactions: {
+ limit:
+ typeof request.transactionLimit === "number"
+ ? request.transactionLimit
+ : 100,
+ returned: 0,
+ truncated: false,
+ entries: [],
+ },
+ };
+ return (
+ request.format === "yaml"
+ ? stringifyYaml(report)
+ : `${JSON.stringify(report, undefined, 2)}\n`
+ ) as never;
+ }
default:
throw Error(
`The ${String(operation)} operation is unavailable in demo mode.`,
diff --git a/packages/wallet-webui/test/screens.test.tsx b/packages/wallet-webui/test/screens.test.tsx
@@ -3314,8 +3314,10 @@ test("developer settings require explicit confirmation before clearing the walle
.default as unknown as {
setup(options: { document: Document }): {
click(element: Element): Promise<void>;
+ clear(element: Element): Promise<void>;
keyboard(value: string): Promise<void>;
selectOptions(element: Element, values: string): Promise<void>;
+ type(element: Element, value: string): Promise<void>;
};
};
let clearCount = 0;
@@ -3325,6 +3327,9 @@ test("developer settings require explicit confirmation before clearing the walle
let selectedLanguage: string | undefined;
let integrationChange: [string, boolean] | undefined;
let balanceHistoryEnabled: boolean | undefined;
+ let diagnosticsOptions:
+ | { format: "json" | "yaml"; transactionLimit: number }
+ | undefined;
const settings = (testMoneyBusy = false, metadataLoaded = true) => (
<main>
<SettingsScreen
@@ -3362,7 +3367,9 @@ test("developer settings require explicit confirmation before clearing the walle
}}
balanceHistoryEnabled={false}
onExport={() => {}}
- onDiagnostics={() => {}}
+ onDiagnostics={(options) => {
+ diagnosticsOptions = options;
+ }}
onImport={() => {}}
onOpenExchanges={() => {}}
onOpenAccounts={() => {}}
@@ -3409,6 +3416,40 @@ test("developer settings require explicit confirmation before clearing the walle
assert(view.getByRole("heading", { name: "Wallet data protection" }));
assert(view.getByRole("heading", { name: "Browser integration" }));
assert(view.getByRole("heading", { name: "Balance display" }));
+ const diagnosticsFormat = view.getByRole("combobox", {
+ name: "File format",
+ });
+ const diagnosticsLimit = view.getByRole("spinbutton", {
+ name: "Maximum transactions",
+ });
+ assert.equal((diagnosticsFormat as HTMLSelectElement).value, "json");
+ assert.equal((diagnosticsLimit as HTMLInputElement).value, "100");
+ await user.click(view.getByRole("button", { name: "Save diagnostics" }));
+ assert.deepEqual(diagnosticsOptions, {
+ format: "json",
+ transactionLimit: 100,
+ });
+ await user.selectOptions(diagnosticsFormat, "yaml");
+ await user.clear(diagnosticsLimit);
+ await user.type(diagnosticsLimit, "25");
+ await user.click(view.getByRole("button", { name: "Save diagnostics" }));
+ assert.deepEqual(diagnosticsOptions, {
+ format: "yaml",
+ transactionLimit: 25,
+ });
+ await user.clear(diagnosticsLimit);
+ await user.type(diagnosticsLimit, "-1");
+ assert(view.getByText("Enter a non-negative whole number."));
+ assert.equal(
+ (
+ view.getByRole("button", {
+ name: "Save diagnostics",
+ }) as HTMLButtonElement
+ ).disabled,
+ true,
+ );
+ await user.clear(diagnosticsLimit);
+ await user.type(diagnosticsLimit, "100");
await user.click(
view.getByRole("checkbox", { name: "Show balance history graph" }),
);