mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(p2p stats): improve own-IP geolocation and world map accuracy (#1138)
* fix(p2p stats): improve own-IP geolocation and world map accuracy Resolve the user's public endpoint when libp2p only advertises private listen addresses, look up an accurate country flag for "Your IP", snap peer markers to country centroids, and add leeching seeder link plus panel layout tweaks. * fix(p2p stats): skip caching own-IP lookups cancelled by abort When the P2P stats panel unmounts mid-request, its AbortSignal cancels the in-flight fetchOwnPublicEndpoint / fetchOwnIpCountryCode calls. Those empty results were still cached for 30-60s, so reopening the panel within that window showed "Your IP" as unavailable or without a country flag even though nothing had actually failed. Skip caching when the signal aborted so a later open retries. Addresses Cursor Bugbot finding.
This commit is contained in:
+81
-4
@@ -60,6 +60,15 @@ describe('P2PStatsSettings', () => {
|
||||
};
|
||||
testState.rpcSettings = { state: 'disconnected' };
|
||||
testState.setAccountMock.mockReset().mockResolvedValue(undefined);
|
||||
// Default: own-IP country lookups (api.country.is) resolve offline so browser
|
||||
// stats tests never hit the network. Individual tests can override this stub.
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ country: 'US', ip: '147.75.84.175' }),
|
||||
}),
|
||||
);
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -68,6 +77,7 @@ describe('P2PStatsSettings', () => {
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders browser libp2p stats from the active PKC client', async () => {
|
||||
@@ -97,7 +107,7 @@ describe('P2PStatsSettings', () => {
|
||||
status: 'open',
|
||||
},
|
||||
],
|
||||
getMultiaddrs: () => ['/ip4/127.0.0.1/tcp/4001'],
|
||||
getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'],
|
||||
getPeers: () => ['peer-1', 'peer-2'],
|
||||
metrics: {
|
||||
toJSON: () => ({
|
||||
@@ -134,6 +144,15 @@ describe('P2PStatsSettings', () => {
|
||||
const rows = getStatRows();
|
||||
const connectedPeers = container.querySelector('[data-testid="connected-peers"]');
|
||||
expect(container.textContent).toContain('Leeching');
|
||||
expect(container.textContent).toContain('want to seed');
|
||||
const seederLink = container.querySelector('a[href="https://github.com/bitsocialnet/bitsocial-seeder"]');
|
||||
expect(seederLink).not.toBeNull();
|
||||
expect(seederLink?.textContent).toBe('want to seed?');
|
||||
expect(rows.get('Your IP')).toContain('147.75.84.175');
|
||||
// The own IP is geolocated accurately (per-IP lookup), not via the coarse peer guess.
|
||||
expect(fetch).toHaveBeenCalledWith('https://api.country.is/147.75.84.175', expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
const yourIpRow = Array.from(container.querySelectorAll('tr')).find((row) => row.textContent?.includes('Your IP'));
|
||||
expect(yourIpRow?.querySelector('[role="img"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain('browser Helia');
|
||||
expect(container.textContent).not.toContain('seed mode');
|
||||
expect(container.textContent).not.toContain('status');
|
||||
@@ -143,6 +162,14 @@ describe('P2PStatsSettings', () => {
|
||||
expect(rows.has('connections')).toBe(false);
|
||||
expect(rows.has('Listen addresses')).toBe(false);
|
||||
expect(rows.has('p2p_stats_updated')).toBe(true);
|
||||
const tableRows = Array.from(container.querySelectorAll('tr'));
|
||||
const rowTexts = tableRows.map((row) => row.textContent ?? '');
|
||||
const dataSentIndex = rowTexts.findIndex((text) => text.includes('Data sent'));
|
||||
const updatedIndex = rowTexts.findIndex((text) => text.includes('p2p_stats_updated'));
|
||||
const connectedPeersIndex = rowTexts.findIndex((text) => text.includes('Connected peers'));
|
||||
expect(dataSentIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(updatedIndex).toBeGreaterThan(dataSentIndex);
|
||||
expect(connectedPeersIndex).toBeGreaterThan(updatedIndex);
|
||||
expect(container.textContent).toContain('self-peer');
|
||||
expect(container.textContent).toContain('Peer ID');
|
||||
expect(container.textContent).toContain('Data received');
|
||||
@@ -183,7 +210,7 @@ describe('P2PStatsSettings', () => {
|
||||
},
|
||||
libp2p: {
|
||||
getConnections: () => [],
|
||||
getMultiaddrs: () => [],
|
||||
getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'],
|
||||
getPeers: () => [],
|
||||
peerId: { toString: () => 'self-peer' },
|
||||
},
|
||||
@@ -217,7 +244,7 @@ describe('P2PStatsSettings', () => {
|
||||
_helia: {
|
||||
libp2p: {
|
||||
getConnections: () => [],
|
||||
getMultiaddrs: () => [],
|
||||
getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'],
|
||||
getPeers: () => [],
|
||||
peerId: { toString: () => 'self-peer' },
|
||||
},
|
||||
@@ -236,6 +263,55 @@ describe('P2PStatsSettings', () => {
|
||||
expect(rows.get('Data sent')).toBe('0 B');
|
||||
});
|
||||
|
||||
it('falls back to the browser node public endpoint when Helia exposes no public address', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ country: 'US', ip: '2001:4860:4860::8888' }),
|
||||
}),
|
||||
);
|
||||
testState.account = {
|
||||
...testState.account,
|
||||
pkcOptions: {
|
||||
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
|
||||
},
|
||||
pkc: {
|
||||
clients: {
|
||||
libp2pJsClients: {
|
||||
libp2pjs: {
|
||||
key: 'libp2pjs',
|
||||
_helia: {
|
||||
libp2p: {
|
||||
getConnections: () => [
|
||||
{
|
||||
localAddr: { toString: () => '/ip4/127.0.0.1/tcp/4001/ws' },
|
||||
remoteAddr: { toString: () => '/ip4/127.0.0.1/tcp/4001/ws/p2p/peer-1' },
|
||||
remotePeer: { toString: () => 'peer-1' },
|
||||
},
|
||||
],
|
||||
getMultiaddrs: () => [],
|
||||
getPeers: () => [],
|
||||
peerId: { toString: () => 'self-peer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await renderSettings(false);
|
||||
await act(async () => Promise.resolve());
|
||||
|
||||
const rows = getStatRows();
|
||||
expect(rows.get('Your IP')).toContain('2001:4860:4860::8888');
|
||||
expect(rows.get('Your IP')).not.toContain('unknown');
|
||||
const yourIpRow = Array.from(container.querySelectorAll('tr')).find((row) => row.textContent?.includes('Your IP'));
|
||||
expect(yourIpRow?.querySelector('[role="img"]')).not.toBeNull();
|
||||
expect(fetch).toHaveBeenCalledWith('https://api.country.is', expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
it('reports seeding only when browser Helia can add and publish provider records', async () => {
|
||||
testState.account = {
|
||||
...testState.account,
|
||||
@@ -253,7 +329,7 @@ describe('P2PStatsSettings', () => {
|
||||
_helia: {
|
||||
libp2p: {
|
||||
getConnections: () => [],
|
||||
getMultiaddrs: () => [],
|
||||
getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'],
|
||||
getPeers: () => [],
|
||||
peerId: { toString: () => 'self-peer' },
|
||||
},
|
||||
@@ -275,6 +351,7 @@ describe('P2PStatsSettings', () => {
|
||||
await act(async () => Promise.resolve());
|
||||
|
||||
expect(container.textContent).toContain('Seeding');
|
||||
expect(container.querySelector('a[href="https://github.com/bitsocialnet/bitsocial-seeder"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain('seed mode');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import PeerWorldMap from '../peer-world-map';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void) => void }).act as (cb: () => void) => void;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const render = (element: React.ReactElement) => act(() => root.render(element));
|
||||
|
||||
describe('PeerWorldMap', () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders the rasterized land backdrop and a marker for a placeable peer', () => {
|
||||
render(createElement(PeerWorldMap, { peers: [{ address: '/ip4/8.8.8.8/tcp/4001', id: 'c1', peerId: 'peer-1' }] }));
|
||||
const landPath = container.querySelector('svg path');
|
||||
expect(landPath).not.toBeNull();
|
||||
// The land mask decodes into a large multi-square path, not a handful of points.
|
||||
expect((landPath?.getAttribute('d') ?? '').length).toBeGreaterThan(1000);
|
||||
expect(container.querySelectorAll('svg rect')).toHaveLength(1);
|
||||
expect(container.querySelector('svg rect title')?.textContent).toBe('peer-1');
|
||||
});
|
||||
|
||||
it('renders nothing when no peer can be placed offline', () => {
|
||||
render(createElement(PeerWorldMap, { peers: [{ address: '/ip4/10.0.0.1/tcp/4001', id: 'c1', peerId: 'peer-1' }] }));
|
||||
expect(container.querySelector('svg')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,17 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.statValue a {
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-content-link-text-decoration);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.statValue a:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
text-decoration: var(--post-content-link-text-decoration-hover);
|
||||
}
|
||||
|
||||
/* Connected peers span the full panel width instead of the narrow value column. */
|
||||
.stats td.connectedPeersCell {
|
||||
padding: 4px 4px 5px 0;
|
||||
@@ -120,11 +131,23 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodeEndpoint {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodeIp {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.peerFlag {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 11px;
|
||||
background-image: url("/assets/icons/flags-1.png");
|
||||
background-image: url('/assets/icons/flags-1.png');
|
||||
background-repeat: no-repeat;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
@@ -159,7 +182,7 @@
|
||||
|
||||
.landDot {
|
||||
fill: currentColor;
|
||||
opacity: 0.18;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.peerMarker {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { memo, useEffect, useReducer } from 'react';
|
||||
import { Fragment, memo, useEffect, useReducer } from 'react';
|
||||
import { useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getCountryFlagPosition, getCountryLabel } from '../../../lib/country-flags';
|
||||
import { getApproximateCountryCode } from '../../../lib/peer-geo';
|
||||
import { getCountryFlagPosition, getCountryLabel, normalizeCountryCode } from '../../../lib/country-flags';
|
||||
import { fetchOwnIpCountryCode, fetchOwnPublicEndpoint, getApproximateCountryCode, getFirstPublicIpFromAddresses, type PublicEndpoint } from '../../../lib/peer-geo';
|
||||
import { getP2PRuntimeMode, type P2PRuntimeMode } from '../../../lib/p2p-runtime';
|
||||
import PeerWorldMap from './peer-world-map';
|
||||
import styles from './p2p-stats-settings.module.css';
|
||||
@@ -32,7 +32,14 @@ type ConnectedPeersStatRow = {
|
||||
type: 'connectedPeers';
|
||||
};
|
||||
|
||||
type StatRow = ConnectedPeersStatRow | TextStatRow;
|
||||
type NodeEndpointStatRow = {
|
||||
countryCode?: string;
|
||||
ip: string;
|
||||
name: string;
|
||||
type: 'nodeEndpoint';
|
||||
};
|
||||
|
||||
type StatRow = ConnectedPeersStatRow | NodeEndpointStatRow | TextStatRow;
|
||||
|
||||
type StatsState = {
|
||||
error?: string;
|
||||
@@ -59,6 +66,9 @@ type StatsAction =
|
||||
type Libp2pClientShape = {
|
||||
_helia?: {
|
||||
libp2p?: {
|
||||
components?: {
|
||||
addressManager?: Libp2pAddressManagerShape;
|
||||
};
|
||||
getConnections?: () => unknown[] | Promise<unknown[]>;
|
||||
getMultiaddrs?: () => unknown[] | Promise<unknown[]>;
|
||||
getPeers?: () => unknown[] | Promise<unknown[]>;
|
||||
@@ -81,6 +91,13 @@ type Libp2pClientShape = {
|
||||
key?: string;
|
||||
};
|
||||
|
||||
type Libp2pAddressManagerShape = {
|
||||
getAddressesWithMetadata?: () => unknown[] | Promise<unknown[]>;
|
||||
getObservedAddrs?: () => unknown[] | Promise<unknown[]>;
|
||||
};
|
||||
|
||||
type BrowserLibp2pShape = NonNullable<NonNullable<NonNullable<Libp2pClientShape['_helia']>['libp2p']>>;
|
||||
|
||||
type TransferStats = {
|
||||
downloadedBytes?: number;
|
||||
uploadedBytes?: number;
|
||||
@@ -94,6 +111,7 @@ type ObservedTransferStats = {
|
||||
};
|
||||
|
||||
const KUBO_API_URL = 'http://localhost:50019/api/v0';
|
||||
const SEEDER_REPO_URL = 'https://github.com/bitsocialnet/bitsocial-seeder';
|
||||
const STATS_REFRESH_MS = 5000;
|
||||
const MAX_TRANSFER_COUNTER_DEPTH = 10;
|
||||
const MAX_TRANSFER_COUNTER_OBJECTS = 400;
|
||||
@@ -176,6 +194,21 @@ const getSafeArray = async (getValue?: () => unknown[] | Promise<unknown[]> | un
|
||||
}
|
||||
};
|
||||
|
||||
const getAddressManagerAddresses = async (libp2p?: BrowserLibp2pShape): Promise<unknown[]> => {
|
||||
const addressManager = isRecord(libp2p?.components) ? (libp2p.components.addressManager as Libp2pAddressManagerShape | undefined) : undefined;
|
||||
const [observedAddrs, addressesWithMetadata] = await Promise.all([
|
||||
getSafeArray(() => addressManager?.getObservedAddrs?.()),
|
||||
getSafeArray(() => addressManager?.getAddressesWithMetadata?.()),
|
||||
]);
|
||||
return [
|
||||
...observedAddrs,
|
||||
...addressesWithMetadata.flatMap((entry) => {
|
||||
const address = isRecord(entry) ? (entry.multiaddr ?? entry.address) : entry;
|
||||
return address ? [address] : [];
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const getByteLength = (value: unknown): number | undefined => {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value === 'string') return new TextEncoder().encode(value).byteLength;
|
||||
@@ -478,6 +511,16 @@ const getBrowserConnectedPeersRow = (peers: unknown[], connections: unknown[]):
|
||||
};
|
||||
};
|
||||
|
||||
// Resolves the "Your IP" row from the node's own observed addresses. The shown IP
|
||||
// is geolocated accurately (it is the user's own address, never a peer's) so the
|
||||
// flag matches it, instead of the coarse continent guess used for connected peers.
|
||||
// Falls back to a public-endpoint lookup when libp2p only knows local/private addresses.
|
||||
const resolveOwnEndpoint = async (addresses: unknown[], signal?: AbortSignal): Promise<PublicEndpoint | undefined> => {
|
||||
const ip = getFirstPublicIpFromAddresses(addresses);
|
||||
if (ip) return { countryCode: await fetchOwnIpCountryCode(ip, signal), ip };
|
||||
return fetchOwnPublicEndpoint(signal);
|
||||
};
|
||||
|
||||
const getElectronConnectedPeersRow = (peers: unknown): ConnectedPeersStatRow => {
|
||||
const peerEntries = isRecord(peers) && Array.isArray(peers.Peers) ? peers.Peers : [];
|
||||
const entries = peerEntries.map<ConnectedPeerEntry>((peer) => {
|
||||
@@ -506,15 +549,26 @@ const getElectronConnectedPeersRow = (peers: unknown): ConnectedPeersStatRow =>
|
||||
};
|
||||
};
|
||||
|
||||
const getBrowserLibp2pStats = async (account?: AccountShape): Promise<StatRow[]> => {
|
||||
const getBrowserLibp2pStats = async (account?: AccountShape, signal?: AbortSignal): Promise<StatRow[]> => {
|
||||
const client = getFirstObjectValue(account?.pkc?.clients?.libp2pJsClients) as Libp2pClientShape | undefined;
|
||||
const libp2p = client?._helia?.libp2p;
|
||||
const [peers, connections] = await Promise.all([getSafeArray(() => libp2p?.getPeers?.()), getSafeArray(() => libp2p?.getConnections?.())]);
|
||||
const [peers, connections, multiaddrs, addressManagerAddresses] = await Promise.all([
|
||||
getSafeArray(() => libp2p?.getPeers?.()),
|
||||
getSafeArray(() => libp2p?.getConnections?.()),
|
||||
getSafeArray(() => libp2p?.getMultiaddrs?.()),
|
||||
getAddressManagerAddresses(libp2p),
|
||||
]);
|
||||
const transferStats = await getBrowserTransferStats(client, connections);
|
||||
const localAddresses = connections.flatMap((connection) => {
|
||||
const localAddr = isRecord(connection) ? connection.localAddr : undefined;
|
||||
return localAddr ? [localAddr] : [];
|
||||
});
|
||||
const nodeEndpoint = await resolveOwnEndpoint([...multiaddrs, ...addressManagerAddresses, ...localAddresses], signal);
|
||||
|
||||
return [
|
||||
{ name: 'Mode', value: getBrowserMode(client) },
|
||||
{ name: 'Peer ID', value: libp2p?.peerId?.toString() ?? 'unknown' },
|
||||
nodeEndpoint ? { countryCode: nodeEndpoint.countryCode, ip: nodeEndpoint.ip, name: 'Your IP', type: 'nodeEndpoint' } : { name: 'Your IP', value: 'unavailable' },
|
||||
{ name: 'Data received', value: transferStats.downloadedBytes === undefined ? 'unknown' : formatBytes(transferStats.downloadedBytes) },
|
||||
{ name: 'Data sent', value: transferStats.uploadedBytes === undefined ? 'unknown' : formatBytes(transferStats.uploadedBytes) },
|
||||
getBrowserConnectedPeersRow(peers, connections),
|
||||
@@ -561,10 +615,46 @@ const getElectronKuboStats = async (rpcState?: string, signal?: AbortSignal): Pr
|
||||
};
|
||||
|
||||
const getP2PStats = async (mode: P2PRuntimeMode, account?: AccountShape, rpcState?: string, signal?: AbortSignal) => {
|
||||
if (mode === 'browser-libp2p') return getBrowserLibp2pStats(account);
|
||||
if (mode === 'browser-libp2p') return getBrowserLibp2pStats(account, signal);
|
||||
return getElectronKuboStats(rpcState, signal);
|
||||
};
|
||||
|
||||
const NodeEndpointValue = ({ row }: { row: NodeEndpointStatRow }) => {
|
||||
const countryCode = normalizeCountryCode(row.countryCode);
|
||||
const flagPosition = getCountryFlagPosition(countryCode);
|
||||
const countryLabel = getCountryLabel(row.countryCode);
|
||||
|
||||
return (
|
||||
<span className={styles.nodeEndpoint}>
|
||||
{flagPosition && (
|
||||
<span
|
||||
aria-label={countryLabel}
|
||||
className={styles.peerFlag}
|
||||
role='img'
|
||||
style={{ backgroundPosition: `-${flagPosition.x}px -${flagPosition.y}px` }}
|
||||
title={countryLabel}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.nodeIp}>{row.ip}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const StatValueCell = ({ row }: { row: TextStatRow }) => {
|
||||
if (row.name === 'Mode' && row.value === 'Leeching') {
|
||||
return (
|
||||
<>
|
||||
Leeching (
|
||||
<a href={SEEDER_REPO_URL} rel='noopener noreferrer' target='_blank'>
|
||||
want to seed?
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
);
|
||||
}
|
||||
return row.value;
|
||||
};
|
||||
|
||||
const ConnectedPeersValue = ({ row }: { row: ConnectedPeersStatRow }) => (
|
||||
<details data-testid='connected-peers' open>
|
||||
<summary className={styles.connectedPeersSummary}>
|
||||
@@ -666,24 +756,35 @@ const P2PStatsSettings = () => {
|
||||
<tbody>
|
||||
{statsState.rows.map((row) =>
|
||||
row.type === 'connectedPeers' ? (
|
||||
<Fragment key={row.name}>
|
||||
{updatedAtLabel && (
|
||||
<tr>
|
||||
<td className={styles.statName}>{t('p2p_stats_updated')}</td>
|
||||
<td className={styles.statValue}>{updatedAtLabel}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className={styles.connectedPeersCell} colSpan={2}>
|
||||
<ConnectedPeersValue row={row} />
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
) : row.type === 'nodeEndpoint' ? (
|
||||
<tr key={row.name}>
|
||||
<td className={styles.connectedPeersCell} colSpan={2}>
|
||||
<ConnectedPeersValue row={row} />
|
||||
<td className={styles.statName}>{row.name}</td>
|
||||
<td className={styles.statValue}>
|
||||
<NodeEndpointValue row={row} />
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={row.name}>
|
||||
<td className={styles.statName}>{row.name}</td>
|
||||
<td className={styles.statValue}>{row.value}</td>
|
||||
<td className={styles.statValue}>
|
||||
<StatValueCell row={row} />
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
{updatedAtLabel && (
|
||||
<tr>
|
||||
<td className={styles.statName}>{t('p2p_stats_updated')}</td>
|
||||
<td className={styles.statValue}>{updatedAtLabel}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={styles.statsMeta}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WORLD_MAP_DOTS } from '../../../data/world-map-dots';
|
||||
import { getApproximateLatLon } from '../../../lib/peer-geo';
|
||||
import styles from './p2p-stats-settings.module.css';
|
||||
|
||||
@@ -7,230 +8,29 @@ type MapPeer = {
|
||||
peerId: string;
|
||||
};
|
||||
|
||||
// Rough continent outlines as [lon, lat] vertices. Deliberately coarse: they are
|
||||
// only rasterized into a faint dotted backdrop, so approximate shapes are fine.
|
||||
const CONTINENTS: [number, number][][] = [
|
||||
// North America
|
||||
[
|
||||
[-168, 65],
|
||||
[-156, 71],
|
||||
[-128, 70],
|
||||
[-110, 68],
|
||||
[-95, 69],
|
||||
[-81, 73],
|
||||
[-78, 67],
|
||||
[-64, 60],
|
||||
[-56, 52],
|
||||
[-66, 49],
|
||||
[-67, 44],
|
||||
[-70, 41],
|
||||
[-75, 35],
|
||||
[-81, 25],
|
||||
[-90, 29],
|
||||
[-97, 26],
|
||||
[-97, 21],
|
||||
[-105, 20],
|
||||
[-112, 24],
|
||||
[-117, 32],
|
||||
[-122, 37],
|
||||
[-125, 43],
|
||||
[-130, 51],
|
||||
[-141, 59],
|
||||
[-155, 58],
|
||||
],
|
||||
// Greenland
|
||||
[
|
||||
[-45, 60],
|
||||
[-30, 60],
|
||||
[-18, 66],
|
||||
[-20, 73],
|
||||
[-25, 80],
|
||||
[-40, 83],
|
||||
[-58, 82],
|
||||
[-55, 76],
|
||||
[-50, 68],
|
||||
],
|
||||
// South America
|
||||
[
|
||||
[-81, 8],
|
||||
[-72, 11],
|
||||
[-62, 10],
|
||||
[-50, 0],
|
||||
[-35, -6],
|
||||
[-39, -14],
|
||||
[-48, -25],
|
||||
[-58, -34],
|
||||
[-65, -41],
|
||||
[-69, -50],
|
||||
[-74, -53],
|
||||
[-72, -45],
|
||||
[-73, -37],
|
||||
[-71, -28],
|
||||
[-71, -18],
|
||||
[-78, -8],
|
||||
[-81, -4],
|
||||
[-80, 2],
|
||||
],
|
||||
// Europe
|
||||
[
|
||||
[-10, 36],
|
||||
[-9, 44],
|
||||
[-2, 43],
|
||||
[-2, 49],
|
||||
[-5, 54],
|
||||
[-6, 58],
|
||||
[5, 61],
|
||||
[8, 58],
|
||||
[12, 59],
|
||||
[16, 66],
|
||||
[24, 71],
|
||||
[30, 70],
|
||||
[40, 67],
|
||||
[46, 60],
|
||||
[42, 52],
|
||||
[36, 46],
|
||||
[28, 45],
|
||||
[24, 40],
|
||||
[18, 40],
|
||||
[12, 38],
|
||||
[2, 42],
|
||||
[-4, 37],
|
||||
],
|
||||
// Africa
|
||||
[
|
||||
[-17, 15],
|
||||
[-16, 21],
|
||||
[-10, 27],
|
||||
[-6, 32],
|
||||
[1, 37],
|
||||
[10, 37],
|
||||
[11, 33],
|
||||
[20, 32],
|
||||
[25, 32],
|
||||
[32, 31],
|
||||
[35, 24],
|
||||
[37, 18],
|
||||
[43, 12],
|
||||
[51, 12],
|
||||
[44, 5],
|
||||
[48, -3],
|
||||
[40, -15],
|
||||
[35, -21],
|
||||
[31, -26],
|
||||
[26, -34],
|
||||
[20, -35],
|
||||
[16, -29],
|
||||
[12, -17],
|
||||
[9, -1],
|
||||
[8, 4],
|
||||
[-4, 5],
|
||||
[-9, 5],
|
||||
[-13, 9],
|
||||
],
|
||||
// Asia
|
||||
[
|
||||
[42, 48],
|
||||
[45, 55],
|
||||
[55, 62],
|
||||
[68, 68],
|
||||
[80, 73],
|
||||
[100, 77],
|
||||
[115, 74],
|
||||
[140, 73],
|
||||
[160, 70],
|
||||
[170, 66],
|
||||
[178, 65],
|
||||
[170, 60],
|
||||
[158, 53],
|
||||
[150, 46],
|
||||
[143, 44],
|
||||
[140, 50],
|
||||
[135, 44],
|
||||
[131, 43],
|
||||
[127, 37],
|
||||
[122, 40],
|
||||
[120, 34],
|
||||
[122, 30],
|
||||
[115, 22],
|
||||
[108, 21],
|
||||
[106, 11],
|
||||
[100, 14],
|
||||
[98, 8],
|
||||
[100, 2],
|
||||
[95, 7],
|
||||
[90, 22],
|
||||
[88, 22],
|
||||
[80, 13],
|
||||
[77, 8],
|
||||
[73, 17],
|
||||
[68, 24],
|
||||
[61, 25],
|
||||
[57, 37],
|
||||
[52, 41],
|
||||
[47, 43],
|
||||
],
|
||||
// Australia
|
||||
[
|
||||
[113, -22],
|
||||
[121, -19],
|
||||
[129, -15],
|
||||
[136, -12],
|
||||
[142, -11],
|
||||
[145, -15],
|
||||
[147, -19],
|
||||
[153, -25],
|
||||
[153, -31],
|
||||
[150, -37],
|
||||
[143, -39],
|
||||
[138, -35],
|
||||
[131, -32],
|
||||
[123, -34],
|
||||
[115, -34],
|
||||
[114, -29],
|
||||
],
|
||||
// Indonesia
|
||||
[
|
||||
[96, 5],
|
||||
[120, 7],
|
||||
[128, 8],
|
||||
[131, 1],
|
||||
[122, -4],
|
||||
[114, -8],
|
||||
[103, -8],
|
||||
[98, -1],
|
||||
],
|
||||
// Japan
|
||||
[
|
||||
[130, 31],
|
||||
[136, 35],
|
||||
[141, 41],
|
||||
[142, 45],
|
||||
[140, 38],
|
||||
[135, 34],
|
||||
[131, 31],
|
||||
],
|
||||
];
|
||||
// Square side per land dot, sized to cover most of a grid cell so the rasterized
|
||||
// Natural Earth land mask (src/data/world-map-dots.ts) reads as a halftone map.
|
||||
const DOT_SIZE = WORLD_MAP_DOTS.step * 0.6;
|
||||
|
||||
const pointInPolygon = (lon: number, lat: number, polygon: [number, number][]) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const [xi, yi] = polygon[i];
|
||||
const [xj, yj] = polygon[j];
|
||||
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
|
||||
// Equirectangular projection into the SVG viewBox below: x = lon + 180, y = 90 - lat.
|
||||
const LAND_STEP = 3.5;
|
||||
const LAND_DOTS: { x: number; y: number }[] = (() => {
|
||||
const dots: { x: number; y: number }[] = [];
|
||||
for (let lat = 80; lat >= -56; lat -= LAND_STEP) {
|
||||
for (let lon = -178; lon <= 180; lon += LAND_STEP) {
|
||||
if (CONTINENTS.some((continent) => pointInPolygon(lon, lat, continent))) dots.push({ x: lon + 180, y: 90 - lat });
|
||||
// Equirectangular projection shared with the peer markers below: x = lon + 180,
|
||||
// y = 90 - lat. Expand the land bitmap into one <path> of small squares so the
|
||||
// backdrop is a single static node instead of thousands of elements.
|
||||
const LAND_PATH = (() => {
|
||||
const { step, lonMin, latMax, cols, rows, bitmap } = WORLD_MAP_DOTS;
|
||||
const binary = atob(bitmap);
|
||||
const square = `h${DOT_SIZE}v${DOT_SIZE}h${-DOT_SIZE}z`;
|
||||
const fmt = (value: number) => +value.toFixed(2);
|
||||
let path = '';
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const index = row * cols + col;
|
||||
if (!((binary.charCodeAt(index >> 3) >> (7 - (index & 7))) & 1)) continue;
|
||||
const lon = lonMin + (col + 0.5) * step;
|
||||
const lat = latMax - (row + 0.5) * step;
|
||||
path += `M${fmt(lon + 180 - DOT_SIZE / 2)} ${fmt(90 - lat - DOT_SIZE / 2)}${square}`;
|
||||
}
|
||||
}
|
||||
return dots;
|
||||
return path;
|
||||
})();
|
||||
|
||||
const PeerWorldMap = ({ peers }: { peers: MapPeer[] }) => {
|
||||
@@ -245,9 +45,7 @@ const PeerWorldMap = ({ peers }: { peers: MapPeer[] }) => {
|
||||
return (
|
||||
<div className={styles.peerWorldMap}>
|
||||
<svg className={styles.peerWorldMapSvg} viewBox='0 8 360 140' shapeRendering='crispEdges' role='img' aria-label='Approximate peer locations'>
|
||||
{LAND_DOTS.map((dot) => (
|
||||
<rect className={styles.landDot} height={1.6} key={`${dot.x}-${dot.y}`} width={1.6} x={dot.x - 0.8} y={dot.y - 0.8} />
|
||||
))}
|
||||
<path className={styles.landDot} d={LAND_PATH} />
|
||||
{plotted.map((peer) => (
|
||||
<rect className={styles.peerMarker} height={4.5} key={peer.id} width={4.5} x={peer.x - 2.25} y={peer.y - 2.25}>
|
||||
<title>{peer.peerId}</title>
|
||||
|
||||
Reference in New Issue
Block a user