mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge remote-tracking branch 'origin/master'
This commit is contained in:
+399
@@ -45,6 +45,8 @@ const renderSettings = async (isElectron = false) => {
|
||||
const getStatRows = () =>
|
||||
new Map(Array.from(container.querySelectorAll('tr')).map((row) => [row.children.item(0)?.textContent ?? '', row.children.item(1)?.textContent ?? '']));
|
||||
|
||||
const getMarkerByTitle = (title: string) => Array.from(container.querySelectorAll('svg rect')).find((rect) => rect.querySelector('title')?.textContent === title);
|
||||
|
||||
describe('P2PStatsSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -183,6 +185,141 @@ describe('P2PStatsSettings', () => {
|
||||
expect(container.textContent).not.toContain('topic subscribers');
|
||||
});
|
||||
|
||||
it('places peer map markers from city-level IP locations', async () => {
|
||||
const fetchMock = vi.fn(async (url: string | URL | Request) => {
|
||||
const requestUrl = String(url);
|
||||
if (requestUrl === 'https://free.freeipapi.com/api/json/91.234.199.189') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cityName: 'Haarlem',
|
||||
countryCode: 'NL',
|
||||
ipAddress: '91.234.199.189',
|
||||
latitude: 52.3874,
|
||||
longitude: 4.64622,
|
||||
regionName: 'North Holland',
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ country: 'US', ip: '147.75.84.175' }),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
testState.account = {
|
||||
...testState.account,
|
||||
pkcOptions: {
|
||||
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
|
||||
},
|
||||
pkc: {
|
||||
clients: {
|
||||
libp2pJsClients: {
|
||||
libp2pjs: {
|
||||
key: 'libp2pjs',
|
||||
_helia: {
|
||||
libp2p: {
|
||||
getConnections: () => [
|
||||
{
|
||||
remoteAddr: { toString: () => '/ip4/91.234.199.189/tcp/4001/ws/p2p/peer-geo' },
|
||||
remotePeer: { toString: () => 'peer-geo' },
|
||||
},
|
||||
],
|
||||
getMultiaddrs: () => ['/ip4/147.75.84.175/tcp/4001/ws'],
|
||||
getPeers: () => ['peer-geo'],
|
||||
peerId: { toString: () => 'self-peer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await renderSettings(false);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const marker = getMarkerByTitle('peer-geo - Haarlem, North Holland, NL');
|
||||
expect(marker).not.toBeNull();
|
||||
expect(marker?.getAttribute('height')).toBe('3');
|
||||
expect(marker?.getAttribute('width')).toBe('3');
|
||||
expect(Number(marker?.getAttribute('x'))).toBeCloseTo(183.15, 1);
|
||||
expect(Number(marker?.getAttribute('y'))).toBeCloseTo(36.11, 1);
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://free.freeipapi.com/api/json/91.234.199.189', expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
it('shows the own IP flag and a red precise map marker when leeching', async () => {
|
||||
const fetchMock = vi.fn(async (url: string | URL | Request) => {
|
||||
const requestUrl = String(url);
|
||||
if (requestUrl === 'https://api.country.is/117.2.120.113') {
|
||||
return {
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}
|
||||
if (requestUrl === 'https://free.freeipapi.com/api/json/117.2.120.113') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cityName: 'Da Nang',
|
||||
countryCode: 'VN',
|
||||
ipAddress: '117.2.120.113',
|
||||
latitude: 16.0678,
|
||||
longitude: 108.221,
|
||||
regionName: 'Da Nang City',
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ country: 'US', ip: '147.75.84.175' }),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
testState.account = {
|
||||
...testState.account,
|
||||
pkcOptions: {
|
||||
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
|
||||
},
|
||||
pkc: {
|
||||
clients: {
|
||||
libp2pJsClients: {
|
||||
libp2pjs: {
|
||||
key: 'libp2pjs',
|
||||
_helia: {
|
||||
libp2p: {
|
||||
getConnections: () => [],
|
||||
getMultiaddrs: () => ['/ip4/117.2.120.113/tcp/4001/ws'],
|
||||
getPeers: () => [],
|
||||
peerId: { toString: () => 'self-peer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await renderSettings(false);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const rows = getStatRows();
|
||||
const yourIpRow = Array.from(container.querySelectorAll('tr')).find((row) => row.textContent?.includes('Your IP'));
|
||||
const marker = getMarkerByTitle('Your node - Da Nang, Da Nang City, VN');
|
||||
expect(container.textContent).toContain('Leeching');
|
||||
expect(rows.get('Your IP')).toContain('117.2.120.113');
|
||||
expect(yourIpRow?.querySelector('[role="img"]')?.getAttribute('aria-label')).toBe('Vietnam');
|
||||
expect(marker?.getAttribute('data-peer-role')).toBe('leecher');
|
||||
expect(Number(marker?.getAttribute('x'))).toBeCloseTo(286.72, 1);
|
||||
expect(Number(marker?.getAttribute('y'))).toBeCloseTo(72.43, 1);
|
||||
});
|
||||
|
||||
it('reads browser transfer counters from Helia bitswap ledgers', async () => {
|
||||
testState.account = {
|
||||
...testState.account,
|
||||
@@ -354,4 +491,266 @@ describe('P2PStatsSettings', () => {
|
||||
expect(container.querySelector('a[href="https://github.com/bitsocialnet/bitsocial-seeder"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain('seed mode');
|
||||
});
|
||||
|
||||
it('renders browser full-node RPC stats with seeding mode and leecher peer markers', async () => {
|
||||
const fetchMock = vi.fn(async (url: string | URL | Request) => {
|
||||
const requestUrl = String(url);
|
||||
if (requestUrl === 'https://free.freeipapi.com/api/json/147.75.84.175') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cityName: 'New York',
|
||||
countryCode: 'US',
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
regionName: 'New York',
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (requestUrl === 'https://free.freeipapi.com/api/json/91.234.199.189') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cityName: 'Haarlem',
|
||||
countryCode: 'NL',
|
||||
latitude: 52.3874,
|
||||
longitude: 4.64622,
|
||||
regionName: 'North Holland',
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (requestUrl === 'https://free.freeipapi.com/api/json/117.2.120.113') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cityName: 'Da Nang',
|
||||
countryCode: 'VN',
|
||||
latitude: 16.0678,
|
||||
longitude: 108.221,
|
||||
regionName: 'Da Nang City',
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ country: 'US', ip: '147.75.84.175' }),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
testState.rpcSettings = { state: 'connected' };
|
||||
testState.account = {
|
||||
...testState.account,
|
||||
pkcOptions: {
|
||||
pkcRpcClientsOptions: ['ws://147.75.84.175:9138'],
|
||||
},
|
||||
pkc: {
|
||||
clients: {
|
||||
pkcRpcClients: {
|
||||
'ws://147.75.84.175:9138': {
|
||||
getPeers: vi.fn().mockResolvedValue({
|
||||
peers: [
|
||||
{
|
||||
address: '/ip4/91.234.199.189/tcp/4001',
|
||||
listenAddress: '/ip4/91.234.199.189/tcp/4001',
|
||||
peerId: 'seed-peer',
|
||||
},
|
||||
{
|
||||
address: '/ip4/117.2.120.113/tcp/4001',
|
||||
listenAddress: '',
|
||||
peerId: 'leech-peer',
|
||||
},
|
||||
],
|
||||
}),
|
||||
getStats: vi.fn().mockResolvedValue({
|
||||
bandwidth: { TotalIn: 1024, TotalOut: 2048 },
|
||||
identity: {
|
||||
AgentVersion: 'kubo/full-node',
|
||||
ID: 'full-node-peer',
|
||||
Addresses: ['/ip4/147.75.84.175/tcp/4001'],
|
||||
},
|
||||
}),
|
||||
state: 'connected',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await renderSettings(false);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const rows = getStatRows();
|
||||
const markers = Array.from(container.querySelectorAll('svg rect'));
|
||||
const ownMarker = markers.find((marker) => marker.querySelector('title')?.textContent?.startsWith('Your node'));
|
||||
const leecherMarker = markers.find((marker) => marker.querySelector('title')?.textContent?.startsWith('leech-peer'));
|
||||
expect(rows.get('Mode')).toBe('Seeding');
|
||||
expect(rows.get('PKC RPC')).toBe('connected');
|
||||
expect(rows.get('Peer ID')).toBe('full-node-peer');
|
||||
expect(rows.get('Your IP')).toContain('147.75.84.175');
|
||||
expect(rows.get('Data received')).toBe('1.00 KB');
|
||||
expect(rows.get('Data sent')).toBe('2.00 KB');
|
||||
expect(container.textContent).toContain('seed-peer');
|
||||
expect(container.textContent).toContain('leech-peer');
|
||||
expect(container.textContent).toContain('Leeching');
|
||||
expect(container.querySelector('a[href="https://github.com/bitsocialnet/bitsocial-seeder"]')).toBeNull();
|
||||
expect(ownMarker?.getAttribute('data-peer-role')).toBe('seeder');
|
||||
expect(leecherMarker?.getAttribute('data-peer-role')).toBe('leecher');
|
||||
});
|
||||
|
||||
it('does not resolve full-node RPC hostnames through external DNS', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
testState.rpcSettings = { state: 'connected' };
|
||||
testState.account = {
|
||||
...testState.account,
|
||||
pkcOptions: {
|
||||
pkcRpcClientsOptions: ['ws://node.example:9138'],
|
||||
},
|
||||
pkc: {
|
||||
clients: {
|
||||
pkcRpcClients: {
|
||||
'ws://node.example:9138': {
|
||||
getPeers: vi.fn().mockResolvedValue({ peers: [] }),
|
||||
getStats: vi.fn().mockResolvedValue({
|
||||
bandwidth: { TotalIn: 0, TotalOut: 0 },
|
||||
identity: {
|
||||
ID: 'hostname-rpc-node',
|
||||
},
|
||||
}),
|
||||
state: 'connected',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await renderSettings(false);
|
||||
await act(async () => Promise.resolve());
|
||||
|
||||
const rows = getStatRows();
|
||||
expect(rows.get('Mode')).toBe('Seeding');
|
||||
expect(rows.get('Peer ID')).toBe('hostname-rpc-node');
|
||||
expect(rows.get('Your IP')).toBe('unavailable');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders Electron Kubo stats as seeding with the node location on the map', async () => {
|
||||
const fetchMock = vi.fn(async (url: string | URL | Request) => {
|
||||
const requestUrl = String(url);
|
||||
if (requestUrl === 'http://localhost:50019/api/v0/id') {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
Addresses: [
|
||||
'/dns4/77-168-54-121.example/tcp/4001/tls/ws/p2p/relay-peer/p2p-circuit/p2p/desktop-kubo-peer',
|
||||
'/ip4/117.2.120.113/udp/4001/webrtc-direct/p2p/desktop-kubo-peer',
|
||||
],
|
||||
AgentVersion: 'kubo/0.41.0',
|
||||
ID: 'desktop-kubo-peer',
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (requestUrl === 'http://localhost:50019/api/v0/swarm/peers?direction=true&latency=true&streams=true') {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
Peers: [
|
||||
{
|
||||
Addr: '/ip4/91.234.199.189/tcp/4001',
|
||||
Direction: 'outbound',
|
||||
ListenAddress: '/ip4/91.234.199.189/tcp/4001',
|
||||
Peer: 'kubo-seeder',
|
||||
},
|
||||
{
|
||||
Addr: '/ip4/203.0.113.10/tcp/4001',
|
||||
Direction: 'inbound',
|
||||
ListenAddress: '',
|
||||
Peer: 'kubo-leecher',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (requestUrl === 'http://localhost:50019/api/v0/stats/bw') {
|
||||
return { ok: true, text: async () => JSON.stringify({ RateIn: 12, RateOut: 34, TotalIn: 4096, TotalOut: 8192 }) };
|
||||
}
|
||||
if (requestUrl === 'https://free.freeipapi.com/api/json/117.2.120.113') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cityName: 'Da Nang',
|
||||
countryCode: 'VN',
|
||||
latitude: 16.0678,
|
||||
longitude: 108.221,
|
||||
regionName: 'Da Nang City',
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (requestUrl === 'https://free.freeipapi.com/api/json/91.234.199.189') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cityName: 'Haarlem',
|
||||
countryCode: 'NL',
|
||||
latitude: 52.3874,
|
||||
longitude: 4.64622,
|
||||
regionName: 'North Holland',
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ country: 'VN', ip: '117.2.120.113' }),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
testState.rpcSettings = { state: 'connected' };
|
||||
testState.account = {
|
||||
...testState.account,
|
||||
pkcOptions: {
|
||||
pkcRpcClientsOptions: ['ws://localhost:9138'],
|
||||
},
|
||||
};
|
||||
|
||||
await renderSettings(true);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const rows = getStatRows();
|
||||
const markers = Array.from(container.querySelectorAll('svg rect'));
|
||||
const ownMarker = markers.find((marker) => marker.querySelector('title')?.textContent?.startsWith('Your node'));
|
||||
const leecherMarker = markers.find((marker) => marker.querySelector('title')?.textContent?.startsWith('kubo-leecher'));
|
||||
expect(rows.get('Mode')).toBe('Seeding');
|
||||
expect(rows.get('Kubo RPC')).toBe('http://localhost:50019/api/v0');
|
||||
expect(rows.get('Peer ID')).toBe('desktop-kubo-peer');
|
||||
expect(rows.get('Your IP')).toContain('117.2.120.113');
|
||||
expect(rows.get('Your IP')).not.toContain('77.168.54.121');
|
||||
expect(rows.get('Data received')).toBe('4.00 KB');
|
||||
expect(rows.get('Data sent')).toBe('8.00 KB');
|
||||
expect(rows.has('PKC RPC')).toBe(false);
|
||||
expect(rows.has('Agent')).toBe(false);
|
||||
expect(rows.has('Repo size')).toBe(false);
|
||||
expect(rows.has('Repo objects')).toBe(false);
|
||||
expect(rows.has('Bitswap peers')).toBe(false);
|
||||
expect(rows.has('Bitswap wantlist')).toBe(false);
|
||||
expect(rows.has('Bandwidth in')).toBe(false);
|
||||
expect(rows.has('Bandwidth out')).toBe(false);
|
||||
expect(container.textContent).toContain('kubo-seeder');
|
||||
expect(container.textContent).toContain('kubo-leecher');
|
||||
expect(container.textContent).toContain('Leeching');
|
||||
expect(container.textContent).not.toContain('Listen addresses');
|
||||
expect(ownMarker?.getAttribute('data-peer-role')).toBe('seeder');
|
||||
expect(leecherMarker?.getAttribute('data-peer-role')).toBe('leecher');
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('http://localhost:50019/api/v0/repo/stat', expect.anything());
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('http://localhost:50019/api/v0/bitswap/stat', expect.anything());
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('http://localhost:50019/api/v0/version', expect.anything());
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('http://localhost:50019/api/v0/swarm/addrs/local', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,16 +24,57 @@ describe('PeerWorldMap', () => {
|
||||
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' }] }));
|
||||
it('renders the rasterized land backdrop and a marker from a resolved IP location', () => {
|
||||
render(
|
||||
createElement(PeerWorldMap, {
|
||||
peers: [
|
||||
{
|
||||
address: '/ip4/91.234.199.189/tcp/4001',
|
||||
id: 'c1',
|
||||
location: { countryCode: 'nl', label: 'Haarlem, North Holland, NL', lat: 52.3874, lon: 4.64622, source: 'geoip' },
|
||||
peerId: 'peer-1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const landPath = container.querySelector('svg path');
|
||||
const marker = container.querySelector('svg rect');
|
||||
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(marker?.getAttribute('height')).toBe('3');
|
||||
expect(marker?.getAttribute('width')).toBe('3');
|
||||
expect(Number(marker?.getAttribute('x'))).toBeCloseTo(183.15, 1);
|
||||
expect(Number(marker?.getAttribute('y'))).toBeCloseTo(36.11, 1);
|
||||
expect(container.querySelector('svg rect title')?.textContent).toBe('peer-1 - Haarlem, North Holland, NL');
|
||||
});
|
||||
|
||||
it('falls back to the offline location estimate for a public peer without GeoIP data', () => {
|
||||
render(createElement(PeerWorldMap, { peers: [{ address: '/ip4/8.8.8.8/tcp/4001', id: 'c1', peerId: 'peer-1' }] }));
|
||||
expect(container.querySelectorAll('svg rect')).toHaveLength(1);
|
||||
expect(container.querySelector('svg rect title')?.textContent).toBe('peer-1');
|
||||
});
|
||||
|
||||
it('marks leecher locations for red map styling', () => {
|
||||
render(
|
||||
createElement(PeerWorldMap, {
|
||||
peers: [
|
||||
{
|
||||
address: '/ip4/117.2.120.113/tcp/4001',
|
||||
id: 'self',
|
||||
location: { countryCode: 'vn', label: 'Da Nang, Da Nang City, VN', lat: 16.0678, lon: 108.221, source: 'geoip' },
|
||||
peerId: 'Your node',
|
||||
role: 'leecher',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const marker = container.querySelector('svg rect');
|
||||
expect(marker?.getAttribute('data-peer-role')).toBe('leecher');
|
||||
expect(container.querySelector('svg rect title')?.textContent).toBe('Your node - Da Nang, Da Nang City, VN');
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
}
|
||||
|
||||
.connectedPeersSummary {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
padding: 1px 0;
|
||||
}
|
||||
@@ -86,7 +87,8 @@
|
||||
}
|
||||
|
||||
.connectionDirection,
|
||||
.connectionStatus {
|
||||
.connectionStatus,
|
||||
.connectionRole {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -117,6 +119,10 @@
|
||||
background: #3fb950;
|
||||
}
|
||||
|
||||
.connectionRole[data-peer-role='leecher'] {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.peerId {
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
@@ -189,6 +195,10 @@
|
||||
fill: #3fb950;
|
||||
}
|
||||
|
||||
.peerMarker[data-peer-role='leecher'] {
|
||||
fill: red;
|
||||
}
|
||||
|
||||
.peerWorldMapCaption {
|
||||
padding: 1px 4px 2px;
|
||||
font-size: 0.8em;
|
||||
|
||||
@@ -2,7 +2,17 @@ import { Fragment, memo, useEffect, useReducer } from 'react';
|
||||
import { useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getCountryFlagPosition, getCountryLabel, normalizeCountryCode } from '../../../lib/country-flags';
|
||||
import { fetchOwnIpCountryCode, fetchOwnPublicEndpoint, getApproximateCountryCode, getFirstPublicIpFromAddresses, type PublicEndpoint } from '../../../lib/peer-geo';
|
||||
import {
|
||||
fetchIpMapLocation,
|
||||
fetchOwnIpCountryCode,
|
||||
fetchOwnPublicEndpoint,
|
||||
fetchPeerMapLocation,
|
||||
getApproximateCountryCode,
|
||||
getFirstPublicIpFromAddresses,
|
||||
isPrivateOrReservedIpv4,
|
||||
type PeerMapLocation,
|
||||
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';
|
||||
@@ -17,16 +27,30 @@ type TextStatRow = {
|
||||
|
||||
type ConnectedPeerEntry = {
|
||||
address: string;
|
||||
countryCode?: string;
|
||||
direction?: string;
|
||||
id: string;
|
||||
location?: PeerMapLocation;
|
||||
peerId: string;
|
||||
role?: PeerConnectionRole;
|
||||
status?: string;
|
||||
transport: string;
|
||||
};
|
||||
|
||||
type PeerConnectionRole = 'leecher' | 'seeder';
|
||||
|
||||
type PeerMapEntry = {
|
||||
address: string;
|
||||
id: string;
|
||||
location?: PeerMapLocation;
|
||||
peerId: string;
|
||||
role?: PeerConnectionRole;
|
||||
};
|
||||
|
||||
type ConnectedPeersStatRow = {
|
||||
connectionCount: number;
|
||||
entries: ConnectedPeerEntry[];
|
||||
mapEntries?: PeerMapEntry[];
|
||||
name: string;
|
||||
peerCount: number;
|
||||
type: 'connectedPeers';
|
||||
@@ -98,6 +122,12 @@ type Libp2pAddressManagerShape = {
|
||||
|
||||
type BrowserLibp2pShape = NonNullable<NonNullable<NonNullable<Libp2pClientShape['_helia']>['libp2p']>>;
|
||||
|
||||
type PkcRpcClientShape = {
|
||||
getPeers?: () => unknown | Promise<unknown>;
|
||||
getStats?: () => unknown | Promise<unknown>;
|
||||
state?: string;
|
||||
};
|
||||
|
||||
type TransferStats = {
|
||||
downloadedBytes?: number;
|
||||
uploadedBytes?: number;
|
||||
@@ -141,7 +171,7 @@ const formatBytes = (value: unknown) => {
|
||||
return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatRate = (value: unknown) => `${formatBytes(value)}/s`;
|
||||
const formatOptionalBytes = (value: unknown) => (getFiniteNumber(value) === undefined ? 'unknown' : formatBytes(value));
|
||||
|
||||
const getFirstObjectValue = <T,>(value?: Record<string, T>) => (value ? Object.values(value)[0] : undefined);
|
||||
|
||||
@@ -186,6 +216,46 @@ const toArray = (value: unknown): unknown[] => {
|
||||
return [];
|
||||
};
|
||||
|
||||
const getRecordField = (record: unknown, fields: string[]) => {
|
||||
if (!isRecord(record)) return undefined;
|
||||
for (const field of fields) {
|
||||
if (field in record) return record[field];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getStringField = (record: unknown, fields: string[], fallback = '') => {
|
||||
const value = getRecordField(record, fields);
|
||||
if (Array.isArray(value)) return getStringValue(value[0], fallback);
|
||||
return getStringValue(value, fallback);
|
||||
};
|
||||
|
||||
const getAddressValues = (value: unknown): string[] => {
|
||||
const iterableValues = Array.isArray(value) ? value : toArray(value);
|
||||
const values = iterableValues.length ? iterableValues : [value];
|
||||
return values.flatMap((entry) => {
|
||||
const address = getStringValue(entry, '');
|
||||
return address ? [address] : [];
|
||||
});
|
||||
};
|
||||
|
||||
const getNestedValue = (source: unknown, path: string[]) => {
|
||||
let current = source;
|
||||
for (const key of path) {
|
||||
if (!isRecord(current) || !(key in current)) return undefined;
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const getFirstNestedValue = (source: unknown, paths: string[][]) => {
|
||||
for (const path of paths) {
|
||||
const value = getNestedValue(source, path);
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getSafeArray = async (getValue?: () => unknown[] | Promise<unknown[]> | undefined): Promise<unknown[]> => {
|
||||
try {
|
||||
return toArray(getValue ? await getValue() : undefined);
|
||||
@@ -209,6 +279,14 @@ const getAddressManagerAddresses = async (libp2p?: BrowserLibp2pShape): Promise<
|
||||
];
|
||||
};
|
||||
|
||||
const getFirstPkcRpcClient = (account?: AccountShape) => getFirstObjectValue(account?.pkc?.clients?.pkcRpcClients) as PkcRpcClientShape | undefined;
|
||||
|
||||
const getPkcRpcUrls = (account?: AccountShape) => {
|
||||
const optionUrls = Array.isArray(account?.pkcOptions?.pkcRpcClientsOptions) ? account.pkcOptions.pkcRpcClientsOptions : [];
|
||||
const clientUrls = isRecord(account?.pkc?.clients?.pkcRpcClients) ? Object.keys(account.pkc.clients.pkcRpcClients) : [];
|
||||
return [...new Set([...optionUrls, ...clientUrls].filter((url): url is string => typeof url === 'string' && url.trim().length > 0))];
|
||||
};
|
||||
|
||||
const getByteLength = (value: unknown): number | undefined => {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value === 'string') return new TextEncoder().encode(value).byteLength;
|
||||
@@ -463,6 +541,43 @@ const getBrowserMode = (client?: Libp2pClientShape) => {
|
||||
return hasSupportedAdd(client) && hasProviderPublishingRouter(client) ? 'Seeding' : 'Leeching';
|
||||
};
|
||||
|
||||
const getEndpointAddress = (ip: string) => (ip.includes(':') ? `/ip6/${ip}/tcp/0` : `/ip4/${ip}/tcp/0`);
|
||||
|
||||
const isIpv4Address = (value: string) => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value);
|
||||
|
||||
const isLikelyPublicIp = (value: string) => {
|
||||
const ip = value.trim();
|
||||
if (!ip) return false;
|
||||
if (isIpv4Address(ip)) return !isPrivateOrReservedIpv4(ip);
|
||||
if (!ip.includes(':')) return false;
|
||||
const normalized = ip.toLowerCase();
|
||||
return normalized !== '::1' && !normalized.startsWith('fe80:') && !normalized.startsWith('fc') && !normalized.startsWith('fd');
|
||||
};
|
||||
|
||||
const getHostnameFromUrl = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^\[|\]$/g, '');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveEndpointFromIp = async (ip: string, signal?: AbortSignal): Promise<PublicEndpoint | undefined> => {
|
||||
if (!isLikelyPublicIp(ip)) return undefined;
|
||||
const location = await fetchIpMapLocation(ip, signal);
|
||||
return {
|
||||
countryCode: location?.countryCode ?? getApproximateCountryCode(getEndpointAddress(ip)),
|
||||
ip,
|
||||
location,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveEndpointFromHost = async (hostname: string, signal?: AbortSignal): Promise<PublicEndpoint | undefined> => {
|
||||
const normalizedHostname = hostname.trim().toLowerCase();
|
||||
if (!normalizedHostname || normalizedHostname === 'localhost') return undefined;
|
||||
return isLikelyPublicIp(normalizedHostname) ? resolveEndpointFromIp(normalizedHostname, signal) : undefined;
|
||||
};
|
||||
|
||||
const getTransportLabel = (address: string) => {
|
||||
const normalizedAddress = address.toLowerCase();
|
||||
let transport = 'Unknown transport';
|
||||
@@ -511,27 +626,66 @@ 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 PEER_LIST_FIELDS = ['Peers', 'peers', 'connectedPeers', 'connections'];
|
||||
const PEER_ADDRESS_FIELDS = ['Addr', 'address', 'addr', 'remoteAddr', 'multiaddr', 'multiaddrString'];
|
||||
const PEER_ID_FIELDS = ['Peer', 'peer', 'peerId', 'id', 'remotePeer'];
|
||||
const PEER_DIRECTION_FIELDS = ['Direction', 'direction'];
|
||||
const PEER_STATUS_FIELDS = ['Status', 'status', 'state'];
|
||||
const PEER_ROLE_FIELDS = ['role', 'Role', 'mode', 'Mode', 'connectionRole', 'connectionType'];
|
||||
const PEER_LISTEN_ADDRESS_FIELDS = ['listenAddress', 'listenAddresses', 'ListenAddress', 'ListenAddresses'];
|
||||
|
||||
const normalizePeerRecords = (peers: unknown): unknown[] => {
|
||||
if (isRecord(peers)) {
|
||||
for (const field of PEER_LIST_FIELDS) {
|
||||
const value = peers[field];
|
||||
if (Array.isArray(value)) return value;
|
||||
}
|
||||
return Object.values(peers);
|
||||
}
|
||||
return toArray(peers);
|
||||
};
|
||||
|
||||
const getElectronConnectedPeersRow = (peers: unknown): ConnectedPeersStatRow => {
|
||||
const peerEntries = isRecord(peers) && Array.isArray(peers.Peers) ? peers.Peers : [];
|
||||
const entries = peerEntries.map<ConnectedPeerEntry>((peer) => {
|
||||
const address = isRecord(peer) ? getStringValue(peer.Addr, 'address unavailable') : 'address unavailable';
|
||||
const peerId = isRecord(peer) ? getStringValue(peer.Peer) : 'unknown';
|
||||
const direction = isRecord(peer) ? getStringValue(peer.Direction, '') : undefined;
|
||||
const getPeerAddress = (peer: unknown) => {
|
||||
const address = getStringField(peer, PEER_ADDRESS_FIELDS, '');
|
||||
if (address) return address;
|
||||
const listenAddress = getAddressValues(getRecordField(peer, PEER_LISTEN_ADDRESS_FIELDS))[0];
|
||||
return listenAddress || 'address unavailable';
|
||||
};
|
||||
|
||||
const normalizePeerRole = (value: unknown): PeerConnectionRole | undefined => {
|
||||
const role = getStringValue(value, '').toLowerCase();
|
||||
if (!role) return undefined;
|
||||
if (role.includes('leech') || role === 'client') return 'leecher';
|
||||
if (role.includes('seed') || role === 'server') return 'seeder';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getListenAddressRole = (peer: unknown): PeerConnectionRole | undefined => {
|
||||
if (!isRecord(peer)) return undefined;
|
||||
for (const field of PEER_LISTEN_ADDRESS_FIELDS) {
|
||||
if (!(field in peer)) continue;
|
||||
return getAddressValues(peer[field]).length > 0 ? 'seeder' : 'leecher';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getPeerConnectionRole = (peer: unknown) => normalizePeerRole(getRecordField(peer, PEER_ROLE_FIELDS)) ?? getListenAddressRole(peer);
|
||||
|
||||
const getConnectedPeersRowFromRecords = (peers: unknown): ConnectedPeersStatRow => {
|
||||
const peerRecords = normalizePeerRecords(peers);
|
||||
const entries = peerRecords.map<ConnectedPeerEntry>((peer, index) => {
|
||||
const address = getPeerAddress(peer);
|
||||
const peerId = getStringField(peer, PEER_ID_FIELDS, getStringValue(peer, 'unknown'));
|
||||
const direction = getStringField(peer, PEER_DIRECTION_FIELDS, '');
|
||||
const status = getStringField(peer, PEER_STATUS_FIELDS, '');
|
||||
const fallbackId = `${peerId}-${address}-${direction}-${index}`;
|
||||
return {
|
||||
address,
|
||||
direction,
|
||||
id: `${peerId}-${address}-${direction ?? ''}`,
|
||||
direction: direction || undefined,
|
||||
id: fallbackId,
|
||||
peerId,
|
||||
role: getPeerConnectionRole(peer),
|
||||
status: status || undefined,
|
||||
transport: getTransportLabel(address),
|
||||
};
|
||||
});
|
||||
@@ -539,16 +693,94 @@ const getElectronConnectedPeersRow = (peers: unknown): ConnectedPeersStatRow =>
|
||||
if (entry.peerId && entry.peerId !== 'unknown') ids.add(entry.peerId);
|
||||
return ids;
|
||||
}, new Set());
|
||||
const peerCount = getFiniteNumber(getRecordField(peers, ['peerCount', 'peersCount', 'PeerCount']));
|
||||
const connectionCount = getFiniteNumber(getRecordField(peers, ['connectionCount', 'connectionsCount', 'ConnectionCount']));
|
||||
|
||||
return {
|
||||
connectionCount: entries.length,
|
||||
connectionCount: connectionCount ?? entries.length,
|
||||
entries,
|
||||
name: 'Connected peers',
|
||||
peerCount: peerIds.size || entries.length,
|
||||
peerCount: peerCount ?? (peerIds.size || entries.length),
|
||||
type: 'connectedPeers',
|
||||
};
|
||||
};
|
||||
|
||||
const resolveConnectedPeerLocations = async (row: ConnectedPeersStatRow, signal?: AbortSignal): Promise<ConnectedPeersStatRow> => {
|
||||
if (!row.entries.length) return row;
|
||||
const lookups = new Map<string, Promise<PeerMapLocation | undefined>>();
|
||||
const entries = await Promise.all(
|
||||
row.entries.map(async (entry) => {
|
||||
let lookup = lookups.get(entry.address);
|
||||
if (!lookup) {
|
||||
lookup = fetchPeerMapLocation(entry.address, signal);
|
||||
lookups.set(entry.address, lookup);
|
||||
}
|
||||
const location = await lookup;
|
||||
if (!location) return entry;
|
||||
return {
|
||||
...entry,
|
||||
countryCode: location.countryCode ?? entry.countryCode,
|
||||
location,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return { ...row, entries };
|
||||
};
|
||||
|
||||
// Resolves the "Your IP" row from observed node addresses for browser/full-node
|
||||
// paths. Electron Kubo uses resolveKuboOwnEndpoint below because its address list
|
||||
// can include relay/circuit endpoints owned by other peers.
|
||||
const resolveOwnEndpoint = async (addresses: unknown[], signal?: AbortSignal): Promise<PublicEndpoint | undefined> => {
|
||||
const ip = getFirstPublicIpFromAddresses(addresses);
|
||||
if (ip) {
|
||||
const [countryCode, location] = await Promise.all([fetchOwnIpCountryCode(ip, signal), fetchIpMapLocation(ip, signal)]);
|
||||
return {
|
||||
countryCode: location?.countryCode ?? countryCode ?? getApproximateCountryCode(getEndpointAddress(ip)),
|
||||
ip,
|
||||
location,
|
||||
};
|
||||
}
|
||||
return fetchOwnPublicEndpoint(signal);
|
||||
};
|
||||
|
||||
const resolveKuboOwnEndpoint = async (addresses: unknown[], signal?: AbortSignal): Promise<PublicEndpoint | undefined> => {
|
||||
const endpoint = await fetchOwnPublicEndpoint(signal);
|
||||
if (endpoint) return endpoint;
|
||||
const directAddresses = addresses.filter((address) => !getStringValue(address, '').toLowerCase().includes('/p2p-circuit'));
|
||||
return resolveOwnEndpoint(directAddresses.length ? directAddresses : addresses, signal);
|
||||
};
|
||||
|
||||
const getOwnMapEntry = (endpoint: PublicEndpoint | undefined, mode: string): PeerMapEntry[] => {
|
||||
if (!endpoint?.location) return [];
|
||||
return [
|
||||
{
|
||||
address: getEndpointAddress(endpoint.ip),
|
||||
id: 'self-node-endpoint',
|
||||
location: endpoint.location,
|
||||
peerId: 'Your node',
|
||||
role: mode === 'Leeching' ? 'leecher' : 'seeder',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getPeerMapEntries = (row: ConnectedPeersStatRow): PeerMapEntry[] =>
|
||||
row.entries.map((entry) => ({
|
||||
address: entry.address,
|
||||
id: entry.id,
|
||||
location: entry.location,
|
||||
peerId: entry.peerId,
|
||||
role: entry.role ?? 'seeder',
|
||||
}));
|
||||
|
||||
const getElectronConnectedPeersRow = (peers: unknown): ConnectedPeersStatRow => getConnectedPeersRowFromRecords(peers);
|
||||
|
||||
const getAddressListFromRecord = (record: unknown) =>
|
||||
[
|
||||
...getAddressValues(getRecordField(record, ['Addresses', 'addresses'])),
|
||||
...getAddressValues(getRecordField(record, ['listenAddress', 'listenAddresses', 'ListenAddress', 'ListenAddresses'])),
|
||||
...getAddressValues(getRecordField(record, ['multiaddr', 'multiaddrs', 'Multiaddrs'])),
|
||||
].filter(Boolean);
|
||||
|
||||
const getBrowserLibp2pStats = async (account?: AccountShape, signal?: AbortSignal): Promise<StatRow[]> => {
|
||||
const client = getFirstObjectValue(account?.pkc?.clients?.libp2pJsClients) as Libp2pClientShape | undefined;
|
||||
const libp2p = client?._helia?.libp2p;
|
||||
@@ -558,20 +790,112 @@ const getBrowserLibp2pStats = async (account?: AccountShape, signal?: AbortSigna
|
||||
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);
|
||||
const connectedPeersRow = getBrowserConnectedPeersRow(peers, connections);
|
||||
const mode = getBrowserMode(client);
|
||||
const [transferStats, nodeEndpoint, connectedPeers] = await Promise.all([
|
||||
getBrowserTransferStats(client, connections),
|
||||
resolveOwnEndpoint([...multiaddrs, ...addressManagerAddresses, ...localAddresses], signal),
|
||||
resolveConnectedPeerLocations(connectedPeersRow, signal),
|
||||
]);
|
||||
const connectedPeersWithMapEntries = {
|
||||
...connectedPeers,
|
||||
mapEntries: [...getOwnMapEntry(nodeEndpoint, mode), ...getPeerMapEntries(connectedPeers)],
|
||||
};
|
||||
|
||||
return [
|
||||
{ name: 'Mode', value: getBrowserMode(client) },
|
||||
{ name: 'Mode', value: mode },
|
||||
{ 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),
|
||||
connectedPeersWithMapEntries,
|
||||
];
|
||||
};
|
||||
|
||||
const getFullNodeRpcEndpoint = async (account?: AccountShape, signal?: AbortSignal): Promise<PublicEndpoint | undefined> => {
|
||||
const hostnames = getPkcRpcUrls(account).flatMap((rpcUrl) => {
|
||||
const hostname = getHostnameFromUrl(rpcUrl);
|
||||
return hostname ? [hostname] : [];
|
||||
});
|
||||
const hasRemoteHost = hostnames.some((hostname) => hostname !== 'localhost' && hostname !== '127.0.0.1' && hostname !== '::1');
|
||||
const endpoints = await Promise.all(hostnames.map((hostname) => resolveEndpointFromHost(hostname, signal)));
|
||||
const endpoint = endpoints.find(Boolean);
|
||||
if (endpoint) return endpoint;
|
||||
return hasRemoteHost ? undefined : fetchOwnPublicEndpoint(signal);
|
||||
};
|
||||
|
||||
const isImplementedRpcMethod = (method: unknown): method is () => unknown | Promise<unknown> => {
|
||||
if (typeof method !== 'function') return false;
|
||||
const source = getFunctionSource(method);
|
||||
return !source?.includes('not implemented');
|
||||
};
|
||||
|
||||
const callPkcRpcMethod = async (client: PkcRpcClientShape | undefined, methodName: 'getPeers' | 'getStats') => {
|
||||
const method = client?.[methodName];
|
||||
if (!isImplementedRpcMethod(method)) return undefined;
|
||||
try {
|
||||
return await method.call(client);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getRpcIdentity = (stats: unknown) => getFirstNestedValue(stats, [['identity'], ['Identity'], ['id'], ['node']]);
|
||||
|
||||
const getRpcPeerId = (stats: unknown) => {
|
||||
const identity = getRpcIdentity(stats);
|
||||
return getStringValue(getRecordField(identity, ['ID', 'id', 'PeerID', 'peerId']) ?? getRecordField(stats, ['ID', 'id', 'PeerID', 'peerId']));
|
||||
};
|
||||
|
||||
const getRpcAgent = (stats: unknown) => {
|
||||
const identity = getRpcIdentity(stats);
|
||||
return getStringValue(getRecordField(identity, ['AgentVersion', 'agentVersion', 'agent']) ?? getRecordField(stats, ['AgentVersion', 'agentVersion', 'agent']));
|
||||
};
|
||||
|
||||
const getRpcBandwidthValue = (stats: unknown, fields: string[][]) => getFirstNestedValue(stats, fields);
|
||||
|
||||
const getFullNodeRpcStats = async (account?: AccountShape, rpcState?: string, signal?: AbortSignal): Promise<StatRow[]> => {
|
||||
const rpcClient = getFirstPkcRpcClient(account);
|
||||
const [stats, rpcPeers] = await Promise.all([callPkcRpcMethod(rpcClient, 'getStats'), callPkcRpcMethod(rpcClient, 'getPeers')]);
|
||||
const identity = getRpcIdentity(stats);
|
||||
const statsAddresses = [...getAddressListFromRecord(identity), ...getAddressListFromRecord(stats)];
|
||||
const peerRecords = rpcPeers ?? getFirstNestedValue(stats, [['peers'], ['Peers'], ['connectedPeers'], ['connections']]);
|
||||
const connectedPeers = await resolveConnectedPeerLocations(getConnectedPeersRowFromRecords(peerRecords), signal);
|
||||
const nodeEndpoint = statsAddresses.length ? await resolveOwnEndpoint(statsAddresses, signal) : await getFullNodeRpcEndpoint(account, signal);
|
||||
const connectedPeersWithMapEntries = {
|
||||
...connectedPeers,
|
||||
mapEntries: [...getOwnMapEntry(nodeEndpoint, 'Seeding'), ...getPeerMapEntries(connectedPeers)],
|
||||
};
|
||||
const bandwidthIn = getRpcBandwidthValue(stats, [
|
||||
['bandwidth', 'TotalIn'],
|
||||
['bandwidth', 'totalIn'],
|
||||
['Bandwidth', 'TotalIn'],
|
||||
['TotalIn'],
|
||||
['totalIn'],
|
||||
['downloadedBytes'],
|
||||
]);
|
||||
const bandwidthOut = getRpcBandwidthValue(stats, [
|
||||
['bandwidth', 'TotalOut'],
|
||||
['bandwidth', 'totalOut'],
|
||||
['Bandwidth', 'TotalOut'],
|
||||
['TotalOut'],
|
||||
['totalOut'],
|
||||
['uploadedBytes'],
|
||||
]);
|
||||
|
||||
return [
|
||||
{ name: 'Mode', value: 'Seeding' },
|
||||
{ name: 'PKC RPC', value: rpcClient?.state ?? rpcState ?? 'unknown' },
|
||||
{ name: 'Peer ID', value: getRpcPeerId(stats) },
|
||||
nodeEndpoint ? { countryCode: nodeEndpoint.countryCode, ip: nodeEndpoint.ip, name: 'Your IP', type: 'nodeEndpoint' } : { name: 'Your IP', value: 'unavailable' },
|
||||
...(getRpcAgent(stats) !== 'unknown' ? [{ name: 'Agent', value: getRpcAgent(stats) } satisfies TextStatRow] : []),
|
||||
{ name: 'Data received', value: formatOptionalBytes(bandwidthIn) },
|
||||
{ name: 'Data sent', value: formatOptionalBytes(bandwidthOut) },
|
||||
connectedPeersWithMapEntries,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -589,34 +913,37 @@ const kuboPostJson = async (path: string, params?: Record<string, string | boole
|
||||
return firstJsonLine ? JSON.parse(firstJsonLine) : {};
|
||||
};
|
||||
|
||||
const getElectronKuboStats = async (rpcState?: string, signal?: AbortSignal): Promise<StatRow[]> => {
|
||||
const [identity, version, peers, bandwidth, repo, bitswap] = await Promise.all([
|
||||
const getElectronKuboStats = async (signal?: AbortSignal): Promise<StatRow[]> => {
|
||||
const [identity, peers, bandwidth] = await Promise.all([
|
||||
kuboPostJson('id', undefined, signal),
|
||||
kuboPostJson('version', undefined, signal),
|
||||
kuboPostJson('swarm/peers', { direction: true, latency: true, streams: true }, signal),
|
||||
kuboPostJson('stats/bw', undefined, signal),
|
||||
kuboPostJson('repo/stat', undefined, signal),
|
||||
kuboPostJson('bitswap/stat', undefined, signal),
|
||||
]);
|
||||
const peerId = getStringValue(identity.ID, 'unknown');
|
||||
const [nodeEndpoint, connectedPeers] = await Promise.all([
|
||||
resolveKuboOwnEndpoint(getAddressListFromRecord(identity), signal),
|
||||
resolveConnectedPeerLocations(getElectronConnectedPeersRow(peers), signal),
|
||||
]);
|
||||
const connectedPeersWithMapEntries = {
|
||||
...connectedPeers,
|
||||
mapEntries: [...getOwnMapEntry(nodeEndpoint, 'Seeding'), ...getPeerMapEntries(connectedPeers)],
|
||||
};
|
||||
|
||||
return [
|
||||
{ name: 'Mode', value: 'Desktop Kubo' },
|
||||
{ name: 'PKC RPC', value: rpcState ?? 'unknown' },
|
||||
{ name: 'Peer ID', value: identity.ID ?? 'unknown' },
|
||||
{ name: 'Agent', value: identity.AgentVersion ?? version.Version ?? 'unknown' },
|
||||
{ name: 'Bandwidth in', value: `${formatBytes(bandwidth.TotalIn)} total, ${formatRate(bandwidth.RateIn)}` },
|
||||
{ name: 'Bandwidth out', value: `${formatBytes(bandwidth.TotalOut)} total, ${formatRate(bandwidth.RateOut)}` },
|
||||
{ name: 'Repo size', value: formatBytes(repo.RepoSize) },
|
||||
{ name: 'Repo objects', value: String(repo.NumObjects ?? 'unknown') },
|
||||
{ name: 'Bitswap peers', value: formatCount(Array.isArray(bitswap.Peers) ? bitswap.Peers.length : 0, 'peer') },
|
||||
{ name: 'Bitswap wantlist', value: formatCount(Array.isArray(bitswap.Wantlist) ? bitswap.Wantlist.length : 0, 'item') },
|
||||
getElectronConnectedPeersRow(peers),
|
||||
{ name: 'Mode', value: 'Seeding' },
|
||||
{ name: 'Kubo RPC', value: KUBO_API_URL },
|
||||
{ name: 'Peer ID', value: peerId },
|
||||
nodeEndpoint ? { countryCode: nodeEndpoint.countryCode, ip: nodeEndpoint.ip, name: 'Your IP', type: 'nodeEndpoint' } : { name: 'Your IP', value: 'unavailable' },
|
||||
{ name: 'Data received', value: formatOptionalBytes(bandwidth.TotalIn) },
|
||||
{ name: 'Data sent', value: formatOptionalBytes(bandwidth.TotalOut) },
|
||||
connectedPeersWithMapEntries,
|
||||
];
|
||||
};
|
||||
|
||||
const getP2PStats = async (mode: P2PRuntimeMode, account?: AccountShape, rpcState?: string, signal?: AbortSignal) => {
|
||||
if (mode === 'browser-libp2p') return getBrowserLibp2pStats(account, signal);
|
||||
return getElectronKuboStats(rpcState, signal);
|
||||
if (mode === 'full-node-rpc') return getFullNodeRpcStats(account, rpcState, signal);
|
||||
return getElectronKuboStats(signal);
|
||||
};
|
||||
|
||||
const NodeEndpointValue = ({ row }: { row: NodeEndpointStatRow }) => {
|
||||
@@ -660,11 +987,11 @@ const ConnectedPeersValue = ({ row }: { row: ConnectedPeersStatRow }) => (
|
||||
<summary className={styles.connectedPeersSummary}>
|
||||
{row.name}: {formatCount(row.peerCount, 'peer')}, {formatCount(row.connectionCount, 'connection')}
|
||||
</summary>
|
||||
<PeerWorldMap peers={row.entries} />
|
||||
<PeerWorldMap peers={row.mapEntries ?? row.entries} />
|
||||
<div className={styles.connectedPeerList}>
|
||||
{row.entries.length ? (
|
||||
row.entries.map((entry) => {
|
||||
const countryCode = getApproximateCountryCode(entry.address);
|
||||
const countryCode = entry.countryCode ?? getApproximateCountryCode(entry.address);
|
||||
const flagPosition = getCountryFlagPosition(countryCode);
|
||||
return (
|
||||
<div className={styles.connectedPeer} key={entry.id}>
|
||||
@@ -680,6 +1007,11 @@ const ConnectedPeersValue = ({ row }: { row: ConnectedPeersStatRow }) => (
|
||||
{entry.status}
|
||||
</span>
|
||||
)}
|
||||
{entry.role && (
|
||||
<span className={styles.connectionRole} data-peer-role={entry.role}>
|
||||
{entry.role === 'leecher' ? 'Leeching' : 'Seeding'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.peerId} title={entry.peerId}>
|
||||
{entry.peerId}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { WORLD_MAP_DOTS } from '../../../data/world-map-dots';
|
||||
import { getApproximateLatLon } from '../../../lib/peer-geo';
|
||||
import { getApproximateLatLon, type PeerMapLocation } from '../../../lib/peer-geo';
|
||||
import styles from './p2p-stats-settings.module.css';
|
||||
|
||||
type MapPeer = {
|
||||
address: string;
|
||||
id: string;
|
||||
location?: PeerMapLocation;
|
||||
peerId: string;
|
||||
role?: 'leecher' | 'seeder';
|
||||
};
|
||||
|
||||
// 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 PEER_MARKER_SIZE = 3;
|
||||
const PEER_MARKER_OFFSET = PEER_MARKER_SIZE / 2;
|
||||
|
||||
// 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
|
||||
@@ -34,10 +38,10 @@ const LAND_PATH = (() => {
|
||||
})();
|
||||
|
||||
const PeerWorldMap = ({ peers }: { peers: MapPeer[] }) => {
|
||||
const plotted: { id: string; peerId: string; x: number; y: number }[] = [];
|
||||
const plotted: { id: string; label?: string; peerId: string; role?: 'leecher' | 'seeder'; x: number; y: number }[] = [];
|
||||
for (const peer of peers) {
|
||||
const location = getApproximateLatLon(peer.address);
|
||||
if (location) plotted.push({ id: peer.id, peerId: peer.peerId, x: location.lon + 180, y: 90 - location.lat });
|
||||
const location = peer.location ?? getApproximateLatLon(peer.address);
|
||||
if (location) plotted.push({ id: peer.id, label: peer.location?.label, peerId: peer.peerId, role: peer.role, x: location.lon + 180, y: 90 - location.lat });
|
||||
}
|
||||
|
||||
if (!plotted.length) return null;
|
||||
@@ -47,12 +51,20 @@ const PeerWorldMap = ({ peers }: { peers: MapPeer[] }) => {
|
||||
<svg className={styles.peerWorldMapSvg} viewBox='0 8 360 140' shapeRendering='crispEdges' role='img' aria-label='Approximate peer locations'>
|
||||
<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>
|
||||
<rect
|
||||
className={styles.peerMarker}
|
||||
data-peer-role={peer.role}
|
||||
height={PEER_MARKER_SIZE}
|
||||
key={peer.id}
|
||||
width={PEER_MARKER_SIZE}
|
||||
x={peer.x - PEER_MARKER_OFFSET}
|
||||
y={peer.y - PEER_MARKER_OFFSET}
|
||||
>
|
||||
<title>{peer.label ? `${peer.peerId} - ${peer.label}` : peer.peerId}</title>
|
||||
</rect>
|
||||
))}
|
||||
</svg>
|
||||
<div className={styles.peerWorldMapCaption}>approximate locations</div>
|
||||
<div className={styles.peerWorldMapCaption}>approximate IP locations</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,11 +49,11 @@ describe('p2p-runtime', () => {
|
||||
expect(getP2PRuntimeMode({ pkc: { clients: { libp2pJsClients: { libp2pjs: {} } } } }, browserWindow)).toBe('browser-libp2p');
|
||||
});
|
||||
|
||||
it('detects electron Kubo RPC accounts only in electron runtime', () => {
|
||||
it('detects full-node RPC accounts in browser and electron runtimes', () => {
|
||||
const account = { pkcOptions: { pkcRpcClientsOptions: ['ws://localhost:9138'] } };
|
||||
|
||||
expect(getP2PRuntimeMode(account, electronWindow)).toBe('electron-kubo-rpc');
|
||||
expect(getP2PRuntimeMode(account, browserWindow)).toBeNull();
|
||||
expect(getP2PRuntimeMode(account, browserWindow)).toBe('full-node-rpc');
|
||||
});
|
||||
|
||||
it('shows p2p settings in browsers when pure p2p is enabled by default', () => {
|
||||
@@ -70,6 +70,13 @@ describe('p2p-runtime', () => {
|
||||
expect(isBrowserPureP2PEnabled(gatewayAccount, p2pBrowserWindowWithDisabledPureP2P)).toBe(false);
|
||||
});
|
||||
|
||||
it('still shows browser full-node RPC stats when browser pure p2p was toggled off', () => {
|
||||
const account = { pkcOptions: { pkcRpcClientsOptions: ['ws://node.example'] } };
|
||||
|
||||
expect(isBrowserPureP2PEnabled(account, browserWindowWithDisabledPureP2P)).toBe(false);
|
||||
expect(shouldShowP2PSettingsSection(account, browserWindowWithDisabledPureP2P)).toBe(true);
|
||||
});
|
||||
|
||||
it('builds browser p2p and gateway account options without a direct pkc-js import', () => {
|
||||
const account = {
|
||||
pkcOptions: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
extractIpv6FromAddress,
|
||||
fetchOwnIpCountryCode,
|
||||
fetchOwnPublicEndpoint,
|
||||
fetchPeerMapLocation,
|
||||
getApproximateCountryCode,
|
||||
getApproximateLatLon,
|
||||
getFirstPublicIpFromAddresses,
|
||||
@@ -98,15 +99,34 @@ describe('getFirstPublicIpFromAddresses', () => {
|
||||
|
||||
describe('fetchOwnPublicEndpoint', () => {
|
||||
it('caches the fetched public endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
const fetchMock = vi.fn(async (url: string | URL | Request) => ({
|
||||
ok: true,
|
||||
json: async () => ({ country: 'US', ip: '2001:4860:4860::8888' }),
|
||||
});
|
||||
json: async () =>
|
||||
String(url).startsWith('https://free.freeipapi.com/api/json/')
|
||||
? {
|
||||
cityName: 'Mountain View',
|
||||
countryCode: 'US',
|
||||
latitude: 37.422,
|
||||
longitude: -122.085,
|
||||
regionName: 'California',
|
||||
}
|
||||
: { 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);
|
||||
await expect(fetchOwnPublicEndpoint()).resolves.toMatchObject({
|
||||
countryCode: 'us',
|
||||
ip: '2001:4860:4860::8888',
|
||||
location: {
|
||||
countryCode: 'us',
|
||||
label: 'Mountain View, California, US',
|
||||
lat: 37.422,
|
||||
lon: -122.085,
|
||||
source: 'geoip',
|
||||
},
|
||||
});
|
||||
await expect(fetchOwnPublicEndpoint()).resolves.toMatchObject({ countryCode: 'us', ip: '2001:4860:4860::8888' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -147,6 +167,65 @@ describe('fetchOwnIpCountryCode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPeerMapLocation', () => {
|
||||
it('resolves and caches a city-level peer location', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cityName: 'Haarlem',
|
||||
countryCode: 'NL',
|
||||
ipAddress: '91.234.199.189',
|
||||
latitude: 52.3874,
|
||||
longitude: 4.64622,
|
||||
regionName: 'North Holland',
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(fetchPeerMapLocation('/ip4/91.234.199.189/tcp/4001')).resolves.toMatchObject({
|
||||
countryCode: 'nl',
|
||||
label: 'Haarlem, North Holland, NL',
|
||||
lat: 52.3874,
|
||||
lon: 4.64622,
|
||||
source: 'geoip',
|
||||
});
|
||||
await expect(fetchPeerMapLocation('/ip4/91.234.199.189/tcp/4001')).resolves.toMatchObject({
|
||||
lat: 52.3874,
|
||||
lon: 4.64622,
|
||||
source: 'geoip',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://free.freeipapi.com/api/json/91.234.199.189');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('falls back to the offline country estimate when lookup fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const location = await fetchPeerMapLocation(addr('80.80.80.80'));
|
||||
const centroid = COUNTRY_CENTROIDS[location!.countryCode!];
|
||||
|
||||
expect(location).toMatchObject({ source: 'coarse' });
|
||||
expect(centroid).toBeDefined();
|
||||
expect(Math.abs(location!.lat - centroid.lat)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(location!.lon - centroid.lon)).toBeLessThanOrEqual(1.3);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not call GeoIP for private peer addresses', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(fetchPeerMapLocation('/ip4/10.0.0.1/tcp/4001')).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApproximateCountryCode', () => {
|
||||
it('returns undefined when the peer cannot be placed offline', () => {
|
||||
expect(getApproximateCountryCode('/ip4/10.0.0.1/tcp/4001')).toBeUndefined();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getBrowserGatewayPkcOptions, getBrowserPureP2PPkcOptions, isElectronRun
|
||||
|
||||
export const P2P_STATS_SECTION_ID = 'p2p-stats-settings';
|
||||
|
||||
export type P2PRuntimeMode = 'browser-libp2p' | 'electron-kubo-rpc';
|
||||
export type P2PRuntimeMode = 'browser-libp2p' | 'electron-kubo-rpc' | 'full-node-rpc';
|
||||
|
||||
type AccountProtocolOptions = {
|
||||
httpRoutersOptions?: string[];
|
||||
@@ -39,8 +39,8 @@ export const getP2PRuntimeMode = (account?: unknown, targetWindow: Window = wind
|
||||
return 'browser-libp2p';
|
||||
}
|
||||
|
||||
if (isElectronRuntime(targetWindow) && (hasArrayItems(protocolOptions?.pkcRpcClientsOptions) || hasObjectItems(clients?.pkcRpcClients))) {
|
||||
return 'electron-kubo-rpc';
|
||||
if (hasArrayItems(protocolOptions?.pkcRpcClientsOptions) || hasObjectItems(clients?.pkcRpcClients)) {
|
||||
return isElectronRuntime(targetWindow) ? 'electron-kubo-rpc' : 'full-node-rpc';
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
+107
-17
@@ -1,11 +1,8 @@
|
||||
// Offline, approximate peer geolocation for the P2P stats world map.
|
||||
// Peer geolocation helpers for the P2P stats panel.
|
||||
//
|
||||
// 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.
|
||||
// The world map first tries a public GeoIP lookup for the peer endpoint IP so the
|
||||
// marker can land near the reported city/region. If that lookup fails, the map
|
||||
// falls back to the offline RIR/country-centroid estimate below.
|
||||
|
||||
import { COUNTRY_CENTROIDS } from '../data/country-centroids';
|
||||
|
||||
@@ -24,10 +21,22 @@ const formatAddressString = (address: unknown): string => {
|
||||
export type PublicEndpoint = {
|
||||
countryCode?: string;
|
||||
ip: string;
|
||||
location?: PeerMapLocation;
|
||||
};
|
||||
|
||||
export type LatLon = { lat: number; lon: number };
|
||||
|
||||
export type PeerMapLocation = LatLon & {
|
||||
countryCode?: string;
|
||||
label?: string;
|
||||
source: 'coarse' | 'geoip';
|
||||
};
|
||||
|
||||
const COUNTRY_LOOKUP_URL = 'https://api.country.is';
|
||||
const PUBLIC_IPV4_LOOKUP_URL = 'https://api64.ipify.org?format=json';
|
||||
const PEER_LOCATION_LOOKUP_URL = 'https://free.freeipapi.com/api/json';
|
||||
const PEER_LOCATION_CACHE_MS = 24 * 60 * 60_000;
|
||||
const PEER_LOCATION_FAILURE_CACHE_MS = 10 * 60_000;
|
||||
|
||||
export const extractIpFromAddress = (address: string): string | null => extractIpv4FromAddress(address) ?? extractIpv6FromAddress(address);
|
||||
|
||||
@@ -40,6 +49,7 @@ export const getFirstPublicIpFromAddresses = (addresses: unknown[]): string | un
|
||||
};
|
||||
|
||||
let cachedOwnPublicEndpoint: { expiresAt: number; value?: PublicEndpoint } | undefined;
|
||||
const peerLocationCache = new Map<string, { expiresAt: number; value?: PeerMapLocation }>();
|
||||
|
||||
const normalizeLookupCountryCode = (value: unknown) => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
@@ -47,6 +57,17 @@ const normalizeLookupCountryCode = (value: unknown) => {
|
||||
return /^[a-z]{2}$/.test(code) ? code : undefined;
|
||||
};
|
||||
|
||||
const normalizePlaceName = (value: unknown) => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const getFiniteCoordinate = (value: unknown, min: number, max: number) => {
|
||||
const coordinate = Number(value);
|
||||
return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : undefined;
|
||||
};
|
||||
|
||||
const parsePublicEndpoint = (data: unknown): PublicEndpoint | undefined => {
|
||||
if (!data || typeof data !== 'object') return undefined;
|
||||
const ip = (data as { ip?: unknown }).ip;
|
||||
@@ -67,21 +88,49 @@ const fetchPublicEndpoint = async (url: string, signal?: AbortSignal): Promise<P
|
||||
}
|
||||
};
|
||||
|
||||
const parsePeerLocation = (data: unknown): PeerMapLocation | undefined => {
|
||||
if (!data || typeof data !== 'object') return undefined;
|
||||
const lat = getFiniteCoordinate((data as { latitude?: unknown }).latitude, -85, 85);
|
||||
const lon = getFiniteCoordinate((data as { longitude?: unknown }).longitude, -180, 180);
|
||||
if (lat === undefined || lon === undefined) return undefined;
|
||||
|
||||
const countryCode = normalizeLookupCountryCode((data as { countryCode?: unknown }).countryCode);
|
||||
const city = normalizePlaceName((data as { cityName?: unknown }).cityName);
|
||||
const region = normalizePlaceName((data as { regionName?: unknown }).regionName);
|
||||
const label = [city, region, countryCode?.toUpperCase()].filter(Boolean).join(', ') || undefined;
|
||||
return {
|
||||
countryCode,
|
||||
label,
|
||||
lat,
|
||||
lon,
|
||||
source: 'geoip',
|
||||
};
|
||||
};
|
||||
|
||||
// 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.
|
||||
// connected peer geolocation uses fetchPeerMapLocation 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 location = await fetchIpMapLocation(endpoint.ip, signal);
|
||||
const value = {
|
||||
...endpoint,
|
||||
countryCode: endpoint.countryCode ?? location?.countryCode,
|
||||
location,
|
||||
};
|
||||
cachedOwnPublicEndpoint = { expiresAt: Date.now() + 60_000, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
const ipv4Endpoint = await fetchPublicEndpoint(PUBLIC_IPV4_LOOKUP_URL, signal);
|
||||
const fallback = ipv4Endpoint?.ip ? { countryCode: getApproximateCountryCode(`/ip4/${ipv4Endpoint.ip}/tcp/0`), ip: ipv4Endpoint.ip } : undefined;
|
||||
const ipv4Location = ipv4Endpoint?.ip ? await fetchIpMapLocation(ipv4Endpoint.ip, signal) : undefined;
|
||||
const fallback = ipv4Endpoint?.ip
|
||||
? { countryCode: ipv4Location?.countryCode ?? getApproximateCountryCode(`/ip4/${ipv4Endpoint.ip}/tcp/0`), ip: ipv4Endpoint.ip, location: ipv4Location }
|
||||
: 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.
|
||||
@@ -93,22 +142,18 @@ 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.
|
||||
// Like fetchOwnPublicEndpoint, this only asks about the user's own node address.
|
||||
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;
|
||||
const value = endpoint?.countryCode ?? getApproximateCountryCode(`/ip4/${ip}/tcp/0`);
|
||||
// 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';
|
||||
|
||||
const REGION_CENTROIDS: Record<Region, LatLon> = {
|
||||
@@ -235,6 +280,11 @@ const isProbablyPublicIpv6 = (ip: string): boolean => {
|
||||
|
||||
const isPublicIpAddress = (ip: string): boolean => (ip.includes(':') ? isProbablyPublicIpv6(ip) : !isPrivateOrReservedIpv4(ip));
|
||||
|
||||
const getPublicIpFromAddress = (address: string): string | undefined => {
|
||||
const ip = extractIpFromAddress(address);
|
||||
return ip && isPublicIpAddress(ip) ? ip : undefined;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const hashOctets = (parts: number[]) => {
|
||||
@@ -263,6 +313,18 @@ export const getApproximateLatLon = (address: string): LatLon | null => {
|
||||
return { lat: clamp(centroid.lat + latOffset, -85, 85), lon: clamp(centroid.lon + lonOffset, -180, 180) };
|
||||
};
|
||||
|
||||
const getCoarsePeerMapLocation = (address: string): PeerMapLocation | undefined => {
|
||||
const location = getApproximateLatLon(address);
|
||||
if (!location) return undefined;
|
||||
const countryCode = getApproximateCountryCode(address);
|
||||
return {
|
||||
...location,
|
||||
countryCode,
|
||||
label: countryCode ? countryCode.toUpperCase() : undefined,
|
||||
source: 'coarse',
|
||||
};
|
||||
};
|
||||
|
||||
// Representative countries per region. The RIR table only resolves to a continent,
|
||||
// so the flag is a deterministic, approximate pick from the region's common
|
||||
// countries — consistent with the map's "approximate locations" framing, not real
|
||||
@@ -285,3 +347,31 @@ export const getApproximateCountryCode = (address: string): string | undefined =
|
||||
const pool = REGION_COUNTRIES[REGION_BY_OCTET[parts[0]]];
|
||||
return pool[hashOctets(parts) % pool.length];
|
||||
};
|
||||
|
||||
export const fetchIpMapLocation = async (ip: string, signal?: AbortSignal): Promise<PeerMapLocation | undefined> => {
|
||||
const cached = peerLocationCache.get(ip);
|
||||
if (cached && Date.now() < cached.expiresAt) return cached.value;
|
||||
|
||||
let value: PeerMapLocation | undefined;
|
||||
try {
|
||||
const response = await fetch(`${PEER_LOCATION_LOOKUP_URL}/${encodeURIComponent(ip)}`, { signal });
|
||||
if (response.ok) value = parsePeerLocation(await response.json());
|
||||
} catch {
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
// See fetchOwnPublicEndpoint: a cancelled request should not poison the cache.
|
||||
if (!signal?.aborted) {
|
||||
peerLocationCache.set(ip, {
|
||||
expiresAt: Date.now() + (value ? PEER_LOCATION_CACHE_MS : PEER_LOCATION_FAILURE_CACHE_MS),
|
||||
value,
|
||||
});
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const fetchPeerMapLocation = async (address: string, signal?: AbortSignal): Promise<PeerMapLocation | undefined> => {
|
||||
const ip = getPublicIpFromAddress(address);
|
||||
if (!ip) return undefined;
|
||||
return (await fetchIpMapLocation(ip, signal)) ?? getCoarsePeerMapLocation(address);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user