Tauri prod CSP for Emotion/MUI and window maximize ACL
- Tauri was injecting nonces/hashes into style-src, which disables 'unsafe-inline' and blocked Emotion/MUI runtime <style> tags. - Grant core:window:allow-maximize so frontend maximize() passes ACL. - Add node-status and explorer helpers plus chart mappers; Jest coverage - NodeOperatorInsights on BondedNymNode; optional API moniker/location - Shared MUI Emotion cache (speedy: false) and CacheProvider wiring - SendInputModal: amount/recipient validation timing; memoized fee check - AuthLayout refresh; NodeTable overflow-x; Bonding error title typo fix
This commit is contained in:
@@ -5,13 +5,18 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "run-s webpack:prod tauri:build",
|
||||
"build:no-sign": "run-s webpack:prod tauri:build:no-sign",
|
||||
"build-macx86": "run-s webpack:prod tauri:buildx86",
|
||||
"dev": "run-p tauri:dev webpack:dev",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"prebuild": "yarn --cwd .. build",
|
||||
"prewebpack:dev": "yarn --cwd .. build",
|
||||
"prewebpack:prod": "yarn check:singletons",
|
||||
"check:singletons": "node scripts/check-mui-singletons.js",
|
||||
"tauri:build": "yarn tauri build",
|
||||
"tauri:build:no-sign": "yarn tauri build --no-sign -b app",
|
||||
"tauri:build:adhoc": "APPLE_SIGNING_IDENTITY=- yarn tauri build -b app",
|
||||
"tauri:dev": "yarn tauri dev",
|
||||
"tauri:buildx86": "yarn tauri build --target x86_64-apple-darwin",
|
||||
"test": "jest --config jest.config.cjs",
|
||||
@@ -42,6 +47,7 @@
|
||||
"@tauri-apps/tauri-forage": "^1.0.0-beta.2",
|
||||
"big.js": "^6.2.1",
|
||||
"bs58": "^4.0.1",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"clsx": "^1.1.1",
|
||||
"date-fns": "^2.28.0",
|
||||
"joi": "^17.11.0",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* eslint-disable no-console -- build-time diagnostic */
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const walletRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const PKGS = [
|
||||
'@emotion/react',
|
||||
'@emotion/styled',
|
||||
'@emotion/cache',
|
||||
'@mui/material',
|
||||
'@mui/system',
|
||||
'@mui/styled-engine',
|
||||
'@mui/private-theming',
|
||||
'@mui/utils',
|
||||
'@mui/lab',
|
||||
'@mui/icons-material',
|
||||
'react',
|
||||
'react-dom',
|
||||
];
|
||||
|
||||
const probes = [
|
||||
walletRoot,
|
||||
path.join(walletRoot, 'node_modules', '@nymproject', 'react'),
|
||||
path.join(walletRoot, 'node_modules', '@mui', 'material'),
|
||||
];
|
||||
|
||||
let bad = 0;
|
||||
PKGS.forEach((pkg) => {
|
||||
const seen = new Set();
|
||||
probes.forEach((probe) => {
|
||||
try {
|
||||
const p = require.resolve(`${pkg}/package.json`, { paths: [probe] });
|
||||
seen.add(fs.realpathSync(p));
|
||||
} catch {
|
||||
/* probe may not resolve this package */
|
||||
}
|
||||
});
|
||||
if (seen.size > 1) {
|
||||
bad += 1;
|
||||
console.error(`[singleton] DUPLICATE ${pkg}:\n ${[...seen].join('\n ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -40,7 +40,7 @@ serde_json = "1.0"
|
||||
serde_repr = "0.1"
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
tap = "1"
|
||||
tauri = { version = "2.10.3", features = [] }
|
||||
tauri = { version = "2.10.3", features = ["devtools"] }
|
||||
#tendermint-rpc = "0.23.0"
|
||||
time = { version = "0.3.30", features = ["local-offset"] }
|
||||
thiserror = "1.0"
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"opener:allow-open-url",
|
||||
"opener:allow-default-urls",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-maximize",
|
||||
"core:app:allow-version",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-write-text",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"main-capability":{"identifier":"main-capability","description":"Default capability for Nym Wallet main window","local":true,"windows":["main","nymWalletApp","log"],"permissions":["core:default","core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","opener:allow-open-url","opener:allow-default-urls","core:window:allow-set-title","core:app:allow-version","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","updater:default","updater:allow-check","updater:allow-download","updater:allow-download-and-install","updater:allow-install","core:event:allow-listen","shell:allow-open","process:default"],"platforms":["linux","macOS","windows"]}}
|
||||
{"main-capability":{"identifier":"main-capability","description":"Default capability for Nym Wallet main window","local":true,"windows":["main","nymWalletApp","log"],"permissions":["core:default","core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","opener:allow-open-url","opener:allow-default-urls","core:window:allow-set-title","core:window:allow-maximize","core:app:allow-version","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","updater:default","updater:allow-check","updater:allow-download","updater:allow-download-and-install","updater:allow-install","core:event:allow-listen","shell:allow-open","process:default"],"platforms":["linux","macOS","windows"]}}
|
||||
@@ -14,7 +14,7 @@
|
||||
],
|
||||
"resources": [],
|
||||
"externalBin": [],
|
||||
"copyright": "Copyright © 2021-2025 Nym Technologies SA",
|
||||
"copyright": "Copyright © 2021-2026 Nym Technologies SA",
|
||||
"category": "Business",
|
||||
"shortDescription": "Nym desktop wallet allows you to manage your NYM tokens",
|
||||
"longDescription": "",
|
||||
@@ -45,7 +45,7 @@
|
||||
},
|
||||
"productName": "NymWallet",
|
||||
"mainBinaryName": "NymWallet",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"identifier": "net.nymtech.wallet",
|
||||
"plugins": {
|
||||
"updater": {
|
||||
@@ -60,12 +60,15 @@
|
||||
"capabilities": [
|
||||
"main-capability"
|
||||
],
|
||||
"dangerousDisableAssetCspModification": [
|
||||
"style-src"
|
||||
],
|
||||
"csp": {
|
||||
"default-src": "'self' customprotocol: asset:",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
|
||||
"style-src": "'unsafe-inline' 'self'",
|
||||
"style-src": "'unsafe-inline' 'self' https://fonts.googleapis.com",
|
||||
"img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data:",
|
||||
"font-src": "'self' data:",
|
||||
"font-src": "'self' data: https://fonts.gstatic.com",
|
||||
"connect-src": "ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* wss://127.0.0.1:* wss://localhost:* http: https: ws: wss:",
|
||||
"media-src": "'self' blob: data:",
|
||||
"worker-src": "'self' blob:",
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import type { ExplorerNymNodeRow } from './nodeStatus';
|
||||
import {
|
||||
clearNodeStatusExplorerCaches,
|
||||
fetchGatewayStatus,
|
||||
fetchGatewayStatusIfBonded,
|
||||
findExplorerNymNodeByIdentity,
|
||||
getNodeStatusBaseUrl,
|
||||
isGatewayRole,
|
||||
normalizeExplorerUptimePercent,
|
||||
parseTotalStakeToNymAmount,
|
||||
} from './nodeStatus';
|
||||
|
||||
describe('getNodeStatusBaseUrl', () => {
|
||||
it('uses mainnet base for MAINNET and unknown', () => {
|
||||
expect(getNodeStatusBaseUrl('MAINNET')).toBe('https://mainnet-node-status-api.nymtech.cc');
|
||||
expect(getNodeStatusBaseUrl(undefined)).toBe('https://mainnet-node-status-api.nymtech.cc');
|
||||
});
|
||||
|
||||
it('uses sandbox base for SANDBOX and QA', () => {
|
||||
expect(getNodeStatusBaseUrl('SANDBOX')).toBe('https://sandbox-node-status-api.nymte.ch');
|
||||
expect(getNodeStatusBaseUrl('QA')).toBe('https://sandbox-node-status-api.nymte.ch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGatewayRole', () => {
|
||||
it('is true only for gateway roles', () => {
|
||||
expect(isGatewayRole('entryGateway')).toBe(true);
|
||||
expect(isGatewayRole('exitGateway')).toBe(true);
|
||||
expect(isGatewayRole('layer1')).toBe(false);
|
||||
expect(isGatewayRole(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTotalStakeToNymAmount', () => {
|
||||
it('divides base unym by 1e6', () => {
|
||||
expect(parseTotalStakeToNymAmount('1000000')).toBe(1);
|
||||
expect(parseTotalStakeToNymAmount('2500000')).toBe(2.5);
|
||||
});
|
||||
|
||||
it('returns 0 for non-finite values', () => {
|
||||
expect(parseTotalStakeToNymAmount('')).toBe(0);
|
||||
expect(parseTotalStakeToNymAmount('x')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeExplorerUptimePercent', () => {
|
||||
it('maps ratio 0-1 to percent', () => {
|
||||
expect(normalizeExplorerUptimePercent(0.98)).toBe(98);
|
||||
expect(normalizeExplorerUptimePercent(1)).toBe(100);
|
||||
expect(normalizeExplorerUptimePercent(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves values already in 0-100 percent scale', () => {
|
||||
expect(normalizeExplorerUptimePercent(98)).toBe(98);
|
||||
expect(normalizeExplorerUptimePercent(100)).toBe(100);
|
||||
});
|
||||
|
||||
it('parses numeric strings', () => {
|
||||
expect(normalizeExplorerUptimePercent('0.98')).toBe(98);
|
||||
expect(normalizeExplorerUptimePercent(' 98 ')).toBe(98);
|
||||
});
|
||||
|
||||
it('clamps negatives and caps at 100', () => {
|
||||
expect(normalizeExplorerUptimePercent(-1)).toBe(0);
|
||||
expect(normalizeExplorerUptimePercent(150)).toBe(100);
|
||||
});
|
||||
|
||||
it('returns 0 for non-numeric input', () => {
|
||||
expect(normalizeExplorerUptimePercent('')).toBe(0);
|
||||
expect(normalizeExplorerUptimePercent('abc')).toBe(0);
|
||||
expect(normalizeExplorerUptimePercent(Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(ok: boolean, status: number, body: unknown): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe('fetchGatewayStatusIfBonded', () => {
|
||||
const baseUrl = 'https://status.example';
|
||||
const identity = 'nym1abc';
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns null on 404 and 400', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(false, 404, null));
|
||||
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toBeNull();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(false, 400, null));
|
||||
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('throws on other non-OK responses', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(false, 500, null));
|
||||
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).rejects.toThrow('Gateway status request failed (500)');
|
||||
});
|
||||
|
||||
it('returns null when bonded is false or identity mismatches', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse(true, 200, {
|
||||
gateway_identity_key: identity,
|
||||
bonded: false,
|
||||
performance: 0,
|
||||
routing_score: 0,
|
||||
last_updated_utc: 't',
|
||||
}),
|
||||
);
|
||||
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toBeNull();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse(true, 200, {
|
||||
gateway_identity_key: 'other',
|
||||
bonded: true,
|
||||
performance: 0,
|
||||
routing_score: 0,
|
||||
last_updated_utc: 't',
|
||||
}),
|
||||
);
|
||||
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns payload when bonded and identity matches', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
const payload = {
|
||||
gateway_identity_key: identity,
|
||||
bonded: true,
|
||||
performance: 88,
|
||||
routing_score: 1,
|
||||
last_updated_utc: 't',
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(true, 200, payload));
|
||||
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toStrictEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://status.example/v2/gateways/nym1abc',
|
||||
expect.objectContaining({ cache: 'no-store' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('strips trailing slash from base URL', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse(true, 200, {
|
||||
gateway_identity_key: identity,
|
||||
bonded: true,
|
||||
performance: 1,
|
||||
routing_score: 0,
|
||||
last_updated_utc: 't',
|
||||
}),
|
||||
);
|
||||
await fetchGatewayStatusIfBonded('https://status.example/', identity);
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://status.example/v2/gateways/nym1abc', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchGatewayStatus', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('throws when gateway is not bonded', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(false, 404, null));
|
||||
await expect(fetchGatewayStatus('https://x', 'id')).rejects.toThrow('Gateway status request failed (404)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findExplorerNymNodeByIdentity', () => {
|
||||
const base = 'https://status.example';
|
||||
const identity = 'nodeIdentity1';
|
||||
|
||||
const sampleRow = (key: string): ExplorerNymNodeRow => ({
|
||||
identity_key: key,
|
||||
node_id: 1,
|
||||
bonded: true,
|
||||
uptime: 0.99,
|
||||
total_stake: '1000000',
|
||||
original_pledge: 1,
|
||||
description: { moniker: 'm', website: '', details: '', security_contact: '' },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearNodeStatusExplorerCaches();
|
||||
global.fetch = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
clearNodeStatusExplorerCaches();
|
||||
});
|
||||
|
||||
it('finds row on first page', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
const row = sampleRow(identity);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(true, 200, { items: [row], page: 0, size: 200, total: 1 }));
|
||||
const result = await findExplorerNymNodeByIdentity('MAINNET', base, identity);
|
||||
expect(result).toStrictEqual(row);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://status.example/explorer/v3/nym-nodes?page=0&size=200',
|
||||
expect.objectContaining({ cache: 'no-store' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('paginates until match', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
const other = sampleRow('other');
|
||||
const row = sampleRow(identity);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(true, 200, {
|
||||
items: Array(200).fill(other),
|
||||
page: 0,
|
||||
size: 200,
|
||||
total: 250,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(true, 200, {
|
||||
items: [row],
|
||||
page: 1,
|
||||
size: 200,
|
||||
total: 250,
|
||||
}),
|
||||
);
|
||||
const result = await findExplorerNymNodeByIdentity('MAINNET', base, identity);
|
||||
expect(result).toStrictEqual(row);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws when explorer returns non-OK', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(false, 503, null));
|
||||
await expect(findExplorerNymNodeByIdentity('MAINNET', base, identity)).rejects.toThrow(
|
||||
'Explorer nym-nodes request failed (503)',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when not found and caches miss', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValue(jsonResponse(true, 200, { items: [], page: 0, size: 200, total: 0 }));
|
||||
await expect(findExplorerNymNodeByIdentity('MAINNET', base, identity)).resolves.toBeNull();
|
||||
await expect(findExplorerNymNodeByIdentity('MAINNET', base, identity)).resolves.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns cached hit without refetching', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
const row = sampleRow(identity);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(true, 200, { items: [row], page: 0, size: 200, total: 1 }));
|
||||
await findExplorerNymNodeByIdentity('MAINNET', base, identity);
|
||||
await findExplorerNymNodeByIdentity('MAINNET', base, identity);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent scans for the same key', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
let resolveFetch!: (v: Response) => void;
|
||||
const fetchPromise = new Promise<Response>((r) => {
|
||||
resolveFetch = r;
|
||||
});
|
||||
fetchMock.mockReturnValueOnce(fetchPromise);
|
||||
|
||||
const p1 = findExplorerNymNodeByIdentity('MAINNET', base, identity);
|
||||
const p2 = findExplorerNymNodeByIdentity('MAINNET', base, identity);
|
||||
resolveFetch(jsonResponse(true, 200, { items: [], page: 0, size: 200, total: 0 }));
|
||||
await expect(Promise.all([p1, p2])).resolves.toStrictEqual([null, null]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not share cache between networks for the same identity', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
const rowMainnet = sampleRow(identity);
|
||||
const rowSandbox = { ...sampleRow(identity), node_id: 99 };
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse(true, 200, { items: [rowMainnet], page: 0, size: 200, total: 1 }))
|
||||
.mockResolvedValueOnce(jsonResponse(true, 200, { items: [rowSandbox], page: 0, size: 200, total: 1 }));
|
||||
await expect(findExplorerNymNodeByIdentity('MAINNET', base, identity)).resolves.toStrictEqual(rowMainnet);
|
||||
await expect(findExplorerNymNodeByIdentity('SANDBOX', base, identity)).resolves.toStrictEqual(rowSandbox);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { Network, TNodeRole } from 'src/types';
|
||||
|
||||
const MAINNET_BASE = 'https://mainnet-node-status-api.nymtech.cc';
|
||||
const SANDBOX_BASE = 'https://sandbox-node-status-api.nymte.ch';
|
||||
|
||||
/** Explorer list responses are paginated; cache successful lookups to avoid repeated full scans. */
|
||||
const EXPLORER_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const EXPLORER_MISS_TTL_MS = 60 * 1000;
|
||||
const EXPLORER_PAGE_SIZE = 200;
|
||||
|
||||
type ExplorerCacheEntry = { data: ExplorerNymNodeRow | null; expiresAt: number };
|
||||
|
||||
const explorerCache = new Map<string, ExplorerCacheEntry>();
|
||||
const explorerInflight = new Map<string, Promise<ExplorerNymNodeRow | null>>();
|
||||
|
||||
/**
|
||||
* Clears explorer list cache and in-flight scans. Call from tests between cases; optional for a future forced refresh UX.
|
||||
*/
|
||||
export function clearNodeStatusExplorerCaches(): void {
|
||||
explorerCache.clear();
|
||||
explorerInflight.clear();
|
||||
}
|
||||
|
||||
export function getNodeStatusBaseUrl(network?: Network): string {
|
||||
if (network === 'SANDBOX' || network === 'QA') {
|
||||
return SANDBOX_BASE;
|
||||
}
|
||||
return MAINNET_BASE;
|
||||
}
|
||||
|
||||
const jsonHeaders = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
};
|
||||
|
||||
/** Subset of probe outcome used for charts (matches node-status API JSON shape). */
|
||||
export type GatewayProbeOutcome = {
|
||||
as_entry?: { can_connect?: boolean; can_route?: boolean } | null;
|
||||
as_exit?: {
|
||||
can_connect?: boolean;
|
||||
can_route_ip_v4?: boolean;
|
||||
can_route_ip_v6?: boolean;
|
||||
can_route_ip_external_v4?: boolean;
|
||||
can_route_ip_external_v6?: boolean;
|
||||
} | null;
|
||||
socks5?: {
|
||||
can_connect_socks5?: boolean;
|
||||
https_connectivity?: { https_success?: boolean; https_latency_ms?: number };
|
||||
} | null;
|
||||
wg?: {
|
||||
can_handshake_v4?: boolean;
|
||||
can_handshake_v6?: boolean;
|
||||
can_query_metadata_v4?: boolean;
|
||||
can_register?: boolean;
|
||||
can_resolve_dns_v4?: boolean;
|
||||
can_resolve_dns_v6?: boolean;
|
||||
ping_hosts_performance_v4?: number;
|
||||
ping_hosts_performance_v6?: number;
|
||||
ping_ips_performance_v4?: number;
|
||||
ping_ips_performance_v6?: number;
|
||||
download_duration_milliseconds_v4?: number;
|
||||
download_duration_milliseconds_v6?: number;
|
||||
} | null;
|
||||
lp?: {
|
||||
can_connect?: boolean;
|
||||
can_handshake?: boolean;
|
||||
can_register?: boolean;
|
||||
error?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type GatewayStatusPayload = {
|
||||
gateway_identity_key: string;
|
||||
bonded: boolean;
|
||||
performance: number;
|
||||
routing_score: number;
|
||||
last_probe_result?: {
|
||||
outcome?: GatewayProbeOutcome;
|
||||
} | null;
|
||||
last_testrun_utc?: string | null;
|
||||
last_updated_utc: string;
|
||||
description?: {
|
||||
moniker?: string;
|
||||
website?: string;
|
||||
details?: string;
|
||||
security_contact?: string;
|
||||
};
|
||||
explorer_pretty_bond?: {
|
||||
location?: {
|
||||
two_letter_iso_country_code?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/** Fields used from `GET /explorer/v3/nym-nodes` items (identity-key match). */
|
||||
export type ExplorerNymNodeRow = {
|
||||
identity_key: string;
|
||||
node_id: number;
|
||||
bonded: boolean;
|
||||
/** Explorer may send percent, ratio 0-1, or a numeric string. */
|
||||
uptime: number | string;
|
||||
total_stake: string;
|
||||
original_pledge: number;
|
||||
description: {
|
||||
moniker: string;
|
||||
website: string;
|
||||
details: string;
|
||||
security_contact: string;
|
||||
};
|
||||
geoip?: {
|
||||
city?: string;
|
||||
country?: string;
|
||||
region?: string;
|
||||
two_letter_iso_country_code?: string;
|
||||
} | null;
|
||||
rewarding_details?: {
|
||||
unique_delegations?: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PagedExplorerNymNodes = {
|
||||
items: ExplorerNymNodeRow[];
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
function explorerCacheKey(network: Network | undefined, identityKey: string): string {
|
||||
return `${network ?? 'MAINNET'}:${identityKey}`;
|
||||
}
|
||||
|
||||
async function fetchExplorerNymNodesPage(baseUrl: string, page: number, size: number): Promise<PagedExplorerNymNodes> {
|
||||
const root = baseUrl.replace(/\/$/, '');
|
||||
const url = `${root}/explorer/v3/nym-nodes?page=${page}&size=${size}`;
|
||||
const response = await fetch(url, { headers: jsonHeaders, cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Explorer nym-nodes request failed (${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<PagedExplorerNymNodes>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginates `GET /explorer/v3/nym-nodes` until `identity_key` matches or the list ends.
|
||||
* Results are cached per network + identity (hits: 5 min, misses: 60 s). Concurrent calls share one in-flight scan.
|
||||
*/
|
||||
export function findExplorerNymNodeByIdentity(
|
||||
network: Network | undefined,
|
||||
baseUrl: string,
|
||||
identityKey: string,
|
||||
): Promise<ExplorerNymNodeRow | null> {
|
||||
const key = explorerCacheKey(network, identityKey);
|
||||
const now = Date.now();
|
||||
const cached = explorerCache.get(key);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return Promise.resolve(cached.data);
|
||||
}
|
||||
|
||||
const pending = explorerInflight.get(key);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const promise = (async (): Promise<ExplorerNymNodeRow | null> => {
|
||||
try {
|
||||
let page = 0;
|
||||
let total = Number.POSITIVE_INFINITY;
|
||||
while (page * EXPLORER_PAGE_SIZE < total) {
|
||||
// eslint-disable-next-line no-await-in-loop -- sequential pagination required
|
||||
const data = await fetchExplorerNymNodesPage(baseUrl, page, EXPLORER_PAGE_SIZE);
|
||||
total = data.total;
|
||||
const found = data.items.find((item) => item.identity_key === identityKey);
|
||||
if (found) {
|
||||
explorerCache.set(key, { data: found, expiresAt: Date.now() + EXPLORER_CACHE_TTL_MS });
|
||||
return found;
|
||||
}
|
||||
const totalPages = Math.ceil(data.total / EXPLORER_PAGE_SIZE);
|
||||
if (page >= totalPages - 1 || data.items.length < EXPLORER_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
explorerCache.set(key, { data: null, expiresAt: Date.now() + EXPLORER_MISS_TTL_MS });
|
||||
return null;
|
||||
} finally {
|
||||
explorerInflight.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
explorerInflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function isGatewayRole(role: TNodeRole | undefined): boolean {
|
||||
return role === 'entryGateway' || role === 'exitGateway';
|
||||
}
|
||||
|
||||
/**
|
||||
* When the status API lists this identity as a bonded gateway, returns the payload.
|
||||
* Otherwise `null` (404, not bonded, or identity mismatch) so callers can fall back to explorer / mixnode paths.
|
||||
* Still throws on other HTTP errors so real failures surface.
|
||||
*/
|
||||
export async function fetchGatewayStatusIfBonded(
|
||||
baseUrl: string,
|
||||
identityKey: string,
|
||||
): Promise<GatewayStatusPayload | null> {
|
||||
const root = baseUrl.replace(/\/$/, '');
|
||||
const url = `${root}/v2/gateways/${encodeURIComponent(identityKey)}`;
|
||||
const response = await fetch(url, { headers: jsonHeaders, cache: 'no-store' });
|
||||
// Mixnodes are not in the gateway index. The API returns 404 or, for some identities, 400 with a short body.
|
||||
// Treat both as "no gateway row" so the wallet can fall back to explorer. Other 4xx are unexpected; 5xx throws below.
|
||||
if (response.status === 404 || response.status === 400) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gateway status request failed (${response.status})`);
|
||||
}
|
||||
const data = (await response.json()) as GatewayStatusPayload;
|
||||
if (!data.bonded || data.gateway_identity_key !== identityKey) {
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchGatewayStatus(baseUrl: string, identityKey: string): Promise<GatewayStatusPayload> {
|
||||
const g = await fetchGatewayStatusIfBonded(baseUrl, identityKey);
|
||||
if (!g) {
|
||||
throw new Error('Gateway status request failed (404)');
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Parse API `total_stake` string (base unym) to NYM display amount. */
|
||||
export function parseTotalStakeToNymAmount(totalStake: string): number {
|
||||
const n = Number.parseFloat(totalStake);
|
||||
if (!Number.isFinite(n)) {
|
||||
return 0;
|
||||
}
|
||||
return n / 1_000_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* API may return 0-100 (percent), 0-1 (ratio), or numeric strings. Values in [0, 1] are treated as a ratio.
|
||||
*/
|
||||
export function normalizeExplorerUptimePercent(raw: unknown): number {
|
||||
const n = typeof raw === 'string' ? Number.parseFloat(raw.trim()) : Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (n <= 1) {
|
||||
return Math.min(100, n * 100);
|
||||
}
|
||||
return Math.min(100, n);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { probeGroupsForChart, socks5LatencyMs, wgComparisonBars } from './nodeStatusCharts';
|
||||
|
||||
describe('probeGroupsForChart', () => {
|
||||
it('returns empty array when outcome is missing', () => {
|
||||
expect(probeGroupsForChart(undefined)).toStrictEqual([]);
|
||||
expect(probeGroupsForChart(null)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('aggregates entry booleans', () => {
|
||||
expect(
|
||||
probeGroupsForChart({
|
||||
as_entry: { can_connect: true, can_route: false },
|
||||
}),
|
||||
).toStrictEqual([{ name: 'Entry', passed: 1, total: 2, pctPassed: 50 }]);
|
||||
});
|
||||
|
||||
it('skips entry group when no boolean fields are present', () => {
|
||||
expect(probeGroupsForChart({ as_entry: {} })).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('aggregates exit routing flags', () => {
|
||||
const groups = probeGroupsForChart({
|
||||
as_exit: {
|
||||
can_connect: true,
|
||||
can_route_ip_v4: false,
|
||||
can_route_ip_v6: true,
|
||||
},
|
||||
});
|
||||
expect(groups).toContainEqual({ name: 'Exit', passed: 2, total: 3, pctPassed: 67 });
|
||||
});
|
||||
|
||||
it('includes SOCKS5 when connectivity flags exist', () => {
|
||||
expect(
|
||||
probeGroupsForChart({
|
||||
socks5: {
|
||||
can_connect_socks5: true,
|
||||
https_connectivity: { https_success: false, https_latency_ms: 12 },
|
||||
},
|
||||
}),
|
||||
).toStrictEqual([{ name: 'SOCKS5', passed: 1, total: 2, pctPassed: 50 }]);
|
||||
});
|
||||
|
||||
it('includes WireGuard and LP groups from booleans', () => {
|
||||
const groups = probeGroupsForChart({
|
||||
wg: { can_handshake_v4: true, can_handshake_v6: false },
|
||||
lp: { can_connect: true, can_handshake: true, can_register: false },
|
||||
});
|
||||
expect(groups).toContainEqual({ name: 'WireGuard', passed: 1, total: 2, pctPassed: 50 });
|
||||
expect(groups).toContainEqual({ name: 'LP', passed: 2, total: 3, pctPassed: 67 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('wgComparisonBars', () => {
|
||||
it('prefers download duration bars when present', () => {
|
||||
expect(
|
||||
wgComparisonBars({
|
||||
wg: { download_duration_milliseconds_v4: 120, download_duration_milliseconds_v6: 200 },
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{ name: 'IPv4 download', value: 120, kind: 'milliseconds' },
|
||||
{ name: 'IPv6 download', value: 200, kind: 'milliseconds' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to ping performance as percent', () => {
|
||||
expect(
|
||||
wgComparisonBars({
|
||||
wg: { ping_ips_performance_v4: 0.88, ping_ips_performance_v6: 0.5 },
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{ name: 'IPv4 ping success', value: 88, kind: 'percent' },
|
||||
{ name: 'IPv6 ping success', value: 50, kind: 'percent' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty when wg is absent', () => {
|
||||
expect(wgComparisonBars(undefined)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('socks5LatencyMs', () => {
|
||||
it('returns latency when numeric', () => {
|
||||
expect(
|
||||
socks5LatencyMs({
|
||||
socks5: { https_connectivity: { https_latency_ms: 42 } },
|
||||
}),
|
||||
).toBe(42);
|
||||
});
|
||||
|
||||
it('returns undefined when missing or non-numeric', () => {
|
||||
expect(socks5LatencyMs(undefined)).toBeUndefined();
|
||||
expect(socks5LatencyMs({ socks5: {} })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { GatewayProbeOutcome } from './nodeStatus';
|
||||
|
||||
/** Measures from WireGuard probe: either download latency (ms) or ping success (0-100 %). */
|
||||
export type WgMeasureBar = { name: string; value: number; kind: 'milliseconds' | 'percent' };
|
||||
|
||||
/** Per probe category: percent of checks that passed (0-100). */
|
||||
export type ProbeGroupBar = { name: string; pctPassed: number; passed: number; total: number };
|
||||
|
||||
export function probeGroupsForChart(outcome: GatewayProbeOutcome | undefined | null): ProbeGroupBar[] {
|
||||
const groups: ProbeGroupBar[] = [];
|
||||
|
||||
const pushGroup = (name: string, p: number, t: number) => {
|
||||
if (t === 0) {
|
||||
return;
|
||||
}
|
||||
groups.push({
|
||||
name,
|
||||
passed: p,
|
||||
total: t,
|
||||
pctPassed: Math.round((p / t) * 100),
|
||||
});
|
||||
};
|
||||
|
||||
if (outcome?.as_entry) {
|
||||
const e = outcome.as_entry;
|
||||
let p = 0;
|
||||
let t = 0;
|
||||
if (typeof e.can_connect === 'boolean') {
|
||||
t += 1;
|
||||
if (e.can_connect) {
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
if (typeof e.can_route === 'boolean') {
|
||||
t += 1;
|
||||
if (e.can_route) {
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
pushGroup('Entry', p, t);
|
||||
}
|
||||
|
||||
if (outcome?.as_exit) {
|
||||
const x = outcome.as_exit;
|
||||
const keys = [
|
||||
'can_connect',
|
||||
'can_route_ip_v4',
|
||||
'can_route_ip_v6',
|
||||
'can_route_ip_external_v4',
|
||||
'can_route_ip_external_v6',
|
||||
] as const;
|
||||
const exitCounts = keys.reduce(
|
||||
(acc, k) => {
|
||||
const v = x[k];
|
||||
if (typeof v === 'boolean') {
|
||||
return { p: acc.p + (v ? 1 : 0), t: acc.t + 1 };
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ p: 0, t: 0 },
|
||||
);
|
||||
pushGroup('Exit', exitCounts.p, exitCounts.t);
|
||||
}
|
||||
|
||||
if (outcome?.socks5) {
|
||||
const s = outcome.socks5;
|
||||
let p = 0;
|
||||
let t = 0;
|
||||
if (typeof s.can_connect_socks5 === 'boolean') {
|
||||
t += 1;
|
||||
if (s.can_connect_socks5) {
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
if (s.https_connectivity && typeof s.https_connectivity.https_success === 'boolean') {
|
||||
t += 1;
|
||||
if (s.https_connectivity.https_success) {
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
pushGroup('SOCKS5', p, t);
|
||||
}
|
||||
|
||||
if (outcome?.wg) {
|
||||
const w = outcome.wg;
|
||||
const boolKeys = [
|
||||
'can_handshake_v4',
|
||||
'can_handshake_v6',
|
||||
'can_query_metadata_v4',
|
||||
'can_register',
|
||||
'can_resolve_dns_v4',
|
||||
'can_resolve_dns_v6',
|
||||
] as const;
|
||||
const wgCounts = boolKeys.reduce(
|
||||
(acc, k) => {
|
||||
const v = w[k];
|
||||
if (typeof v === 'boolean') {
|
||||
return { p: acc.p + (v ? 1 : 0), t: acc.t + 1 };
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ p: 0, t: 0 },
|
||||
);
|
||||
pushGroup('WireGuard', wgCounts.p, wgCounts.t);
|
||||
}
|
||||
|
||||
if (outcome?.lp) {
|
||||
const l = outcome.lp;
|
||||
const lpKeys = ['can_connect', 'can_handshake', 'can_register'] as const;
|
||||
const lpCounts = lpKeys.reduce(
|
||||
(acc, k) => {
|
||||
const v = l[k];
|
||||
if (typeof v === 'boolean') {
|
||||
return { p: acc.p + (v ? 1 : 0), t: acc.t + 1 };
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ p: 0, t: 0 },
|
||||
);
|
||||
pushGroup('LP', lpCounts.p, lpCounts.t);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Bars for WG download latency (ms) or ping success (percent). Only one family is returned per probe. */
|
||||
export function wgComparisonBars(outcome: GatewayProbeOutcome | undefined | null): WgMeasureBar[] {
|
||||
const w = outcome?.wg;
|
||||
if (!w) {
|
||||
return [];
|
||||
}
|
||||
const v4 = w.download_duration_milliseconds_v4;
|
||||
const v6 = w.download_duration_milliseconds_v6;
|
||||
if (typeof v4 === 'number' || typeof v6 === 'number') {
|
||||
const out: WgMeasureBar[] = [];
|
||||
if (typeof v4 === 'number') {
|
||||
out.push({ name: 'IPv4 download', value: v4, kind: 'milliseconds' });
|
||||
}
|
||||
if (typeof v6 === 'number') {
|
||||
out.push({ name: 'IPv6 download', value: v6, kind: 'milliseconds' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const p4 = w.ping_ips_performance_v4;
|
||||
const p6 = w.ping_ips_performance_v6;
|
||||
const out: WgMeasureBar[] = [];
|
||||
if (typeof p4 === 'number') {
|
||||
out.push({ name: 'IPv4 ping success', value: Math.round(p4 * 100), kind: 'percent' });
|
||||
}
|
||||
if (typeof p6 === 'number') {
|
||||
out.push({ name: 'IPv6 ping success', value: Math.round(p6 * 100), kind: 'percent' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function socks5LatencyMs(outcome: GatewayProbeOutcome | undefined | null): number | undefined {
|
||||
const ms = outcome?.socks5?.https_connectivity?.https_latency_ms;
|
||||
return typeof ms === 'number' ? ms : undefined;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Button, Chip, Stack, Tooltip, Typography } from '@mui/material';
|
||||
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
|
||||
@@ -11,9 +11,22 @@ import { TBondedNymNode } from 'src/requests/nymNodeDetails';
|
||||
import { Node as NodeIcon } from '../../svg-icons/node';
|
||||
import { Cell, Header, NodeTable } from './NodeTable';
|
||||
import { BondedNymNodeActions, TBondedNymNodeActions } from './BondedNymNodeActions';
|
||||
import { NodeOperatorInsights, type NodeStatusMetadata } from './NodeOperatorInsights';
|
||||
|
||||
const textWhenNotName = 'This node has not yet set a name';
|
||||
|
||||
/** Wallet default name vs legacy copy in BondedNymNode. */
|
||||
function isUnsetNodeName(name: string | undefined): boolean {
|
||||
if (!name) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
name.includes('Name has not been set') ||
|
||||
name.includes(textWhenNotName) ||
|
||||
name.toLowerCase().includes('not been set')
|
||||
);
|
||||
}
|
||||
|
||||
const headers: Header[] = [
|
||||
{
|
||||
header: 'Stake',
|
||||
@@ -66,6 +79,7 @@ export const BondedNymNode = ({
|
||||
onActionSelect: (action: TBondedNymNodeActions) => void;
|
||||
}) => {
|
||||
const [nextEpoch, setNextEpoch] = useState<string | Error>();
|
||||
const [statusMeta, setStatusMeta] = useState<NodeStatusMetadata | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
name,
|
||||
@@ -79,8 +93,13 @@ export const BondedNymNode = ({
|
||||
delegators,
|
||||
identityKey,
|
||||
host,
|
||||
uptime,
|
||||
} = nymnode;
|
||||
|
||||
const handleStatusLoaded = useCallback((meta: NodeStatusMetadata) => {
|
||||
setStatusMeta(meta);
|
||||
}, []);
|
||||
|
||||
const getNextInterval = async () => {
|
||||
try {
|
||||
const { nextEpoch: newNextEpoch } = await getIntervalAsDate();
|
||||
@@ -136,22 +155,41 @@ export const BondedNymNode = ({
|
||||
getNextInterval();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setStatusMeta(null);
|
||||
}, [identityKey]);
|
||||
|
||||
const showWalletName = !isUnsetNodeName(name);
|
||||
const apiMoniker = statusMeta?.displayMoniker?.trim();
|
||||
const showApiMoniker = !showWalletName && Boolean(apiMoniker);
|
||||
const locationChip = statusMeta?.locationLabel;
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<NymCard
|
||||
borderless
|
||||
title={
|
||||
<Stack gap={3}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Stack gap={2}>
|
||||
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
|
||||
<Typography variant="h5" fontWeight={600}>
|
||||
Nym node
|
||||
</Typography>
|
||||
{locationChip ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||
{locationChip}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
{name?.includes(textWhenNotName) ? null : (
|
||||
{showWalletName ? (
|
||||
<Typography fontWeight="regular" variant="h6" width="fit-content">
|
||||
{name}
|
||||
</Typography>
|
||||
)}
|
||||
) : null}
|
||||
{showApiMoniker ? (
|
||||
<Typography fontWeight="regular" variant="h6" width="fit-content" color="text.primary">
|
||||
{apiMoniker}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Tooltip title={host} placement="top" arrow>
|
||||
<Box width="fit-content">
|
||||
<IdentityKey identityKey={identityKey} />
|
||||
@@ -190,17 +228,26 @@ export const BondedNymNode = ({
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Stack spacing={3} sx={{ width: '100%', minWidth: 0 }}>
|
||||
<Box sx={{ width: '100%', minWidth: 0 }}>
|
||||
<NodeTable headers={headers} cells={cells} />
|
||||
{network && (
|
||||
{network ? (
|
||||
<Typography sx={{ mt: 2, fontSize: 'small' }}>
|
||||
Check more stats of your node on the{' '}
|
||||
<Link href={`${urls(network).networkExplorer}/nodes/${nodeId}`} target="_blank">
|
||||
explorer
|
||||
</Link>
|
||||
</Typography>
|
||||
)}
|
||||
) : null}
|
||||
</Box>
|
||||
<NodeOperatorInsights
|
||||
network={network}
|
||||
identityKey={identityKey}
|
||||
walletUptime={uptime}
|
||||
onStatusLoaded={handleStatusLoaded}
|
||||
/>
|
||||
</Stack>
|
||||
</NymCard>
|
||||
{/* <NodeStats bondedNode={nymnode} /> */}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ExpandMore } from '@mui/icons-material';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Collapse,
|
||||
Divider,
|
||||
Grid,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { alpha, useTheme, type Theme } from '@mui/material/styles';
|
||||
import type { Network } from 'src/types';
|
||||
import {
|
||||
fetchGatewayStatusIfBonded,
|
||||
findExplorerNymNodeByIdentity,
|
||||
getNodeStatusBaseUrl,
|
||||
normalizeExplorerUptimePercent,
|
||||
type ExplorerNymNodeRow,
|
||||
type GatewayStatusPayload,
|
||||
} from 'src/api/nodeStatus';
|
||||
import { probeGroupsForChart, socks5LatencyMs, wgComparisonBars, type ProbeGroupBar } from 'src/api/nodeStatusCharts';
|
||||
|
||||
const OPERATOR_INSIGHTS_EXPANDED_KEY = 'nymWallet.bonding.operatorInsightsExpanded';
|
||||
|
||||
function readOperatorInsightsExpanded(): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(OPERATOR_INSIGHTS_EXPANDED_KEY);
|
||||
if (v === null) {
|
||||
return true;
|
||||
}
|
||||
return v === 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
type InsightsCollapseFrameProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const InsightsCollapseFrame = ({ title, subtitle, children }: InsightsCollapseFrameProps) => {
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(readOperatorInsightsExpanded);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpen((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(OPERATOR_INSIGHTS_EXPANDED_KEY, String(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', mt: { xs: 1, md: 0 } }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
gap: 1,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.primary.main}`,
|
||||
outlineOffset: 2,
|
||||
borderRadius: 1,
|
||||
},
|
||||
}}
|
||||
onClick={toggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="h6" component="h2" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={open ? 'Collapse operator insights' : 'Expand operator insights'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
}}
|
||||
>
|
||||
<ExpandMore
|
||||
sx={{
|
||||
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: theme.transitions.create('transform', { duration: theme.transitions.duration.shortest }),
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Collapse in={open}>
|
||||
<Box sx={{ pt: 2.5 }}>{children}</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ACCENT = '#8482FD';
|
||||
|
||||
export type NodeStatusMetadata = {
|
||||
displayMoniker?: string;
|
||||
locationLabel?: string;
|
||||
};
|
||||
|
||||
export type NodeOperatorInsightsProps = {
|
||||
network?: Network;
|
||||
identityKey: string;
|
||||
walletUptime?: number;
|
||||
onStatusLoaded?: (meta: NodeStatusMetadata) => void;
|
||||
};
|
||||
|
||||
function chartCardSx(theme: Theme) {
|
||||
return {
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
bgcolor:
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.nym.nymWallet.nav.background
|
||||
: theme.palette.nym.nymWallet.background.subtle,
|
||||
p: 2,
|
||||
height: '100%',
|
||||
} as const;
|
||||
}
|
||||
|
||||
/** Softer digest surface for gateway (single panel, less contrast than nested cards). */
|
||||
function gatewayDigestSx(theme: Theme) {
|
||||
return {
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.85)}`,
|
||||
background:
|
||||
theme.palette.mode === 'dark'
|
||||
? `linear-gradient(145deg, ${alpha(theme.palette.nym.nymWallet.nav.background, 0.97)} 0%, ${alpha(
|
||||
ACCENT,
|
||||
0.04,
|
||||
)} 100%)`
|
||||
: theme.palette.nym.nymWallet.background.subtle,
|
||||
p: { xs: 2, sm: 2.5 },
|
||||
overflow: 'hidden',
|
||||
} as const;
|
||||
}
|
||||
|
||||
const GatewayProbeRow = ({ theme, row, isLast }: { theme: Theme; row: ProbeGroupBar; isLast: boolean }) => {
|
||||
const { name, pctPassed, passed, total } = row;
|
||||
let barColor: string;
|
||||
if (pctPassed >= 100) {
|
||||
barColor = ACCENT;
|
||||
} else if (pctPassed > 0) {
|
||||
barColor = alpha(ACCENT, 0.65);
|
||||
} else {
|
||||
barColor = alpha(theme.palette.text.disabled, 0.35);
|
||||
}
|
||||
return (
|
||||
<Tooltip title={`${passed} of ${total} checks passed`} arrow placement="top" enterDelay={400}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1.5}
|
||||
sx={{
|
||||
py: 1,
|
||||
borderBottom: isLast ? 'none' : `1px solid ${alpha(theme.palette.divider, 0.5)}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ minWidth: 76, color: 'text.secondary', fontWeight: 500 }}>
|
||||
{name}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pctPassed}
|
||||
sx={{
|
||||
height: 5,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: alpha(ACCENT, theme.palette.mode === 'dark' ? 0.1 : 0.14),
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 2.5,
|
||||
bgcolor: barColor,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
minWidth: 56,
|
||||
textAlign: 'right',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{pctPassed}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const NodeOperatorInsights: React.FC<NodeOperatorInsightsProps> = ({
|
||||
network,
|
||||
identityKey,
|
||||
walletUptime,
|
||||
onStatusLoaded,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [gateway, setGateway] = useState<GatewayStatusPayload | undefined>();
|
||||
const [explorerRow, setExplorerRow] = useState<ExplorerNymNodeRow | undefined>();
|
||||
const [explorerMissing, setExplorerMissing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const baseUrl = getNodeStatusBaseUrl(network);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
setExplorerMissing(false);
|
||||
try {
|
||||
const g = await fetchGatewayStatusIfBonded(baseUrl, identityKey);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (g) {
|
||||
setGateway(g);
|
||||
setExplorerRow(undefined);
|
||||
const loc = g.explorer_pretty_bond?.location;
|
||||
const locationLabel =
|
||||
loc && (loc.city || loc.two_letter_iso_country_code)
|
||||
? [loc.city, loc.two_letter_iso_country_code].filter(Boolean).join(', ')
|
||||
: undefined;
|
||||
onStatusLoaded?.({
|
||||
displayMoniker: g.description?.moniker,
|
||||
locationLabel,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setGateway(undefined);
|
||||
const row = await findExplorerNymNodeByIdentity(network, baseUrl, identityKey);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (row) {
|
||||
setExplorerRow(row);
|
||||
setExplorerMissing(false);
|
||||
const geo = row.geoip;
|
||||
const locationLabel =
|
||||
geo && (geo.city || geo.country) ? [geo.city, geo.country].filter(Boolean).join(', ') : undefined;
|
||||
onStatusLoaded?.({
|
||||
displayMoniker: row.description?.moniker,
|
||||
locationLabel,
|
||||
});
|
||||
} else {
|
||||
setExplorerRow(undefined);
|
||||
setExplorerMissing(true);
|
||||
onStatusLoaded?.({});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load node status');
|
||||
setGateway(undefined);
|
||||
setExplorerRow(undefined);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
run().catch(() => {
|
||||
/* errors surfaced via setError inside run */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [network, identityKey, onStatusLoaded]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack spacing={2} sx={{ width: '100%' }}>
|
||||
<Skeleton variant="rounded" height={120} sx={{ borderRadius: 2 }} />
|
||||
<Skeleton variant="rounded" height={180} sx={{ borderRadius: 2 }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (gateway) {
|
||||
const outcome = gateway.last_probe_result?.outcome;
|
||||
const probeGroups = probeGroupsForChart(outcome);
|
||||
const wgBars = wgComparisonBars(outcome);
|
||||
const latency = socks5LatencyMs(outcome);
|
||||
const perf = Math.min(100, Math.max(0, gateway.performance));
|
||||
const wgKind = wgBars[0]?.kind;
|
||||
const socksGroup = probeGroups.find((g) => g.name === 'SOCKS5');
|
||||
|
||||
return (
|
||||
<InsightsCollapseFrame
|
||||
title="Operator insights"
|
||||
subtitle="Quiet snapshot of how the network last probed this gateway. Expand for detail."
|
||||
>
|
||||
<Box sx={gatewayDigestSx(theme)}>
|
||||
<Grid container spacing={2.5} alignItems="flex-start">
|
||||
<Grid item xs={12} md={4}>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', mb: 0.75 }}
|
||||
>
|
||||
Performance
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: ACCENT,
|
||||
lineHeight: 1.1,
|
||||
fontSize: { xs: '1.65rem', sm: '1.9rem' },
|
||||
}}
|
||||
>
|
||||
{perf}%
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 0.75, lineHeight: 1.45 }}
|
||||
>
|
||||
Reward-weight score (0-100) from the status API.
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={perf}
|
||||
sx={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
mt: 1.25,
|
||||
bgcolor: alpha(ACCENT, 0.14),
|
||||
'& .MuiLinearProgress-bar': { bgcolor: ACCENT, borderRadius: 2 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Divider flexItem sx={{ borderColor: alpha(theme.palette.divider, 0.55) }} />
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
Routing score
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 0.35, fontWeight: 500 }}>
|
||||
{gateway.routing_score > 0 ? gateway.routing_score : 'Not published'}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 0.75, lineHeight: 1.45 }}
|
||||
>
|
||||
Distinct from performance. Often 0 until the network publishes it.
|
||||
</Typography>
|
||||
</Box>
|
||||
{gateway.last_testrun_utc ? (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
Last probe: {gateway.last_testrun_utc}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', mb: 0.5 }}
|
||||
>
|
||||
Categories
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.25, lineHeight: 1.5 }}>
|
||||
Hover a row for pass counts.
|
||||
</Typography>
|
||||
{probeGroups.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No probe data yet.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box>
|
||||
{probeGroups.map((row, i) => (
|
||||
<GatewayProbeRow key={row.name} theme={theme} row={row} isLast={i === probeGroups.length - 1} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{socksGroup !== undefined && socksGroup.pctPassed < 100 ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 1.25, lineHeight: 1.5 }}
|
||||
>
|
||||
SOCKS5 / NR did not fully pass this run - optional path, depends on topology.
|
||||
</Typography>
|
||||
) : null}
|
||||
{latency !== undefined ? (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.75 }}>
|
||||
SOCKS5 HTTPS latency: {latency} ms
|
||||
</Typography>
|
||||
) : null}
|
||||
</Grid>
|
||||
{wgBars.length > 0 ? (
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ borderColor: alpha(theme.palette.divider, 0.55), my: 0.25 }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', mb: 0.75 }}
|
||||
>
|
||||
WireGuard
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.25, lineHeight: 1.5 }}>
|
||||
{wgKind === 'milliseconds'
|
||||
? 'Probe download time over the tunnel (ms). Lower reads faster.'
|
||||
: 'ICMP success rate through the tunnel. Higher is better.'}
|
||||
</Typography>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
{wgBars.map((b) => (
|
||||
<Box
|
||||
key={b.name}
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: 1.5,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.035),
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.4)}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{b.name}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mt: 0.35, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{b.kind === 'milliseconds' ? `${b.value} ms` : `${b.value}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Grid>
|
||||
) : null}
|
||||
</Grid>
|
||||
</Box>
|
||||
</InsightsCollapseFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (explorerRow) {
|
||||
const apiUptime = normalizeExplorerUptimePercent(explorerRow.uptime);
|
||||
const epochUptimePct =
|
||||
walletUptime !== undefined && walletUptime !== null ? normalizeExplorerUptimePercent(walletUptime) : undefined;
|
||||
|
||||
return (
|
||||
<InsightsCollapseFrame
|
||||
title="Operator insights"
|
||||
subtitle="Uptime from the explorer index versus epoch uptime from the chain. Stake, delegators, and saturation stay in the table above."
|
||||
>
|
||||
<Box sx={chartCardSx(theme)}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="text.secondary" fontWeight={600}>
|
||||
Explorer uptime
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 0.5, mb: 1.25, lineHeight: 1.5 }}
|
||||
>
|
||||
Public index value. The API may send a 0-1 ratio (for example 0.98) - shown here as 0-100%.
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={apiUptime}
|
||||
sx={{
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
bgcolor: alpha(ACCENT, 0.2),
|
||||
'& .MuiLinearProgress-bar': { bgcolor: ACCENT, borderRadius: 5 },
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mt: 0.75 }}>
|
||||
{apiUptime.toFixed(1)}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="text.secondary" fontWeight={600}>
|
||||
Epoch uptime (wallet)
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 0.5, mb: 1.25, lineHeight: 1.5 }}
|
||||
>
|
||||
Estimate from your wallet RPC for the current epoch (same scale as the explorer bar).
|
||||
</Typography>
|
||||
{epochUptimePct !== undefined ? (
|
||||
<>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={epochUptimePct}
|
||||
sx={{
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
mt: 0,
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.15),
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mt: 0.75 }}>
|
||||
{epochUptimePct.toFixed(1)}%
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Not available from the wallet for this node.
|
||||
</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</InsightsCollapseFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (explorerMissing) {
|
||||
return (
|
||||
<InsightsCollapseFrame
|
||||
title="Operator insights"
|
||||
subtitle="Explorer index for your node (identity match). Cached after the first full scan."
|
||||
>
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
Your node is not in the explorer listing yet, or the identity does not match the index. New bonds can take
|
||||
time to appear. Insights will load automatically once indexed.
|
||||
</Alert>
|
||||
</InsightsCollapseFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -22,7 +22,7 @@ export interface TableProps {
|
||||
}
|
||||
|
||||
export const NodeTable = ({ headers, cells }: TableProps) => (
|
||||
<TableContainer>
|
||||
<TableContainer sx={{ overflowX: 'auto', width: '100%' }}>
|
||||
<Table aria-label="node-table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Stack, Typography, SxProps, FormControlLabel, Checkbox, Alert } from '@mui/material';
|
||||
import { alpha, useTheme } from '@mui/material/styles';
|
||||
import Big from 'big.js';
|
||||
@@ -86,14 +86,20 @@ export const SendInputModal = ({
|
||||
const [errorAmount, setErrorAmount] = useState<string | undefined>();
|
||||
const [errorFee, setErrorFee] = useState<string | undefined>();
|
||||
const [recipientTouched, setRecipientTouched] = useState(false);
|
||||
/** Avoid "Amount is required" on modal open; show empty errors only after user edits the field. */
|
||||
const [amountTouched, setAmountTouched] = useState(false);
|
||||
/** Focus trap can fire a spurious blur on open; delay recipient blur validation slightly. */
|
||||
const [recipientBlurReady, setRecipientBlurReady] = useState(false);
|
||||
const theme = useTheme();
|
||||
|
||||
// Calculate noAccount at the component root level instead of using useEffect
|
||||
const noAccount = !balance || balance === '0' || parseFloat(balance) === 0;
|
||||
|
||||
const validateSendAmount = async (value: DecCoin) => {
|
||||
const validateSendAmount = useCallback(
|
||||
async (value: DecCoin, assumeAmountInteracted = false) => {
|
||||
let newValidatedValue = true;
|
||||
let errorAmountMessage;
|
||||
const showEmptyFieldErrors = assumeAmountInteracted || amountTouched;
|
||||
|
||||
if (noAccount) {
|
||||
newValidatedValue = false;
|
||||
@@ -105,7 +111,7 @@ export const SendInputModal = ({
|
||||
|
||||
if (!value?.amount || String(value.amount).trim() === '') {
|
||||
newValidatedValue = false;
|
||||
errorAmountMessage = 'Amount is required';
|
||||
errorAmountMessage = showEmptyFieldErrors ? 'Amount is required' : undefined;
|
||||
} else {
|
||||
// Skip validation for partial decimal inputs during typing
|
||||
if (value.amount === '.' || value.amount.endsWith('.')) {
|
||||
@@ -142,9 +148,12 @@ export const SendInputModal = ({
|
||||
setIsValid(newValidatedValue);
|
||||
setErrorAmount(errorAmountMessage);
|
||||
return newValidatedValue;
|
||||
};
|
||||
},
|
||||
[amountTouched, noAccount, balance, denom],
|
||||
);
|
||||
|
||||
const validateUserFees = (fees: DecCoin) => {
|
||||
const validateUserFees = useCallback(
|
||||
(fees: DecCoin) => {
|
||||
let feeValid = true;
|
||||
let errorFeeMessage;
|
||||
|
||||
@@ -187,12 +196,21 @@ export const SendInputModal = ({
|
||||
setFeeAmountIsValid(feeValid);
|
||||
setErrorFee(errorFeeMessage);
|
||||
return feeValid;
|
||||
};
|
||||
},
|
||||
[noAccount, denom],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setRecipientBlurReady(true), 400);
|
||||
return () => window.clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const empty: DecCoin = { amount: '', denom: denom ?? 'nym' };
|
||||
validateSendAmount(amount ?? empty);
|
||||
}, [amount, balance, noAccount, denom]);
|
||||
validateSendAmount(amount ?? empty).catch(() => {
|
||||
/* validateSendAmount only updates state */
|
||||
});
|
||||
}, [amount, denom, validateSendAmount]);
|
||||
|
||||
// Effect to validate address whenever it changes
|
||||
useEffect(() => {
|
||||
@@ -213,7 +231,7 @@ export const SendInputModal = ({
|
||||
} else {
|
||||
setFeeAmountIsValid(true);
|
||||
}
|
||||
}, [userFees, noAccount]);
|
||||
}, [userFees, validateUserFees]);
|
||||
|
||||
return (
|
||||
<SimpleModal
|
||||
@@ -302,13 +320,23 @@ export const SendInputModal = ({
|
||||
<TextFieldWithPaste
|
||||
label="Recipient address"
|
||||
fullWidth
|
||||
onChange={(e) => onAddressChange(e.target.value)}
|
||||
onBlur={() => setRecipientTouched(true)}
|
||||
onChange={(e) => {
|
||||
setRecipientTouched(true);
|
||||
onAddressChange(e.target.value);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (recipientBlurReady) {
|
||||
setRecipientTouched(true);
|
||||
}
|
||||
}}
|
||||
value={toAddress}
|
||||
error={(recipientTouched && !toAddress.trim()) || (toAddress !== '' && !addressIsValid)}
|
||||
helperText={recipientHelperText(recipientTouched, toAddress, addressIsValid)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
onPasteValue={onAddressChange}
|
||||
onPasteValue={(v) => {
|
||||
setRecipientTouched(true);
|
||||
onAddressChange(v);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Amount field with paste button */}
|
||||
@@ -316,8 +344,11 @@ export const SendInputModal = ({
|
||||
label="Amount"
|
||||
fullWidth
|
||||
onChanged={(value) => {
|
||||
setAmountTouched(true);
|
||||
onAmountChange(value);
|
||||
validateSendAmount(value);
|
||||
validateSendAmount(value, true).catch(() => {
|
||||
/* validateSendAmount only updates state */
|
||||
});
|
||||
}}
|
||||
initialValue={amount?.amount}
|
||||
denom={denom}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { NymWordmark } from '@nymproject/react/logo/NymWordmark';
|
||||
import { Stack, Box, Typography } from '@mui/material';
|
||||
import { Stack, Box } from '@mui/material';
|
||||
import { alpha, useTheme } from '@mui/material/styles';
|
||||
import { AppContext } from 'src/context';
|
||||
import { AppSessionLoadingOverlay, LoadingPage } from 'src/components';
|
||||
@@ -24,63 +24,16 @@ export const AuthLayout: FCWithChildren = ({ children }) => {
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
// Below 2000px: single column (sign-in card centered). At 2000px+: optional left strip of static marketing copy (not navigation).
|
||||
gridTemplateColumns: '1fr',
|
||||
'@media (min-width: 2000px)': {
|
||||
gridTemplateColumns: 'minmax(320px, 420px) minmax(0, 1fr)',
|
||||
},
|
||||
alignItems: 'stretch',
|
||||
bgcolor: 'background.default',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: { xs: 'none' },
|
||||
'@media (min-width: 2000px)': {
|
||||
display: 'flex',
|
||||
},
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
px: 6,
|
||||
py: 7,
|
||||
borderRight: (t) => `1px solid ${t.palette.divider}`,
|
||||
background: isDark ? alpha(theme.palette.common.black, 0.2) : theme.palette.nym.nymWallet.background.subtle,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<NymWordmark width={88} fill={wordmarkFill} />
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="overline" sx={{ color: 'text.secondary', letterSpacing: 1.4 }}>
|
||||
Secure wallet access
|
||||
</Typography>
|
||||
<Typography variant="h3" sx={{ maxWidth: 320, lineHeight: 1.1, color: 'text.primary' }}>
|
||||
Access tokens, staking, and node operations in one place.
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'text.secondary', maxWidth: 340 }}>
|
||||
The wallet keeps the same trusted Nym visual language while making key tasks easier to scan and complete.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Step />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
px: { xs: 2, md: 4 },
|
||||
py: { xs: 4, md: 6 },
|
||||
bgcolor: 'background.default',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3} alignItems="center" sx={{ width: '100%', maxWidth: 1080 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'block',
|
||||
'@media (min-width: 2000px)': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'block' }}>
|
||||
<NymWordmark width={75} fill={wordmarkFill} />
|
||||
</Box>
|
||||
<Box
|
||||
@@ -103,6 +56,5 @@ export const AuthLayout: FCWithChildren = ({ children }) => {
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -201,7 +201,7 @@ export const Bonding = () => {
|
||||
return (
|
||||
<ErrorModal
|
||||
open
|
||||
title="An error occured, please check logs for details"
|
||||
title="An error occurred, please check logs for details"
|
||||
message={error}
|
||||
onClose={() => refresh()}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import { CssBaseline, PaletteMode } from '@mui/material';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import { getDesignTokens } from './theme';
|
||||
import { muiEmotionCache } from './emotionCache';
|
||||
import '@assets/fonts/non-variable/fonts.css';
|
||||
|
||||
let fontsInitialized = false;
|
||||
@@ -43,10 +45,12 @@ export const NymWalletThemeWithMode: FCWithChildren<{ mode: PaletteMode; childre
|
||||
const theme = useMemo(() => createTheme(getDesignTokens(mode)), [mode]);
|
||||
|
||||
return (
|
||||
<CacheProvider value={muiEmotionCache}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<FontLoader />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</CacheProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import createCache from '@emotion/cache';
|
||||
|
||||
/**
|
||||
* WKWebView (Tauri) can silently fail Emotion's production `insertRule` path (`speedy: true`),
|
||||
* which shows up as React/MUI rendering with almost no CSS while scripts run.
|
||||
* `speedy: false` uses `<style>` node insertion, which is reliable here.
|
||||
*/
|
||||
export const muiEmotionCache = createCache({
|
||||
key: 'mui',
|
||||
prepend: true,
|
||||
speedy: false,
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import { getDesignTokens } from './theme';
|
||||
import { AppContext } from '../context/main';
|
||||
import { NymWalletThemeWithMode } from './NymWalletTheme';
|
||||
import { muiEmotionCache } from './emotionCache';
|
||||
|
||||
/**
|
||||
* Provides the theme for the Network Explorer by reacting to the light/dark mode choice stored in the app context.
|
||||
@@ -20,9 +22,11 @@ export const AuthTheme: FCWithChildren = ({ children }) => {
|
||||
// Uses dark mode by default for auth screens
|
||||
const theme = createTheme(getDesignTokens('dark'));
|
||||
return (
|
||||
<CacheProvider value={muiEmotionCache}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</CacheProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
},
|
||||
"include": [
|
||||
"webpack.*.js",
|
||||
"scripts/**/*.js",
|
||||
"src/**/*.js",
|
||||
"src/**/*.jsx",
|
||||
"src/**/*.ts",
|
||||
|
||||
@@ -4,12 +4,14 @@ const { webpackCommon } = require('@nymproject/webpack');
|
||||
|
||||
const resolveFromWallet = (request) => require.resolve(request, { paths: [__dirname] });
|
||||
|
||||
/** Package root (folder), not main entry - required so `@mui/material/Button` style subpath imports resolve. */
|
||||
const resolveMuiPackageRoot = (pkg) =>
|
||||
path.dirname(require.resolve(`${pkg}/package.json`, { paths: [__dirname, path.resolve(__dirname, '..')] }));
|
||||
|
||||
const muiSystemDir = path.dirname(
|
||||
require.resolve('@mui/system/package.json', { paths: [__dirname, path.resolve(__dirname, '..')] }),
|
||||
);
|
||||
const muiStyledEngineV5 = path.dirname(
|
||||
require.resolve('@mui/styled-engine/package.json', { paths: [muiSystemDir] }),
|
||||
);
|
||||
const muiStyledEngineV5 = path.dirname(require.resolve('@mui/styled-engine/package.json', { paths: [muiSystemDir] }));
|
||||
|
||||
const entry = {
|
||||
auth: path.resolve(__dirname, 'src/auth.tsx'), // JS bundle for sign up/sign in
|
||||
@@ -36,7 +38,19 @@ module.exports = mergeWithRules({
|
||||
// Yarn workspaces hoist deps to ../node_modules; resolve Tauri packages from there too.
|
||||
modules: [path.resolve(__dirname, 'node_modules'), path.resolve(__dirname, '../node_modules')],
|
||||
alias: {
|
||||
// Single Emotion instance so CacheProvider matches MUI's styled engine (workspaces can duplicate).
|
||||
'@emotion/react': resolveFromWallet('@emotion/react'),
|
||||
'@emotion/styled': resolveFromWallet('@emotion/styled'),
|
||||
'@emotion/cache': resolveFromWallet('@emotion/cache'),
|
||||
'@mui/styled-engine': muiStyledEngineV5,
|
||||
'@mui/material': resolveMuiPackageRoot('@mui/material'),
|
||||
'@mui/system': resolveMuiPackageRoot('@mui/system'),
|
||||
'@mui/private-theming': resolveMuiPackageRoot('@mui/private-theming'),
|
||||
'@mui/utils': resolveMuiPackageRoot('@mui/utils'),
|
||||
'@mui/base': resolveMuiPackageRoot('@mui/base'),
|
||||
'@mui/styles': resolveMuiPackageRoot('@mui/styles'),
|
||||
'@mui/icons-material': resolveMuiPackageRoot('@mui/icons-material'),
|
||||
'@mui/lab': resolveMuiPackageRoot('@mui/lab'),
|
||||
react$: resolveFromWallet('react'),
|
||||
'react-dom$': resolveFromWallet('react-dom'),
|
||||
'react-dom/client': resolveFromWallet('react-dom/client'),
|
||||
|
||||
@@ -3,12 +3,33 @@ const common = require('./webpack.common');
|
||||
|
||||
module.exports = merge(common, {
|
||||
mode: 'production',
|
||||
// Tauri + WKWebView: `publicPath: 'auto'` resolves `__webpack_require__.p` from `document.currentScript`,
|
||||
// which is unreliable here and can 404 async chunks so MUI/Emotion never runs (unstyled UI).
|
||||
// Relative `./` matches `dist/*.html` + sibling chunk files under the same custom HTTPS origin.
|
||||
output: {
|
||||
publicPath: './',
|
||||
},
|
||||
node: {
|
||||
__dirname: false,
|
||||
},
|
||||
optimization: {
|
||||
runtimeChunk: 'single',
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
cacheGroups: {
|
||||
framework: {
|
||||
name: 'framework',
|
||||
test: /[\\/]node_modules[\\/](react|react-dom|scheduler|@emotion[\\/](react|styled|cache|sheet|serialize|utils|hash|memoize|weak-memoize|use-insertion-effect-with-fallbacks)|@mui)[\\/]/,
|
||||
priority: 40,
|
||||
enforce: true,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
vendors: {
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
priority: 10,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user