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:
Tommaso Casaburi
2026-05-23 22:47:41 +07:00
committed by GitHub
parent 9c4bb28940
commit f5039285a7
12 changed files with 995 additions and 276 deletions
+2
View File
@@ -55,6 +55,8 @@
"scripts": {
"generate:assets": "node scripts/generate-asset-manifest.js",
"llms:generate": "node scripts/generate-llms-files.mjs",
"map:generate": "node scripts/generate-world-map-dots.mjs",
"centroids:generate": "node scripts/generate-country-centroids.mjs",
"release:manifest": "node scripts/generate-release-manifest.mjs",
"release:manifest:keygen": "node scripts/generate-release-manifest-keypair.mjs",
"sync:directories": "node scripts/sync-directories.js",
+74
View File
@@ -0,0 +1,74 @@
// Generates src/data/country-centroids.ts: ISO 3166-1 alpha-2 -> approximate
// country centroid, used to place P2P peer markers at the center of the country
// already shown as the peer's flag. Run with `yarn centroids:generate`.
//
// Peer country itself is derived offline (see lib/peer-geo getApproximateCountryCode)
// so no peer IP is ever sent to a geolocation API; this only supplies the static
// "where is country X" centroids the markers snap to.
import { writeFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, '..');
const OUTPUT_PATH = resolve(REPO_ROOT, 'src/data/country-centroids.ts');
// Public-domain ISO country centroids (country,latitude,longitude,name).
// Override with COUNTRY_CENTROIDS_CSV_URL.
const CSV_URL =
process.env.COUNTRY_CENTROIDS_CSV_URL ?? 'https://raw.githubusercontent.com/google/dspl/master/samples/google/canonical/countries.csv';
const log = (message) => process.stdout.write(`[generate-country-centroids] ${message}\n`);
const fetchCsv = async (url) => {
log(`fetching ${url}`);
const response = await fetch(url);
if (!response.ok) throw new Error(`failed to fetch centroid CSV: ${response.status} ${response.statusText}`);
return response.text();
};
const round = (value) => Math.round(value * 100) / 100;
const parseCentroids = (csv) => {
const centroids = {};
for (const line of csv.trim().split('\n').slice(1)) {
// Only the first three columns are needed; the trailing name may contain commas.
const [code, lat, lon] = line.split(',');
const iso = code?.trim().toLowerCase();
const latNum = Number(lat);
const lonNum = Number(lon);
if (!/^[a-z]{2}$/.test(iso ?? '') || !Number.isFinite(latNum) || !Number.isFinite(lonNum)) continue;
centroids[iso] = { lat: round(latNum), lon: round(lonNum) };
}
return centroids;
};
const renderModule = (centroids) => {
const entries = Object.keys(centroids)
.sort()
.map((iso) => ` ${iso}: { lat: ${centroids[iso].lat}, lon: ${centroids[iso].lon} },`)
.join('\n');
return `// GENERATED FILE — do not edit by hand.
// Source: Google DSPL canonical countries.csv (public domain), via
// scripts/generate-country-centroids.mjs. Run \`yarn centroids:generate\` to refresh.
//
// ISO 3166-1 alpha-2 (lowercase) -> approximate country centroid, used to place
// P2P peer markers at the center of the country shown as the peer's flag.
export const COUNTRY_CENTROIDS: Record<string, { lat: number; lon: number }> = {
${entries}
};
`;
};
const main = async () => {
const csv = await fetchCsv(CSV_URL);
const centroids = parseCentroids(csv);
writeFileSync(OUTPUT_PATH, renderModule(centroids));
log(`wrote ${relative(REPO_ROOT, OUTPUT_PATH)} (${Object.keys(centroids).length} countries)`);
};
main().catch((error) => {
process.stderr.write(`[generate-country-centroids] ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
+144
View File
@@ -0,0 +1,144 @@
// Generates src/data/world-map-dots.ts: a compact dotted-world-map backdrop for
// the P2P stats panel, rasterized from Natural Earth 1:110m land polygons
// (public domain). Run with `yarn map:generate`.
//
// The map is decorative ("approximate locations"), so we only need a recognizable
// land/sea mask. We rasterize the real land polygons onto a regular lon/lat grid
// and pack the result into a 1-bit-per-cell bitmap (base64). At runtime the panel
// expands set bits into square dots using the same equirectangular projection the
// peer markers use, so dots and markers stay aligned without shipping a geo lib.
import { Buffer } from 'node:buffer';
import { writeFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, '..');
const OUTPUT_PATH = resolve(REPO_ROOT, 'src/data/world-map-dots.ts');
// Natural Earth 1:110m land (public domain). Override with WORLD_MAP_GEOJSON_URL.
const GEOJSON_URL =
process.env.WORLD_MAP_GEOJSON_URL ??
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_land.geojson';
// Equirectangular grid. STEP is the cell size in degrees; smaller = finer/more
// dots. The latitude band excludes Antarctica and trims the very top of the map.
const STEP = 2;
const LON_MIN = -180;
const LAT_MAX = 84;
const LAT_MIN = -56;
const COLS = Math.round((180 - LON_MIN) / STEP);
const ROWS = Math.round((LAT_MAX - LAT_MIN) / STEP);
const log = (message) => process.stdout.write(`[generate-world-map-dots] ${message}\n`);
const fetchGeoJson = async (url) => {
log(`fetching ${url}`);
const response = await fetch(url);
if (!response.ok) throw new Error(`failed to fetch land GeoJSON: ${response.status} ${response.statusText}`);
return response.json();
};
// Flatten Polygon/MultiPolygon features into rings plus a bounding box for a fast
// reject before the per-edge ray cast.
const collectPolygons = (geojson) => {
const polygons = [];
for (const feature of geojson.features ?? []) {
const geometry = feature.geometry;
if (!geometry) continue;
const coordinateSets =
geometry.type === 'Polygon' ? [geometry.coordinates] : geometry.type === 'MultiPolygon' ? geometry.coordinates : [];
for (const rings of coordinateSets) {
let minLon = Infinity;
let minLat = Infinity;
let maxLon = -Infinity;
let maxLat = -Infinity;
for (const ring of rings) {
for (const [lon, lat] of ring) {
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
}
}
polygons.push({ bbox: [minLon, minLat, maxLon, maxLat], rings });
}
}
return polygons;
};
// Even-odd ray casting across every ring of a polygon, so interior holes (lakes)
// correctly read as sea. Natural Earth cuts geometry at the antimeridian, so no
// polygon wraps past +/-180 and a plain lon-space ray cast is sound.
const isInsidePolygon = (lon, lat, rings) => {
let inside = false;
for (const ring of rings) {
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
}
}
return inside;
};
const isLand = (lon, lat, polygons) => {
for (const { bbox, rings } of polygons) {
if (lon < bbox[0] || lon > bbox[2] || lat < bbox[1] || lat > bbox[3]) continue;
if (isInsidePolygon(lon, lat, rings)) return true;
}
return false;
};
const rasterize = (polygons) => {
const bits = new Uint8Array(Math.ceil((COLS * ROWS) / 8));
let landCount = 0;
for (let row = 0; row < ROWS; row++) {
const lat = LAT_MAX - (row + 0.5) * STEP;
for (let col = 0; col < COLS; col++) {
const lon = LON_MIN + (col + 0.5) * STEP;
if (isLand(lon, lat, polygons)) {
const index = row * COLS + col;
bits[index >> 3] |= 1 << (7 - (index & 7));
landCount++;
}
}
}
return { bits, landCount };
};
const renderModule = (base64) =>
`// GENERATED FILE — do not edit by hand.
// Source: Natural Earth 1:110m land (public domain), rasterized by
// scripts/generate-world-map-dots.mjs. Run \`yarn map:generate\` to refresh.
//
// Row-major land/sea grid for the P2P stats world map. \`bitmap\` is a base64
// 1-bit-per-cell mask (MSB first, 1 = land). Cell (col,row) centers on
// lon = lonMin + (col + 0.5) * step, lat = latMax - (row + 0.5) * step.
export const WORLD_MAP_DOTS = {
step: ${STEP},
lonMin: ${LON_MIN},
latMax: ${LAT_MAX},
cols: ${COLS},
rows: ${ROWS},
bitmap: '${base64}',
} as const;
`;
const main = async () => {
const geojson = await fetchGeoJson(GEOJSON_URL);
const polygons = collectPolygons(geojson);
log(`rasterizing ${polygons.length} land polygons onto a ${COLS}x${ROWS} grid (step ${STEP}°)`);
const { bits, landCount } = rasterize(polygons);
const base64 = Buffer.from(bits).toString('base64');
writeFileSync(OUTPUT_PATH, renderModule(base64));
log(`wrote ${relative(REPO_ROOT, OUTPUT_PATH)} (${landCount} land cells, ${base64.length} base64 chars)`);
};
main().catch((error) => {
process.stderr.write(`[generate-world-map-dots] ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
@@ -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>
+253
View File
@@ -0,0 +1,253 @@
// GENERATED FILE — do not edit by hand.
// Source: Google DSPL canonical countries.csv (public domain), via
// scripts/generate-country-centroids.mjs. Run `yarn centroids:generate` to refresh.
//
// ISO 3166-1 alpha-2 (lowercase) -> approximate country centroid, used to place
// P2P peer markers at the center of the country shown as the peer's flag.
export const COUNTRY_CENTROIDS: Record<string, { lat: number; lon: number }> = {
ad: { lat: 42.55, lon: 1.6 },
ae: { lat: 23.42, lon: 53.85 },
af: { lat: 33.94, lon: 67.71 },
ag: { lat: 17.06, lon: -61.8 },
ai: { lat: 18.22, lon: -63.07 },
al: { lat: 41.15, lon: 20.17 },
am: { lat: 40.07, lon: 45.04 },
an: { lat: 12.23, lon: -69.06 },
ao: { lat: -11.2, lon: 17.87 },
aq: { lat: -75.25, lon: -0.07 },
ar: { lat: -38.42, lon: -63.62 },
as: { lat: -14.27, lon: -170.13 },
at: { lat: 47.52, lon: 14.55 },
au: { lat: -25.27, lon: 133.78 },
aw: { lat: 12.52, lon: -69.97 },
az: { lat: 40.14, lon: 47.58 },
ba: { lat: 43.92, lon: 17.68 },
bb: { lat: 13.19, lon: -59.54 },
bd: { lat: 23.68, lon: 90.36 },
be: { lat: 50.5, lon: 4.47 },
bf: { lat: 12.24, lon: -1.56 },
bg: { lat: 42.73, lon: 25.49 },
bh: { lat: 25.93, lon: 50.64 },
bi: { lat: -3.37, lon: 29.92 },
bj: { lat: 9.31, lon: 2.32 },
bm: { lat: 32.32, lon: -64.76 },
bn: { lat: 4.54, lon: 114.73 },
bo: { lat: -16.29, lon: -63.59 },
br: { lat: -14.24, lon: -51.93 },
bs: { lat: 25.03, lon: -77.4 },
bt: { lat: 27.51, lon: 90.43 },
bv: { lat: -54.42, lon: 3.41 },
bw: { lat: -22.33, lon: 24.68 },
by: { lat: 53.71, lon: 27.95 },
bz: { lat: 17.19, lon: -88.5 },
ca: { lat: 56.13, lon: -106.35 },
cc: { lat: -12.16, lon: 96.87 },
cd: { lat: -4.04, lon: 21.76 },
cf: { lat: 6.61, lon: 20.94 },
cg: { lat: -0.23, lon: 15.83 },
ch: { lat: 46.82, lon: 8.23 },
ci: { lat: 7.54, lon: -5.55 },
ck: { lat: -21.24, lon: -159.78 },
cl: { lat: -35.68, lon: -71.54 },
cm: { lat: 7.37, lon: 12.35 },
cn: { lat: 35.86, lon: 104.2 },
co: { lat: 4.57, lon: -74.3 },
cr: { lat: 9.75, lon: -83.75 },
cu: { lat: 21.52, lon: -77.78 },
cv: { lat: 16, lon: -24.01 },
cx: { lat: -10.45, lon: 105.69 },
cy: { lat: 35.13, lon: 33.43 },
cz: { lat: 49.82, lon: 15.47 },
de: { lat: 51.17, lon: 10.45 },
dj: { lat: 11.83, lon: 42.59 },
dk: { lat: 56.26, lon: 9.5 },
dm: { lat: 15.41, lon: -61.37 },
do: { lat: 18.74, lon: -70.16 },
dz: { lat: 28.03, lon: 1.66 },
ec: { lat: -1.83, lon: -78.18 },
ee: { lat: 58.6, lon: 25.01 },
eg: { lat: 26.82, lon: 30.8 },
eh: { lat: 24.22, lon: -12.89 },
er: { lat: 15.18, lon: 39.78 },
es: { lat: 40.46, lon: -3.75 },
et: { lat: 9.15, lon: 40.49 },
fi: { lat: 61.92, lon: 25.75 },
fj: { lat: -16.58, lon: 179.41 },
fk: { lat: -51.8, lon: -59.52 },
fm: { lat: 7.43, lon: 150.55 },
fo: { lat: 61.89, lon: -6.91 },
fr: { lat: 46.23, lon: 2.21 },
ga: { lat: -0.8, lon: 11.61 },
gb: { lat: 55.38, lon: -3.44 },
gd: { lat: 12.26, lon: -61.6 },
ge: { lat: 42.32, lon: 43.36 },
gf: { lat: 3.93, lon: -53.13 },
gg: { lat: 49.47, lon: -2.59 },
gh: { lat: 7.95, lon: -1.02 },
gi: { lat: 36.14, lon: -5.35 },
gl: { lat: 71.71, lon: -42.6 },
gm: { lat: 13.44, lon: -15.31 },
gn: { lat: 9.95, lon: -9.7 },
gp: { lat: 17, lon: -62.07 },
gq: { lat: 1.65, lon: 10.27 },
gr: { lat: 39.07, lon: 21.82 },
gs: { lat: -54.43, lon: -36.59 },
gt: { lat: 15.78, lon: -90.23 },
gu: { lat: 13.44, lon: 144.79 },
gw: { lat: 11.8, lon: -15.18 },
gy: { lat: 4.86, lon: -58.93 },
gz: { lat: 31.35, lon: 34.31 },
hk: { lat: 22.4, lon: 114.11 },
hm: { lat: -53.08, lon: 73.5 },
hn: { lat: 15.2, lon: -86.24 },
hr: { lat: 45.1, lon: 15.2 },
ht: { lat: 18.97, lon: -72.29 },
hu: { lat: 47.16, lon: 19.5 },
id: { lat: -0.79, lon: 113.92 },
ie: { lat: 53.41, lon: -8.24 },
il: { lat: 31.05, lon: 34.85 },
im: { lat: 54.24, lon: -4.55 },
in: { lat: 20.59, lon: 78.96 },
io: { lat: -6.34, lon: 71.88 },
iq: { lat: 33.22, lon: 43.68 },
ir: { lat: 32.43, lon: 53.69 },
is: { lat: 64.96, lon: -19.02 },
it: { lat: 41.87, lon: 12.57 },
je: { lat: 49.21, lon: -2.13 },
jm: { lat: 18.11, lon: -77.3 },
jo: { lat: 30.59, lon: 36.24 },
jp: { lat: 36.2, lon: 138.25 },
ke: { lat: -0.02, lon: 37.91 },
kg: { lat: 41.2, lon: 74.77 },
kh: { lat: 12.57, lon: 104.99 },
ki: { lat: -3.37, lon: -168.73 },
km: { lat: -11.88, lon: 43.87 },
kn: { lat: 17.36, lon: -62.78 },
kp: { lat: 40.34, lon: 127.51 },
kr: { lat: 35.91, lon: 127.77 },
kw: { lat: 29.31, lon: 47.48 },
ky: { lat: 19.51, lon: -80.57 },
kz: { lat: 48.02, lon: 66.92 },
la: { lat: 19.86, lon: 102.5 },
lb: { lat: 33.85, lon: 35.86 },
lc: { lat: 13.91, lon: -60.98 },
li: { lat: 47.17, lon: 9.56 },
lk: { lat: 7.87, lon: 80.77 },
lr: { lat: 6.43, lon: -9.43 },
ls: { lat: -29.61, lon: 28.23 },
lt: { lat: 55.17, lon: 23.88 },
lu: { lat: 49.82, lon: 6.13 },
lv: { lat: 56.88, lon: 24.6 },
ly: { lat: 26.34, lon: 17.23 },
ma: { lat: 31.79, lon: -7.09 },
mc: { lat: 43.75, lon: 7.41 },
md: { lat: 47.41, lon: 28.37 },
me: { lat: 42.71, lon: 19.37 },
mg: { lat: -18.77, lon: 46.87 },
mh: { lat: 7.13, lon: 171.18 },
mk: { lat: 41.61, lon: 21.75 },
ml: { lat: 17.57, lon: -4 },
mm: { lat: 21.91, lon: 95.96 },
mn: { lat: 46.86, lon: 103.85 },
mo: { lat: 22.2, lon: 113.54 },
mp: { lat: 17.33, lon: 145.38 },
mq: { lat: 14.64, lon: -61.02 },
mr: { lat: 21.01, lon: -10.94 },
ms: { lat: 16.74, lon: -62.19 },
mt: { lat: 35.94, lon: 14.38 },
mu: { lat: -20.35, lon: 57.55 },
mv: { lat: 3.2, lon: 73.22 },
mw: { lat: -13.25, lon: 34.3 },
mx: { lat: 23.63, lon: -102.55 },
my: { lat: 4.21, lon: 101.98 },
mz: { lat: -18.67, lon: 35.53 },
na: { lat: -22.96, lon: 18.49 },
nc: { lat: -20.9, lon: 165.62 },
ne: { lat: 17.61, lon: 8.08 },
nf: { lat: -29.04, lon: 167.95 },
ng: { lat: 9.08, lon: 8.68 },
ni: { lat: 12.87, lon: -85.21 },
nl: { lat: 52.13, lon: 5.29 },
no: { lat: 60.47, lon: 8.47 },
np: { lat: 28.39, lon: 84.12 },
nr: { lat: -0.52, lon: 166.93 },
nu: { lat: -19.05, lon: -169.87 },
nz: { lat: -40.9, lon: 174.89 },
om: { lat: 21.51, lon: 55.92 },
pa: { lat: 8.54, lon: -80.78 },
pe: { lat: -9.19, lon: -75.02 },
pf: { lat: -17.68, lon: -149.41 },
pg: { lat: -6.31, lon: 143.96 },
ph: { lat: 12.88, lon: 121.77 },
pk: { lat: 30.38, lon: 69.35 },
pl: { lat: 51.92, lon: 19.15 },
pm: { lat: 46.94, lon: -56.27 },
pn: { lat: -24.7, lon: -127.44 },
pr: { lat: 18.22, lon: -66.59 },
ps: { lat: 31.95, lon: 35.23 },
pt: { lat: 39.4, lon: -8.22 },
pw: { lat: 7.51, lon: 134.58 },
py: { lat: -23.44, lon: -58.44 },
qa: { lat: 25.35, lon: 51.18 },
re: { lat: -21.12, lon: 55.54 },
ro: { lat: 45.94, lon: 24.97 },
rs: { lat: 44.02, lon: 21.01 },
ru: { lat: 61.52, lon: 105.32 },
rw: { lat: -1.94, lon: 29.87 },
sa: { lat: 23.89, lon: 45.08 },
sb: { lat: -9.65, lon: 160.16 },
sc: { lat: -4.68, lon: 55.49 },
sd: { lat: 12.86, lon: 30.22 },
se: { lat: 60.13, lon: 18.64 },
sg: { lat: 1.35, lon: 103.82 },
sh: { lat: -24.14, lon: -10.03 },
si: { lat: 46.15, lon: 15 },
sj: { lat: 77.55, lon: 23.67 },
sk: { lat: 48.67, lon: 19.7 },
sl: { lat: 8.46, lon: -11.78 },
sm: { lat: 43.94, lon: 12.46 },
sn: { lat: 14.5, lon: -14.45 },
so: { lat: 5.15, lon: 46.2 },
sr: { lat: 3.92, lon: -56.03 },
st: { lat: 0.19, lon: 6.61 },
sv: { lat: 13.79, lon: -88.9 },
sy: { lat: 34.8, lon: 39 },
sz: { lat: -26.52, lon: 31.47 },
tc: { lat: 21.69, lon: -71.8 },
td: { lat: 15.45, lon: 18.73 },
tf: { lat: -49.28, lon: 69.35 },
tg: { lat: 8.62, lon: 0.82 },
th: { lat: 15.87, lon: 100.99 },
tj: { lat: 38.86, lon: 71.28 },
tk: { lat: -8.97, lon: -171.86 },
tl: { lat: -8.87, lon: 125.73 },
tm: { lat: 38.97, lon: 59.56 },
tn: { lat: 33.89, lon: 9.54 },
to: { lat: -21.18, lon: -175.2 },
tr: { lat: 38.96, lon: 35.24 },
tt: { lat: 10.69, lon: -61.22 },
tv: { lat: -7.11, lon: 177.65 },
tw: { lat: 23.7, lon: 120.96 },
tz: { lat: -6.37, lon: 34.89 },
ua: { lat: 48.38, lon: 31.17 },
ug: { lat: 1.37, lon: 32.29 },
um: { lat: 0, lon: 0 },
us: { lat: 37.09, lon: -95.71 },
uy: { lat: -32.52, lon: -55.77 },
uz: { lat: 41.38, lon: 64.59 },
va: { lat: 41.9, lon: 12.45 },
vc: { lat: 12.98, lon: -61.29 },
ve: { lat: 6.42, lon: -66.59 },
vg: { lat: 18.42, lon: -64.64 },
vi: { lat: 18.34, lon: -64.9 },
vn: { lat: 14.06, lon: 108.28 },
vu: { lat: -15.38, lon: 166.96 },
wf: { lat: -13.77, lon: -177.16 },
ws: { lat: -13.76, lon: -172.1 },
xk: { lat: 42.6, lon: 20.9 },
ye: { lat: 15.55, lon: 48.52 },
yt: { lat: -12.83, lon: 45.17 },
za: { lat: -30.56, lon: 22.94 },
zm: { lat: -13.13, lon: 27.85 },
zw: { lat: -19.02, lon: 29.15 },
};
+16
View File
@@ -0,0 +1,16 @@
// GENERATED FILE — do not edit by hand.
// Source: Natural Earth 1:110m land (public domain), rasterized by
// scripts/generate-world-map-dots.mjs. Run `yarn map:generate` to refresh.
//
// Row-major land/sea grid for the P2P stats world map. `bitmap` is a base64
// 1-bit-per-cell mask (MSB first, 1 = land). Cell (col,row) centers on
// lon = lonMin + (col + 0.5) * step, lat = latMax - (row + 0.5) * step.
export const WORLD_MAP_DOTS = {
step: 2,
lonMin: -180,
latMax: 84,
cols: 180,
rows: 70,
bitmap:
'AAAAAAAAf4AP/AAAAAAAAAAAAAAAAAAAAAAAAX/z///+AAAAAAAABAAAAAAAAAAAAAYd8P///wAA+AAAAAA8AAAAAAAAAAAwAnw////4AAIAAAAAAGAAAAAAAAAAADivwAf//wAAAAADAAf/wAHYAAAAAADoi3sAP//gAAAAAMAD///sAAAAgBgACfwz/AD/+gAAAwAEHf///+/8gBAP/7/nJdjwD/+AAAH/AA7f///////f4P//////h8H/gAAAf/6//f////////Mf/////9H4D8AeAA+ev///////////AP/////4A0B4AAAD5/////////////Af3////gHgA4AAAH5///////////LwAHgH///gHkAAAAAH4/////////+CIAABAB///4D+AAAAGCx/////////4A8AAIAAf///n/gAAAOCD/////////wA4AAAAAf///n/wAAAbP///////////AgAAAAAP/////wAAADf//////////9AAAAAAAF////0YAAAB///////////9AAAAAAAD////8EAAAB///////////5AAAAAAAD////2AAAAB/f5fP//////wAAAAAAAD////gAAAAfxnwPP//////jAAAAAAAD////AAAAAPCb3/n/////+CAAAAAAAD///8AAAAAfALf/n////+ECAAAAAAAB///8AAAAAGHQP/n/////mMAAAAAAAA///8AAAAAH+Ai///////E8AAAAAAAAf//wAAAAAP/AA///////BgAAAAAAAAP//gAAAAAf/73///////gAAAAAAAAAD/AQAAAAAf////f/////gAAAAAAAAAF+AQAAAAB///+/n/////AAAAAAAAAAC+AAAAAAB///+f0H////AAAAAAAAAAAeAwAAAAD////f/B///8gAAAAAAAAAAeGEAAAAH////v+B/z/AAAAAAAAAAAAPMAgAAAD////n+A/B+gAAAAAAAAAAAD8AAAAAD////n4AeB/AgAAAAAAAAAAAPAAAAAH////3gAcAfAgAAAAAAAAAAADAAAAAD////6AAcAfggAAAAAAAAAAABDwAAAD////8wAMATAIAAAAAAAAAAAAr/AAAB/////gAKASAAAAAAAAAAAAAAH/gAAA/////gACAAAIAAAAAAAAAAAAH/8AAAaH///AAAAsGAAAAAAAAAAAAAH/+AAAAB//+AAAAUOAAAAAAAAAAAAAP/+AAAAB//8AAAAYegAAAAAAAAAAAAP//gAAAD//4AAAAMeBgAAAAAAAAAAAP//8AAAB//wAAAAGdiuAAAAAAAAAAAf///AAAA//wAAAACAQHgAAAAAAAAAAP///gAAA//wAAAABwAHwgAAAAAAAAAH///AAAA//wAAAAACIDQIAAAAAAAAAH//+AAAAf/wAAAAAAAAAAAAAAAAAAAD//+AAAA//wgAAAAABxAAAAAAAAAAAD//+AAAA//wgAAAAAPxgBAAAAAAAAAA//8AAAA//jgAAAAAf5gAAAAAAAAAAAf/8AAAA//DgAAAAAf/gAAAAAAAAAAAf/8AAAAf/DAAAAAD//4CAAAAAAAAAAf/wAAAAf/DAAAAAH//4AAAAAAAAAAAf/AAAAAf+CAAAAAH//8AAAAAAAAAAAf/AAAAAP8AAAAAAH//+AAAAAAAAAAA/+AAAAAP8AAAAAAH//+AAAAAAAAAAA/+AAAAAH4AAAAAAD//+AAAAAAAAAAA/8AAAAAHwAAAAAADwf8AAAAAAAAAAA/gAAAAAAAAAAAAACAH4AIAAAAAAAAB/wAAAAAAAAAAAAAAAD4AEAAAAAAAAB+AAAAAAAAAAAAAAAAAAAGAAAAAAAAB6AAAAAAAAAAAAAAAAAwAMAAAAAAAAA8AAAAAAAAAAAAAAAAAQAYAAAAAAAAB4AAAAAAAAAAAAAAAAAAAwAAAAAAAAB4AAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAACAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAA',
} as const;
+93 -23
View File
@@ -1,5 +1,16 @@
import { describe, expect, it } from 'vitest';
import { extractIpv4FromAddress, getApproximateCountryCode, getApproximateLatLon, isPrivateOrReservedIpv4 } from '../peer-geo';
import { describe, expect, it, vi } from 'vitest';
import { COUNTRY_CENTROIDS } from '../../data/country-centroids';
import {
extractIpFromAddress,
extractIpv4FromAddress,
extractIpv6FromAddress,
fetchOwnIpCountryCode,
fetchOwnPublicEndpoint,
getApproximateCountryCode,
getApproximateLatLon,
getFirstPublicIpFromAddresses,
isPrivateOrReservedIpv4,
} from '../peer-geo';
const addr = (ip: string) => `/ip4/${ip}/tcp/4001/ws/p2p/12D3KooWExample`;
@@ -19,6 +30,13 @@ describe('extractIpv4FromAddress', () => {
});
});
describe('extractIpv6FromAddress', () => {
it('extracts the IPv6 from a multiaddr', () => {
expect(extractIpv6FromAddress('/ip6/2001:4860:4860::8888/tcp/4001/ws')).toBe('2001:4860:4860::8888');
expect(extractIpFromAddress('/ip6/2001:4860:4860::8888/tcp/4001/ws')).toBe('2001:4860:4860::8888');
});
});
describe('isPrivateOrReservedIpv4', () => {
it('flags private and reserved ranges', () => {
for (const ip of ['10.0.0.1', '172.16.5.4', '192.168.1.10', '127.0.0.1', '169.254.1.1', '100.64.0.1', '0.0.0.0', '239.255.0.1']) {
@@ -44,27 +62,17 @@ describe('getApproximateLatLon', () => {
expect(getApproximateLatLon(addr('8.8.8.8'))).toEqual(getApproximateLatLon(addr('8.8.8.8')));
});
it('places addresses in the expected continental region', () => {
const na = getApproximateLatLon(addr('8.8.8.8'));
expect(na?.lon).toBeLessThan(-80); // North America
expect(na?.lat).toBeGreaterThan(30);
const eu = getApproximateLatLon(addr('80.80.80.80'));
expect(eu?.lon).toBeGreaterThan(0);
expect(eu?.lon).toBeLessThan(30);
expect(eu?.lat).toBeGreaterThan(40);
const as = getApproximateLatLon(addr('1.1.1.1'));
expect(as?.lon).toBeGreaterThan(90);
const af = getApproximateLatLon(addr('41.0.0.1'));
expect(af?.lon).toBeGreaterThan(8);
expect(af?.lon).toBeLessThan(34);
expect(af?.lat).toBeLessThan(12);
const sa = getApproximateLatLon(addr('200.0.0.1'));
expect(sa?.lon).toBeLessThan(-45);
expect(sa?.lat).toBeLessThan(-5);
it('snaps a peer to the centroid of its flag country', () => {
for (const ip of ['8.8.8.8', '80.80.80.80', '1.1.1.1', '41.0.0.1', '200.0.0.1', '194.110.247.146', '91.234.199.189']) {
const country = getApproximateCountryCode(addr(ip));
expect(country).toBeDefined();
const centroid = COUNTRY_CENTROIDS[country!];
expect(centroid).toBeDefined();
const loc = getApproximateLatLon(addr(ip))!;
// Marker sits at the country centroid, within the small placement jitter.
expect(Math.abs(loc.lat - centroid.lat)).toBeLessThanOrEqual(1);
expect(Math.abs(loc.lon - centroid.lon)).toBeLessThanOrEqual(1.3);
}
});
it('stays within valid coordinate bounds', () => {
@@ -77,6 +85,68 @@ describe('getApproximateLatLon', () => {
});
});
describe('getFirstPublicIpFromAddresses', () => {
it('returns the first public IP from multiaddrs', () => {
expect(getFirstPublicIpFromAddresses(['/ip4/127.0.0.1/tcp/4001', '/ip4/147.75.84.175/tcp/4001/ws'])).toBe('147.75.84.175');
expect(getFirstPublicIpFromAddresses(['/ip4/127.0.0.1/tcp/4001', '/ip6/2001:4860:4860::8888/tcp/443'])).toBe('2001:4860:4860::8888');
});
it('returns undefined when only private addresses are present', () => {
expect(getFirstPublicIpFromAddresses(['/ip4/127.0.0.1/tcp/4001', '/ip4/10.0.0.5/tcp/4001', '/ip6/fd00::1/tcp/4001'])).toBeUndefined();
});
});
describe('fetchOwnPublicEndpoint', () => {
it('caches the fetched public endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ country: 'US', ip: '2001:4860:4860::8888' }),
});
vi.stubGlobal('fetch', fetchMock);
await expect(fetchOwnPublicEndpoint()).resolves.toEqual({ countryCode: 'us', ip: '2001:4860:4860::8888' });
await expect(fetchOwnPublicEndpoint()).resolves.toEqual({ countryCode: 'us', ip: '2001:4860:4860::8888' });
expect(fetchMock).toHaveBeenCalledTimes(1);
vi.unstubAllGlobals();
});
});
describe('fetchOwnIpCountryCode', () => {
it("resolves and caches the accurate country for the node's own ip", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ country: 'VN', ip: '172.225.56.8' }),
});
vi.stubGlobal('fetch', fetchMock);
await expect(fetchOwnIpCountryCode('172.225.56.8')).resolves.toBe('vn');
await expect(fetchOwnIpCountryCode('172.225.56.8')).resolves.toBe('vn');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('https://api.country.is/172.225.56.8');
vi.unstubAllGlobals();
});
it('does not cache a result from an aborted lookup', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ country: 'VN', ip: '203.0.113.50' }),
});
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
controller.abort();
// An aborted request is cancellation, not a real result, so it must not be cached.
await fetchOwnIpCountryCode('203.0.113.50', controller.signal);
// A later non-aborted call must perform a fresh lookup instead of a cached blank.
await expect(fetchOwnIpCountryCode('203.0.113.50')).resolves.toBe('vn');
expect(fetchMock).toHaveBeenCalledTimes(2);
vi.unstubAllGlobals();
});
});
describe('getApproximateCountryCode', () => {
it('returns undefined when the peer cannot be placed offline', () => {
expect(getApproximateCountryCode('/ip4/10.0.0.1/tcp/4001')).toBeUndefined();
+127 -7
View File
@@ -1,11 +1,112 @@
// Offline, approximate peer geolocation for the P2P stats world map.
//
// This intentionally avoids any external geolocation API: 5chan is serverless and
// privacy-focused, so we must not leak the set of peers a user is connected to.
// Connected peer geolocation intentionally avoids any external geolocation API:
// 5chan is serverless and privacy-focused, so we must not leak the set of peers
// a user is connected to.
// Instead we map an IPv4 address to its Regional Internet Registry (RIR) region at
// continent resolution, using the coarse IANA /8 allocation table below. Positions
// are therefore approximate ("roughly which continent"), not precise coordinates.
import { COUNTRY_CENTROIDS } from '../data/country-centroids';
const formatAddressString = (address: unknown): string => {
if (typeof address === 'string') return address;
if (address && typeof address === 'object' && typeof (address as { toString?: unknown }).toString === 'function') {
try {
return String(address);
} catch {
return '';
}
}
return '';
};
export type PublicEndpoint = {
countryCode?: string;
ip: string;
};
const COUNTRY_LOOKUP_URL = 'https://api.country.is';
const PUBLIC_IPV4_LOOKUP_URL = 'https://api64.ipify.org?format=json';
export const extractIpFromAddress = (address: string): string | null => extractIpv4FromAddress(address) ?? extractIpv6FromAddress(address);
export const getFirstPublicIpFromAddresses = (addresses: unknown[]): string | undefined => {
for (const address of addresses) {
const ip = extractIpFromAddress(formatAddressString(address));
if (ip && isPublicIpAddress(ip)) return ip;
}
return undefined;
};
let cachedOwnPublicEndpoint: { expiresAt: number; value?: PublicEndpoint } | undefined;
const normalizeLookupCountryCode = (value: unknown) => {
if (typeof value !== 'string') return undefined;
const code = value.trim().toLowerCase();
return /^[a-z]{2}$/.test(code) ? code : undefined;
};
const parsePublicEndpoint = (data: unknown): PublicEndpoint | undefined => {
if (!data || typeof data !== 'object') return undefined;
const ip = (data as { ip?: unknown }).ip;
if (typeof ip !== 'string' || !isPublicIpAddress(ip)) return undefined;
return {
countryCode: normalizeLookupCountryCode((data as { country?: unknown }).country),
ip,
};
};
const fetchPublicEndpoint = async (url: string, signal?: AbortSignal): Promise<PublicEndpoint | undefined> => {
try {
const response = await fetch(url, { signal });
if (!response.ok) return undefined;
return parsePublicEndpoint(await response.json());
} catch {
return undefined;
}
};
// Fetches the browser node's own public endpoint for the P2P stats panel when
// libp2p only advertises local/private listen addresses (common in browser nodes
// and VPN setups). This only asks about the current browser's public endpoint;
// connected peer geolocation remains offline/approximate below.
export const fetchOwnPublicEndpoint = async (signal?: AbortSignal): Promise<PublicEndpoint | undefined> => {
if (cachedOwnPublicEndpoint && Date.now() < cachedOwnPublicEndpoint.expiresAt) return cachedOwnPublicEndpoint.value;
const endpoint = await fetchPublicEndpoint(COUNTRY_LOOKUP_URL, signal);
if (endpoint) {
cachedOwnPublicEndpoint = { expiresAt: Date.now() + 60_000, value: endpoint };
return endpoint;
}
const ipv4Endpoint = await fetchPublicEndpoint(PUBLIC_IPV4_LOOKUP_URL, signal);
const fallback = ipv4Endpoint?.ip ? { countryCode: getApproximateCountryCode(`/ip4/${ipv4Endpoint.ip}/tcp/0`), ip: ipv4Endpoint.ip } : undefined;
// Don't cache an empty result produced by an aborted lookup (e.g. the panel was
// closed mid-request): that is cancellation, not a real failure, so a later
// reopen should retry instead of being served a cached blank for 30s.
if (!signal?.aborted) cachedOwnPublicEndpoint = { expiresAt: Date.now() + 30_000, value: fallback };
return fallback;
};
const ownIpCountryCache = new Map<string, { expiresAt: number; value?: string }>();
// Accurate country code for the local node's OWN public IP, so the P2P stats
// "Your IP" flag matches the address shown instead of the coarse continent guess.
// Like fetchOwnPublicEndpoint, this only ever looks up the user's own node
// address; connected peer geolocation stays offline (getApproximateCountryCode)
// so the set of peers a user connects to is never sent to an external API.
export const fetchOwnIpCountryCode = async (ip: string, signal?: AbortSignal): Promise<string | undefined> => {
const cached = ownIpCountryCache.get(ip);
if (cached && Date.now() < cached.expiresAt) return cached.value;
const endpoint = await fetchPublicEndpoint(`${COUNTRY_LOOKUP_URL}/${ip}`, signal);
const value = endpoint?.countryCode;
// See fetchOwnPublicEndpoint: skip caching when the lookup was aborted so a
// cancelled request does not blank the flag for 60s on the next open.
if (!signal?.aborted) ownIpCountryCache.set(ip, { expiresAt: Date.now() + 60_000, value });
return value;
};
export type LatLon = { lat: number; lon: number };
type Region = 'AF' | 'AS' | 'EU' | 'NA' | 'SA';
@@ -99,6 +200,11 @@ export const extractIpv4FromAddress = (address: string): string | null => {
return null;
};
export const extractIpv6FromAddress = (address: string): string | null => {
const direct = /\/ip6\/([^/]+)/.exec(address);
return direct ? direct[1] : null;
};
const parseOctets = (ip: string): number[] | null => {
const parts = ip.split('.').map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
@@ -118,6 +224,17 @@ export const isPrivateOrReservedIpv4 = (ip: string): boolean => {
return false;
};
const isProbablyPublicIpv6 = (ip: string): boolean => {
const normalized = ip.trim().toLowerCase();
if (!normalized.includes(':')) return false;
if (normalized === '::' || normalized === '::1') return false;
if (normalized.startsWith('fe80:') || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('ff')) return false;
if (normalized.startsWith('2001:db8:')) return false;
return /^[0-9a-f:.]+$/.test(normalized);
};
const isPublicIpAddress = (ip: string): boolean => (ip.includes(':') ? isProbablyPublicIpv6(ip) : !isPrivateOrReservedIpv4(ip));
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const hashOctets = (parts: number[]) => {
@@ -134,12 +251,15 @@ export const getApproximateLatLon = (address: string): LatLon | null => {
const parts = parseOctets(ip);
if (!parts) return null;
const centroid = REGION_CENTROIDS[REGION_BY_OCTET[parts[0]]];
const hash = hashOctets(parts);
// Deterministic intra-region spread so peers don't stack on one point. The
// region is the meaningful signal; the offset is cosmetic.
const lonOffset = ((hash % 1000) / 1000 - 0.5) * 24; // +/- 12 deg
const latOffset = ((Math.floor(hash / 1000) % 1000) / 1000 - 0.5) * 14; // +/- 7 deg
// Snap the marker to the centroid of the same country shown as the peer's flag
// (getApproximateCountryCode), falling back to the continent centroid if that
// country has no known centroid. A small deterministic jitter keeps multiple
// peers in one country from stacking exactly while staying near its center.
const country = getApproximateCountryCode(address);
const centroid = (country && COUNTRY_CENTROIDS[country]) || REGION_CENTROIDS[REGION_BY_OCTET[parts[0]]];
const lonOffset = ((hash % 1000) / 1000 - 0.5) * 2.4; // +/- 1.2 deg
const latOffset = ((Math.floor(hash / 1000) % 1000) / 1000 - 0.5) * 1.6; // +/- 0.8 deg
return { lat: clamp(centroid.lat + latOffset, -85, 85), lon: clamp(centroid.lon + lonOffset, -180, 180) };
};