mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Improve P2P stats peer locations and Kubo display (#1139)
* feat(p2p stats): plot peers by ip location * fix(p2p stats): show own node map marker * feat(p2p stats): support full node peers * fix(p2p stats): clean up kubo stats * fix(p2p stats): address review feedback
This commit is contained in:
+399
@@ -45,6 +45,8 @@ const renderSettings = async (isElectron = false) => {
|
|||||||
const getStatRows = () =>
|
const getStatRows = () =>
|
||||||
new Map(Array.from(container.querySelectorAll('tr')).map((row) => [row.children.item(0)?.textContent ?? '', row.children.item(1)?.textContent ?? '']));
|
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', () => {
|
describe('P2PStatsSettings', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -183,6 +185,141 @@ describe('P2PStatsSettings', () => {
|
|||||||
expect(container.textContent).not.toContain('topic subscribers');
|
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 () => {
|
it('reads browser transfer counters from Helia bitswap ledgers', async () => {
|
||||||
testState.account = {
|
testState.account = {
|
||||||
...testState.account,
|
...testState.account,
|
||||||
@@ -354,4 +491,266 @@ describe('P2PStatsSettings', () => {
|
|||||||
expect(container.querySelector('a[href="https://github.com/bitsocialnet/bitsocial-seeder"]')).toBeNull();
|
expect(container.querySelector('a[href="https://github.com/bitsocialnet/bitsocial-seeder"]')).toBeNull();
|
||||||
expect(container.textContent).not.toContain('seed mode');
|
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();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the rasterized land backdrop and a marker for a placeable peer', () => {
|
it('renders the rasterized land backdrop and a marker from a resolved IP location', () => {
|
||||||
render(createElement(PeerWorldMap, { peers: [{ address: '/ip4/8.8.8.8/tcp/4001', id: 'c1', peerId: 'peer-1' }] }));
|
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 landPath = container.querySelector('svg path');
|
||||||
|
const marker = container.querySelector('svg rect');
|
||||||
expect(landPath).not.toBeNull();
|
expect(landPath).not.toBeNull();
|
||||||
// The land mask decodes into a large multi-square path, not a handful of points.
|
// The land mask decodes into a large multi-square path, not a handful of points.
|
||||||
expect((landPath?.getAttribute('d') ?? '').length).toBeGreaterThan(1000);
|
expect((landPath?.getAttribute('d') ?? '').length).toBeGreaterThan(1000);
|
||||||
expect(container.querySelectorAll('svg rect')).toHaveLength(1);
|
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');
|
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', () => {
|
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' }] }));
|
render(createElement(PeerWorldMap, { peers: [{ address: '/ip4/10.0.0.1/tcp/4001', id: 'c1', peerId: 'peer-1' }] }));
|
||||||
expect(container.querySelector('svg')).toBeNull();
|
expect(container.querySelector('svg')).toBeNull();
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.connectedPeersSummary {
|
.connectedPeersSummary {
|
||||||
|
display: inline-block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 1px 0;
|
padding: 1px 0;
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.connectionDirection,
|
.connectionDirection,
|
||||||
.connectionStatus {
|
.connectionStatus,
|
||||||
|
.connectionRole {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -117,6 +119,10 @@
|
|||||||
background: #3fb950;
|
background: #3fb950;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connectionRole[data-peer-role='leecher'] {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
.peerId {
|
.peerId {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
@@ -189,6 +195,10 @@
|
|||||||
fill: #3fb950;
|
fill: #3fb950;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.peerMarker[data-peer-role='leecher'] {
|
||||||
|
fill: red;
|
||||||
|
}
|
||||||
|
|
||||||
.peerWorldMapCaption {
|
.peerWorldMapCaption {
|
||||||
padding: 1px 4px 2px;
|
padding: 1px 4px 2px;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
|
|||||||
@@ -2,7 +2,17 @@ import { Fragment, memo, useEffect, useReducer } from 'react';
|
|||||||
import { useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks';
|
import { useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getCountryFlagPosition, getCountryLabel, normalizeCountryCode } from '../../../lib/country-flags';
|
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 { getP2PRuntimeMode, type P2PRuntimeMode } from '../../../lib/p2p-runtime';
|
||||||
import PeerWorldMap from './peer-world-map';
|
import PeerWorldMap from './peer-world-map';
|
||||||
import styles from './p2p-stats-settings.module.css';
|
import styles from './p2p-stats-settings.module.css';
|
||||||
@@ -17,16 +27,30 @@ type TextStatRow = {
|
|||||||
|
|
||||||
type ConnectedPeerEntry = {
|
type ConnectedPeerEntry = {
|
||||||
address: string;
|
address: string;
|
||||||
|
countryCode?: string;
|
||||||
direction?: string;
|
direction?: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
location?: PeerMapLocation;
|
||||||
peerId: string;
|
peerId: string;
|
||||||
|
role?: PeerConnectionRole;
|
||||||
status?: string;
|
status?: string;
|
||||||
transport: string;
|
transport: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PeerConnectionRole = 'leecher' | 'seeder';
|
||||||
|
|
||||||
|
type PeerMapEntry = {
|
||||||
|
address: string;
|
||||||
|
id: string;
|
||||||
|
location?: PeerMapLocation;
|
||||||
|
peerId: string;
|
||||||
|
role?: PeerConnectionRole;
|
||||||
|
};
|
||||||
|
|
||||||
type ConnectedPeersStatRow = {
|
type ConnectedPeersStatRow = {
|
||||||
connectionCount: number;
|
connectionCount: number;
|
||||||
entries: ConnectedPeerEntry[];
|
entries: ConnectedPeerEntry[];
|
||||||
|
mapEntries?: PeerMapEntry[];
|
||||||
name: string;
|
name: string;
|
||||||
peerCount: number;
|
peerCount: number;
|
||||||
type: 'connectedPeers';
|
type: 'connectedPeers';
|
||||||
@@ -98,6 +122,12 @@ type Libp2pAddressManagerShape = {
|
|||||||
|
|
||||||
type BrowserLibp2pShape = NonNullable<NonNullable<NonNullable<Libp2pClientShape['_helia']>['libp2p']>>;
|
type BrowserLibp2pShape = NonNullable<NonNullable<NonNullable<Libp2pClientShape['_helia']>['libp2p']>>;
|
||||||
|
|
||||||
|
type PkcRpcClientShape = {
|
||||||
|
getPeers?: () => unknown | Promise<unknown>;
|
||||||
|
getStats?: () => unknown | Promise<unknown>;
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type TransferStats = {
|
type TransferStats = {
|
||||||
downloadedBytes?: number;
|
downloadedBytes?: number;
|
||||||
uploadedBytes?: number;
|
uploadedBytes?: number;
|
||||||
@@ -141,7 +171,7 @@ const formatBytes = (value: unknown) => {
|
|||||||
return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
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);
|
const getFirstObjectValue = <T,>(value?: Record<string, T>) => (value ? Object.values(value)[0] : undefined);
|
||||||
|
|
||||||
@@ -186,6 +216,46 @@ const toArray = (value: unknown): unknown[] => {
|
|||||||
return [];
|
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[]> => {
|
const getSafeArray = async (getValue?: () => unknown[] | Promise<unknown[]> | undefined): Promise<unknown[]> => {
|
||||||
try {
|
try {
|
||||||
return toArray(getValue ? await getValue() : undefined);
|
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 => {
|
const getByteLength = (value: unknown): number | undefined => {
|
||||||
if (value === null || value === undefined) return undefined;
|
if (value === null || value === undefined) return undefined;
|
||||||
if (typeof value === 'string') return new TextEncoder().encode(value).byteLength;
|
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';
|
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 getTransportLabel = (address: string) => {
|
||||||
const normalizedAddress = address.toLowerCase();
|
const normalizedAddress = address.toLowerCase();
|
||||||
let transport = 'Unknown transport';
|
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
|
const PEER_LIST_FIELDS = ['Peers', 'peers', 'connectedPeers', 'connections'];
|
||||||
// is geolocated accurately (it is the user's own address, never a peer's) so the
|
const PEER_ADDRESS_FIELDS = ['Addr', 'address', 'addr', 'remoteAddr', 'multiaddr', 'multiaddrString'];
|
||||||
// flag matches it, instead of the coarse continent guess used for connected peers.
|
const PEER_ID_FIELDS = ['Peer', 'peer', 'peerId', 'id', 'remotePeer'];
|
||||||
// Falls back to a public-endpoint lookup when libp2p only knows local/private addresses.
|
const PEER_DIRECTION_FIELDS = ['Direction', 'direction'];
|
||||||
const resolveOwnEndpoint = async (addresses: unknown[], signal?: AbortSignal): Promise<PublicEndpoint | undefined> => {
|
const PEER_STATUS_FIELDS = ['Status', 'status', 'state'];
|
||||||
const ip = getFirstPublicIpFromAddresses(addresses);
|
const PEER_ROLE_FIELDS = ['role', 'Role', 'mode', 'Mode', 'connectionRole', 'connectionType'];
|
||||||
if (ip) return { countryCode: await fetchOwnIpCountryCode(ip, signal), ip };
|
const PEER_LISTEN_ADDRESS_FIELDS = ['listenAddress', 'listenAddresses', 'ListenAddress', 'ListenAddresses'];
|
||||||
return fetchOwnPublicEndpoint(signal);
|
|
||||||
|
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 getPeerAddress = (peer: unknown) => {
|
||||||
const peerEntries = isRecord(peers) && Array.isArray(peers.Peers) ? peers.Peers : [];
|
const address = getStringField(peer, PEER_ADDRESS_FIELDS, '');
|
||||||
const entries = peerEntries.map<ConnectedPeerEntry>((peer) => {
|
if (address) return address;
|
||||||
const address = isRecord(peer) ? getStringValue(peer.Addr, 'address unavailable') : 'address unavailable';
|
const listenAddress = getAddressValues(getRecordField(peer, PEER_LISTEN_ADDRESS_FIELDS))[0];
|
||||||
const peerId = isRecord(peer) ? getStringValue(peer.Peer) : 'unknown';
|
return listenAddress || 'address unavailable';
|
||||||
const direction = isRecord(peer) ? getStringValue(peer.Direction, '') : undefined;
|
};
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
address,
|
address,
|
||||||
direction,
|
direction: direction || undefined,
|
||||||
id: `${peerId}-${address}-${direction ?? ''}`,
|
id: fallbackId,
|
||||||
peerId,
|
peerId,
|
||||||
|
role: getPeerConnectionRole(peer),
|
||||||
|
status: status || undefined,
|
||||||
transport: getTransportLabel(address),
|
transport: getTransportLabel(address),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -539,16 +693,94 @@ const getElectronConnectedPeersRow = (peers: unknown): ConnectedPeersStatRow =>
|
|||||||
if (entry.peerId && entry.peerId !== 'unknown') ids.add(entry.peerId);
|
if (entry.peerId && entry.peerId !== 'unknown') ids.add(entry.peerId);
|
||||||
return ids;
|
return ids;
|
||||||
}, new Set());
|
}, new Set());
|
||||||
|
const peerCount = getFiniteNumber(getRecordField(peers, ['peerCount', 'peersCount', 'PeerCount']));
|
||||||
|
const connectionCount = getFiniteNumber(getRecordField(peers, ['connectionCount', 'connectionsCount', 'ConnectionCount']));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionCount: entries.length,
|
connectionCount: connectionCount ?? entries.length,
|
||||||
entries,
|
entries,
|
||||||
name: 'Connected peers',
|
name: 'Connected peers',
|
||||||
peerCount: peerIds.size || entries.length,
|
peerCount: peerCount ?? (peerIds.size || entries.length),
|
||||||
type: 'connectedPeers',
|
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 getBrowserLibp2pStats = async (account?: AccountShape, signal?: AbortSignal): Promise<StatRow[]> => {
|
||||||
const client = getFirstObjectValue(account?.pkc?.clients?.libp2pJsClients) as Libp2pClientShape | undefined;
|
const client = getFirstObjectValue(account?.pkc?.clients?.libp2pJsClients) as Libp2pClientShape | undefined;
|
||||||
const libp2p = client?._helia?.libp2p;
|
const libp2p = client?._helia?.libp2p;
|
||||||
@@ -558,20 +790,112 @@ const getBrowserLibp2pStats = async (account?: AccountShape, signal?: AbortSigna
|
|||||||
getSafeArray(() => libp2p?.getMultiaddrs?.()),
|
getSafeArray(() => libp2p?.getMultiaddrs?.()),
|
||||||
getAddressManagerAddresses(libp2p),
|
getAddressManagerAddresses(libp2p),
|
||||||
]);
|
]);
|
||||||
const transferStats = await getBrowserTransferStats(client, connections);
|
|
||||||
const localAddresses = connections.flatMap((connection) => {
|
const localAddresses = connections.flatMap((connection) => {
|
||||||
const localAddr = isRecord(connection) ? connection.localAddr : undefined;
|
const localAddr = isRecord(connection) ? connection.localAddr : undefined;
|
||||||
return localAddr ? [localAddr] : [];
|
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 [
|
return [
|
||||||
{ name: 'Mode', value: getBrowserMode(client) },
|
{ name: 'Mode', value: mode },
|
||||||
{ name: 'Peer ID', value: libp2p?.peerId?.toString() ?? 'unknown' },
|
{ 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' },
|
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 received', value: transferStats.downloadedBytes === undefined ? 'unknown' : formatBytes(transferStats.downloadedBytes) },
|
||||||
{ name: 'Data sent', value: transferStats.uploadedBytes === undefined ? 'unknown' : formatBytes(transferStats.uploadedBytes) },
|
{ 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) : {};
|
return firstJsonLine ? JSON.parse(firstJsonLine) : {};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElectronKuboStats = async (rpcState?: string, signal?: AbortSignal): Promise<StatRow[]> => {
|
const getElectronKuboStats = async (signal?: AbortSignal): Promise<StatRow[]> => {
|
||||||
const [identity, version, peers, bandwidth, repo, bitswap] = await Promise.all([
|
const [identity, peers, bandwidth] = await Promise.all([
|
||||||
kuboPostJson('id', undefined, signal),
|
kuboPostJson('id', undefined, signal),
|
||||||
kuboPostJson('version', undefined, signal),
|
|
||||||
kuboPostJson('swarm/peers', { direction: true, latency: true, streams: true }, signal),
|
kuboPostJson('swarm/peers', { direction: true, latency: true, streams: true }, signal),
|
||||||
kuboPostJson('stats/bw', undefined, 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 [
|
return [
|
||||||
{ name: 'Mode', value: 'Desktop Kubo' },
|
{ name: 'Mode', value: 'Seeding' },
|
||||||
{ name: 'PKC RPC', value: rpcState ?? 'unknown' },
|
{ name: 'Kubo RPC', value: KUBO_API_URL },
|
||||||
{ name: 'Peer ID', value: identity.ID ?? 'unknown' },
|
{ name: 'Peer ID', value: peerId },
|
||||||
{ name: 'Agent', value: identity.AgentVersion ?? version.Version ?? 'unknown' },
|
nodeEndpoint ? { countryCode: nodeEndpoint.countryCode, ip: nodeEndpoint.ip, name: 'Your IP', type: 'nodeEndpoint' } : { name: 'Your IP', value: 'unavailable' },
|
||||||
{ name: 'Bandwidth in', value: `${formatBytes(bandwidth.TotalIn)} total, ${formatRate(bandwidth.RateIn)}` },
|
{ name: 'Data received', value: formatOptionalBytes(bandwidth.TotalIn) },
|
||||||
{ name: 'Bandwidth out', value: `${formatBytes(bandwidth.TotalOut)} total, ${formatRate(bandwidth.RateOut)}` },
|
{ name: 'Data sent', value: formatOptionalBytes(bandwidth.TotalOut) },
|
||||||
{ name: 'Repo size', value: formatBytes(repo.RepoSize) },
|
connectedPeersWithMapEntries,
|
||||||
{ 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),
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getP2PStats = async (mode: P2PRuntimeMode, account?: AccountShape, rpcState?: string, signal?: AbortSignal) => {
|
const getP2PStats = async (mode: P2PRuntimeMode, account?: AccountShape, rpcState?: string, signal?: AbortSignal) => {
|
||||||
if (mode === 'browser-libp2p') return getBrowserLibp2pStats(account, signal);
|
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 }) => {
|
const NodeEndpointValue = ({ row }: { row: NodeEndpointStatRow }) => {
|
||||||
@@ -660,11 +987,11 @@ const ConnectedPeersValue = ({ row }: { row: ConnectedPeersStatRow }) => (
|
|||||||
<summary className={styles.connectedPeersSummary}>
|
<summary className={styles.connectedPeersSummary}>
|
||||||
{row.name}: {formatCount(row.peerCount, 'peer')}, {formatCount(row.connectionCount, 'connection')}
|
{row.name}: {formatCount(row.peerCount, 'peer')}, {formatCount(row.connectionCount, 'connection')}
|
||||||
</summary>
|
</summary>
|
||||||
<PeerWorldMap peers={row.entries} />
|
<PeerWorldMap peers={row.mapEntries ?? row.entries} />
|
||||||
<div className={styles.connectedPeerList}>
|
<div className={styles.connectedPeerList}>
|
||||||
{row.entries.length ? (
|
{row.entries.length ? (
|
||||||
row.entries.map((entry) => {
|
row.entries.map((entry) => {
|
||||||
const countryCode = getApproximateCountryCode(entry.address);
|
const countryCode = entry.countryCode ?? getApproximateCountryCode(entry.address);
|
||||||
const flagPosition = getCountryFlagPosition(countryCode);
|
const flagPosition = getCountryFlagPosition(countryCode);
|
||||||
return (
|
return (
|
||||||
<div className={styles.connectedPeer} key={entry.id}>
|
<div className={styles.connectedPeer} key={entry.id}>
|
||||||
@@ -680,6 +1007,11 @@ const ConnectedPeersValue = ({ row }: { row: ConnectedPeersStatRow }) => (
|
|||||||
{entry.status}
|
{entry.status}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{entry.role && (
|
||||||
|
<span className={styles.connectionRole} data-peer-role={entry.role}>
|
||||||
|
{entry.role === 'leecher' ? 'Leeching' : 'Seeding'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.peerId} title={entry.peerId}>
|
<div className={styles.peerId} title={entry.peerId}>
|
||||||
{entry.peerId}
|
{entry.peerId}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { WORLD_MAP_DOTS } from '../../../data/world-map-dots';
|
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';
|
import styles from './p2p-stats-settings.module.css';
|
||||||
|
|
||||||
type MapPeer = {
|
type MapPeer = {
|
||||||
address: string;
|
address: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
location?: PeerMapLocation;
|
||||||
peerId: string;
|
peerId: string;
|
||||||
|
role?: 'leecher' | 'seeder';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Square side per land dot, sized to cover most of a grid cell so the rasterized
|
// 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.
|
// 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 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,
|
// 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
|
// 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 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) {
|
for (const peer of peers) {
|
||||||
const location = getApproximateLatLon(peer.address);
|
const location = peer.location ?? getApproximateLatLon(peer.address);
|
||||||
if (location) plotted.push({ id: peer.id, peerId: peer.peerId, x: location.lon + 180, y: 90 - location.lat });
|
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;
|
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'>
|
<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} />
|
<path className={styles.landDot} d={LAND_PATH} />
|
||||||
{plotted.map((peer) => (
|
{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}>
|
<rect
|
||||||
<title>{peer.peerId}</title>
|
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>
|
</rect>
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
<div className={styles.peerWorldMapCaption}>approximate locations</div>
|
<div className={styles.peerWorldMapCaption}>approximate IP locations</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,11 +49,11 @@ describe('p2p-runtime', () => {
|
|||||||
expect(getP2PRuntimeMode({ pkc: { clients: { libp2pJsClients: { libp2pjs: {} } } } }, browserWindow)).toBe('browser-libp2p');
|
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'] } };
|
const account = { pkcOptions: { pkcRpcClientsOptions: ['ws://localhost:9138'] } };
|
||||||
|
|
||||||
expect(getP2PRuntimeMode(account, electronWindow)).toBe('electron-kubo-rpc');
|
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', () => {
|
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);
|
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', () => {
|
it('builds browser p2p and gateway account options without a direct pkc-js import', () => {
|
||||||
const account = {
|
const account = {
|
||||||
pkcOptions: {
|
pkcOptions: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
extractIpv6FromAddress,
|
extractIpv6FromAddress,
|
||||||
fetchOwnIpCountryCode,
|
fetchOwnIpCountryCode,
|
||||||
fetchOwnPublicEndpoint,
|
fetchOwnPublicEndpoint,
|
||||||
|
fetchPeerMapLocation,
|
||||||
getApproximateCountryCode,
|
getApproximateCountryCode,
|
||||||
getApproximateLatLon,
|
getApproximateLatLon,
|
||||||
getFirstPublicIpFromAddresses,
|
getFirstPublicIpFromAddresses,
|
||||||
@@ -98,15 +99,34 @@ describe('getFirstPublicIpFromAddresses', () => {
|
|||||||
|
|
||||||
describe('fetchOwnPublicEndpoint', () => {
|
describe('fetchOwnPublicEndpoint', () => {
|
||||||
it('caches the fetched public endpoint', async () => {
|
it('caches the fetched public endpoint', async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn(async (url: string | URL | Request) => ({
|
||||||
ok: true,
|
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);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
await expect(fetchOwnPublicEndpoint()).resolves.toEqual({ countryCode: 'us', ip: '2001:4860:4860::8888' });
|
await expect(fetchOwnPublicEndpoint()).resolves.toMatchObject({
|
||||||
await expect(fetchOwnPublicEndpoint()).resolves.toEqual({ countryCode: 'us', ip: '2001:4860:4860::8888' });
|
countryCode: 'us',
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
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();
|
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', () => {
|
describe('getApproximateCountryCode', () => {
|
||||||
it('returns undefined when the peer cannot be placed offline', () => {
|
it('returns undefined when the peer cannot be placed offline', () => {
|
||||||
expect(getApproximateCountryCode('/ip4/10.0.0.1/tcp/4001')).toBeUndefined();
|
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 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 = {
|
type AccountProtocolOptions = {
|
||||||
httpRoutersOptions?: string[];
|
httpRoutersOptions?: string[];
|
||||||
@@ -39,8 +39,8 @@ export const getP2PRuntimeMode = (account?: unknown, targetWindow: Window = wind
|
|||||||
return 'browser-libp2p';
|
return 'browser-libp2p';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isElectronRuntime(targetWindow) && (hasArrayItems(protocolOptions?.pkcRpcClientsOptions) || hasObjectItems(clients?.pkcRpcClients))) {
|
if (hasArrayItems(protocolOptions?.pkcRpcClientsOptions) || hasObjectItems(clients?.pkcRpcClients)) {
|
||||||
return 'electron-kubo-rpc';
|
return isElectronRuntime(targetWindow) ? 'electron-kubo-rpc' : 'full-node-rpc';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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:
|
// The world map first tries a public GeoIP lookup for the peer endpoint IP so the
|
||||||
// 5chan is serverless and privacy-focused, so we must not leak the set of peers
|
// marker can land near the reported city/region. If that lookup fails, the map
|
||||||
// a user is connected to.
|
// falls back to the offline RIR/country-centroid estimate below.
|
||||||
// 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';
|
import { COUNTRY_CENTROIDS } from '../data/country-centroids';
|
||||||
|
|
||||||
@@ -24,10 +21,22 @@ const formatAddressString = (address: unknown): string => {
|
|||||||
export type PublicEndpoint = {
|
export type PublicEndpoint = {
|
||||||
countryCode?: string;
|
countryCode?: string;
|
||||||
ip: 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 COUNTRY_LOOKUP_URL = 'https://api.country.is';
|
||||||
const PUBLIC_IPV4_LOOKUP_URL = 'https://api64.ipify.org?format=json';
|
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);
|
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;
|
let cachedOwnPublicEndpoint: { expiresAt: number; value?: PublicEndpoint } | undefined;
|
||||||
|
const peerLocationCache = new Map<string, { expiresAt: number; value?: PeerMapLocation }>();
|
||||||
|
|
||||||
const normalizeLookupCountryCode = (value: unknown) => {
|
const normalizeLookupCountryCode = (value: unknown) => {
|
||||||
if (typeof value !== 'string') return undefined;
|
if (typeof value !== 'string') return undefined;
|
||||||
@@ -47,6 +57,17 @@ const normalizeLookupCountryCode = (value: unknown) => {
|
|||||||
return /^[a-z]{2}$/.test(code) ? code : undefined;
|
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 => {
|
const parsePublicEndpoint = (data: unknown): PublicEndpoint | undefined => {
|
||||||
if (!data || typeof data !== 'object') return undefined;
|
if (!data || typeof data !== 'object') return undefined;
|
||||||
const ip = (data as { ip?: unknown }).ip;
|
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
|
// 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
|
// libp2p only advertises local/private listen addresses (common in browser nodes
|
||||||
// and VPN setups). This only asks about the current browser's public endpoint;
|
// 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> => {
|
export const fetchOwnPublicEndpoint = async (signal?: AbortSignal): Promise<PublicEndpoint | undefined> => {
|
||||||
if (cachedOwnPublicEndpoint && Date.now() < cachedOwnPublicEndpoint.expiresAt) return cachedOwnPublicEndpoint.value;
|
if (cachedOwnPublicEndpoint && Date.now() < cachedOwnPublicEndpoint.expiresAt) return cachedOwnPublicEndpoint.value;
|
||||||
|
|
||||||
const endpoint = await fetchPublicEndpoint(COUNTRY_LOOKUP_URL, signal);
|
const endpoint = await fetchPublicEndpoint(COUNTRY_LOOKUP_URL, signal);
|
||||||
if (endpoint) {
|
if (endpoint) {
|
||||||
cachedOwnPublicEndpoint = { expiresAt: Date.now() + 60_000, value: endpoint };
|
const location = await fetchIpMapLocation(endpoint.ip, signal);
|
||||||
return endpoint;
|
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 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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// "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
|
// Like fetchOwnPublicEndpoint, this only asks about the user's own node address.
|
||||||
// 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> => {
|
export const fetchOwnIpCountryCode = async (ip: string, signal?: AbortSignal): Promise<string | undefined> => {
|
||||||
const cached = ownIpCountryCache.get(ip);
|
const cached = ownIpCountryCache.get(ip);
|
||||||
if (cached && Date.now() < cached.expiresAt) return cached.value;
|
if (cached && Date.now() < cached.expiresAt) return cached.value;
|
||||||
const endpoint = await fetchPublicEndpoint(`${COUNTRY_LOOKUP_URL}/${ip}`, signal);
|
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
|
// See fetchOwnPublicEndpoint: skip caching when the lookup was aborted so a
|
||||||
// cancelled request does not blank the flag for 60s on the next open.
|
// 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 });
|
if (!signal?.aborted) ownIpCountryCache.set(ip, { expiresAt: Date.now() + 60_000, value });
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LatLon = { lat: number; lon: number };
|
|
||||||
|
|
||||||
type Region = 'AF' | 'AS' | 'EU' | 'NA' | 'SA';
|
type Region = 'AF' | 'AS' | 'EU' | 'NA' | 'SA';
|
||||||
|
|
||||||
const REGION_CENTROIDS: Record<Region, LatLon> = {
|
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 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 clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
const hashOctets = (parts: number[]) => {
|
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) };
|
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,
|
// 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
|
// so the flag is a deterministic, approximate pick from the region's common
|
||||||
// countries — consistent with the map's "approximate locations" framing, not real
|
// 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]]];
|
const pool = REGION_COUNTRIES[REGION_BY_OCTET[parts[0]]];
|
||||||
return pool[hashOctets(parts) % pool.length];
|
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