Merge branch 'codex/fix/p2p-stats-core-dev-advice'

This commit is contained in:
Tommaso Casaburi
2026-05-14 12:34:05 +07:00
38 changed files with 422 additions and 97 deletions
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "إلغاء",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "প্রত্যাহার করুন",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Odvolat",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Tilbagekald",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Widerrufen",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Ανάκληση",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Revoke",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Revocar",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "لغو",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Peruuta",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Bawiin",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Révoquer",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "בטל",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "रद्द करें",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Visszavonás",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Cabut",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Revoca",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "取り消す",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "취소",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "रद्द करा",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Intrekken",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Tilbakekall",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Odwołaj",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Revogar",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Revocă",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Отозвать",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Revoko",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Återkalla",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "రద్దు చేయి",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "เพิกถอน",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "İptal et",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Відкликати",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "منسوخ کریں",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "Thu hồi",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
+1 -1
View File
@@ -374,7 +374,7 @@
"revoke": "撤销",
"p2p_stats": "P2P stats",
"enable_pure_p2p": "Enable pure P2P",
"enable_pure_p2p_tip": "Use a browser P2P node instead of gateway compatibility mode.",
"enable_pure_p2p_tip": "Use a browser P2P node instead of centralized IPFS gateways.",
"p2p_stats_loading": "Loading P2P stats...",
"p2p_stats_updated": "Updated",
"p2p_stats_starting": "P2P stats appear after the P2P client starts.",
@@ -42,6 +42,9 @@ 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 ?? '']));
describe('P2PStatsSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -86,7 +89,14 @@ describe('P2PStatsSettings', () => {
},
_helia: {
libp2p: {
getConnections: () => ['connection-1'],
getConnections: () => [
{
direction: 'outbound',
remoteAddr: { toString: () => '/ip4/127.0.0.1/tcp/4001/ws/p2p/peer-1' },
remotePeer: { toString: () => 'peer-1' },
status: 'open',
},
],
getMultiaddrs: () => ['/ip4/127.0.0.1/tcp/4001'],
getPeers: () => ['peer-1', 'peer-2'],
metrics: {
@@ -121,16 +131,26 @@ describe('P2PStatsSettings', () => {
await renderSettings(false);
await act(async () => Promise.resolve());
expect(container.textContent).toContain('leeching');
const rows = getStatRows();
expect(container.textContent).toContain('Leeching');
expect(container.textContent).not.toContain('browser Helia');
expect(container.textContent).not.toContain('seed mode');
expect(container.textContent).not.toContain('status');
expect(rows.get('Connected peers')).toContain('2 peers, 1 connection');
expect(rows.get('Connected peers')).toContain('WebSocket');
expect(rows.get('Connected peers')).toContain('/ip4/127.0.0.1/tcp/4001/ws/p2p/peer-1');
expect(rows.has('connections')).toBe(false);
expect(rows.has('Listen addresses')).toBe(false);
expect(rows.has('p2p_stats_updated')).toBe(true);
expect(container.textContent).toContain('self-peer');
expect(container.textContent).toContain('2 peers');
expect(container.textContent).toContain('downloaded data');
expect(container.textContent).toContain('Peer ID');
expect(container.textContent).toContain('Data received');
expect(container.textContent).toContain('2.00 KB');
expect(container.textContent).toContain('uploaded data');
expect(container.textContent).toContain('Data sent');
expect(container.textContent).toContain('1.00 KB');
expect(container.textContent).not.toContain('client key');
expect(container.textContent).not.toContain('pubsub peers');
expect(container.textContent).not.toContain('routers');
expect(container.textContent).not.toContain('pubsub topics');
expect(container.textContent).not.toContain('topic subscribers');
});
@@ -176,12 +196,45 @@ describe('P2PStatsSettings', () => {
await renderSettings(false);
await act(async () => Promise.resolve());
expect(container.textContent).toContain('downloaded data');
expect(container.textContent).toContain('Data received');
expect(container.textContent).toContain('4.00 KB');
expect(container.textContent).toContain('uploaded data');
expect(container.textContent).toContain('Data sent');
expect(container.textContent).toContain('2.00 KB');
});
it('starts browser transfer counters at zero when Helia exposes no byte totals yet', async () => {
testState.account = {
...testState.account,
pkcOptions: {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
pkc: {
clients: {
libp2pJsClients: {
libp2pjs: {
key: 'libp2pjs',
_helia: {
libp2p: {
getConnections: () => [],
getMultiaddrs: () => [],
getPeers: () => [],
peerId: { toString: () => 'self-peer' },
},
},
},
},
},
},
};
await renderSettings(false);
await act(async () => Promise.resolve());
const rows = getStatRows();
expect(rows.get('Data received')).toBe('0 B');
expect(rows.get('Data sent')).toBe('0 B');
});
it('reports seeding only when browser Helia can add and publish provider records', async () => {
testState.account = {
...testState.account,
@@ -220,7 +273,7 @@ describe('P2PStatsSettings', () => {
await renderSettings(false);
await act(async () => Promise.resolve());
expect(container.textContent).toContain('seeding');
expect(container.textContent).toContain('Seeding');
expect(container.textContent).not.toContain('seed mode');
});
});
@@ -31,7 +31,6 @@
.statName {
width: 34%;
text-transform: capitalize;
white-space: nowrap;
}
@@ -39,6 +38,46 @@
word-break: break-word;
}
.connectedPeers summary {
cursor: pointer;
}
.connectedPeerList {
display: grid;
gap: 6px;
margin-top: 5px;
}
.connectedPeer {
border-top: 1px solid var(--border-color, #d9d9d9);
padding-top: 5px;
}
.connectedPeerMeta {
display: flex;
flex-wrap: wrap;
gap: 5px;
font-size: 0.9em;
}
.connectionTransport {
font-weight: 700;
}
.peerId,
.connectionAddress {
word-break: break-all;
}
.connectionAddress {
display: block;
margin-top: 2px;
}
.connectedPeerEmpty {
font-style: italic;
}
.statsMeta {
margin-top: 5px;
font-size: 0.85em;
@@ -6,11 +6,31 @@ import styles from './p2p-stats-settings.module.css';
type AccountShape = Record<string, any>;
type StatRow = {
type TextStatRow = {
name: string;
type?: 'text';
value: string;
};
type ConnectedPeerEntry = {
address: string;
direction?: string;
id: string;
peerId: string;
status?: string;
transport: string;
};
type ConnectedPeersStatRow = {
connectionCount: number;
entries: ConnectedPeerEntry[];
name: string;
peerCount: number;
type: 'connectedPeers';
};
type StatRow = ConnectedPeersStatRow | TextStatRow;
type StatsState = {
error?: string;
loading: boolean;
@@ -63,10 +83,18 @@ type TransferStats = {
uploadedBytes?: number;
};
type ObservedTransferStats = {
connections: WeakSet<object>;
downloadedBytes: number;
streams: WeakSet<object>;
uploadedBytes: number;
};
const KUBO_API_URL = 'http://localhost:50019/api/v0';
const STATS_REFRESH_MS = 5000;
const MAX_TRANSFER_COUNTER_DEPTH = 10;
const MAX_TRANSFER_COUNTER_OBJECTS = 400;
const observedBrowserTransferStats = new WeakMap<object, ObservedTransferStats>();
const statsReducer = (state: StatsState, action: StatsAction): StatsState => {
if (action.type === 'loading') return { ...state, error: undefined, loading: true };
@@ -94,17 +122,20 @@ const formatBytes = (value: unknown) => {
const formatRate = (value: unknown) => `${formatBytes(value)}/s`;
const stringifyList = (items?: unknown[], maxItems = 5) => {
if (!items?.length) return 'none';
const values = items.map((item) => String(item));
const visibleValues = values.slice(0, maxItems);
return values.length > maxItems ? `${visibleValues.join(', ')} +${values.length - maxItems} more` : visibleValues.join(', ');
};
const getFirstObjectValue = <T,>(value?: Record<string, T>) => (value ? Object.values(value)[0] : undefined);
const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object';
const getStringValue = (value: unknown, fallback = 'unknown') => {
if (value === null || value === undefined) return fallback;
try {
const stringValue = String(value);
return stringValue || fallback;
} catch {
return fallback;
}
};
const getFiniteNumber = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const numericValue = typeof value === 'bigint' ? Number(value) : Number(value);
@@ -142,6 +173,24 @@ const getSafeArray = async (getValue?: () => unknown[] | Promise<unknown[]> | un
}
};
const getByteLength = (value: unknown): number | undefined => {
if (value === null || value === undefined) return undefined;
if (typeof value === 'string') return new TextEncoder().encode(value).byteLength;
if (value instanceof ArrayBuffer) return value.byteLength;
if (ArrayBuffer.isView(value)) return value.byteLength;
if (isRecord(value)) {
const directByteLength = getFiniteNumber(value.byteLength);
if (directByteLength !== undefined) return directByteLength;
const dataByteLength = getByteLength(value.data);
if (dataByteLength !== undefined) return dataByteLength;
}
if (Array.isArray(value)) {
const total = value.reduce((sum, entry) => sum + (getByteLength(entry) ?? 0), 0);
return total > 0 ? total : undefined;
}
return undefined;
};
const getTransferStatsFromHeliaCounters = (helia: unknown): TransferStats => {
const stats: TransferStats = {};
const visited = new WeakSet<object>();
@@ -199,17 +248,18 @@ const classifyTransferMetricPath = (path: string[]) => {
const getMetricSnapshot = async (source: unknown) => {
if (!isRecord(source)) return source;
for (const method of ['getMetrics', 'getMetricValues', 'toJSON']) {
const candidate = source[method];
if (typeof candidate !== 'function') continue;
try {
const snapshot = await candidate.call(source);
if (snapshot !== undefined) return snapshot;
} catch {
return undefined;
}
}
return source;
const snapshots = await Promise.all(
['getMetrics', 'getMetricValues', 'toJSON'].map(async (method) => {
const candidate = source[method];
if (typeof candidate !== 'function') return undefined;
try {
return await candidate.call(source);
} catch {
return undefined;
}
}),
);
return snapshots.find((snapshot) => snapshot !== undefined) ?? source;
};
const getTransferStatsFromMetricSnapshot = (snapshot: unknown): TransferStats => {
@@ -249,9 +299,92 @@ const mergeTransferStats = (primary: TransferStats, fallback: TransferStats): Tr
uploadedBytes: primary.uploadedBytes ?? fallback.uploadedBytes,
});
const getBrowserTransferStats = async (client?: Libp2pClientShape): Promise<TransferStats> => {
const getTransferStatsFromClientShape = (client?: Libp2pClientShape): TransferStats => {
const clientRecord = isRecord(client) ? (client as Record<string, unknown>) : undefined;
const statsSources = [clientRecord, clientRecord?.stats, clientRecord?.sessionStats].filter(Boolean);
return statsSources.reduce<TransferStats>((stats, source) => {
if (!isRecord(source)) return stats;
const downloadedBytes = source.totalIn ?? source.downloadedBytes ?? source.bytesReceived ?? source.receivedBytes;
const uploadedBytes = source.totalOut ?? source.uploadedBytes ?? source.bytesSent ?? source.sentBytes;
addTransferStats(stats, 'downloadedBytes', downloadedBytes);
addTransferStats(stats, 'uploadedBytes', uploadedBytes);
return stats;
}, {});
};
const getObservedTransferStats = (client?: Libp2pClientShape): ObservedTransferStats | undefined => {
if (!isRecord(client)) return undefined;
let stats = observedBrowserTransferStats.get(client);
if (!stats) {
stats = {
connections: new WeakSet<object>(),
downloadedBytes: 0,
streams: new WeakSet<object>(),
uploadedBytes: 0,
};
observedBrowserTransferStats.set(client, stats);
}
return stats;
};
const instrumentStreamTransferStats = (stream: unknown, stats: ObservedTransferStats) => {
if (!isRecord(stream) || stats.streams.has(stream)) return;
stats.streams.add(stream);
const send = stream.send;
if (typeof send === 'function') {
try {
stream.send = function sendWithTransferStats(this: unknown, data: unknown, ...args: unknown[]) {
addTransferStats(stats, 'uploadedBytes', getByteLength(data));
return send.call(this, data, ...args);
};
} catch {
// Some stream implementations may expose read-only methods.
}
}
const addEventListener = stream.addEventListener;
if (typeof addEventListener === 'function') {
try {
addEventListener.call(stream, 'message', (event: unknown) => {
const data = isRecord(event) ? (event.data ?? event.detail) : undefined;
addTransferStats(stats, 'downloadedBytes', getByteLength(data));
});
} catch {
return;
}
}
};
const instrumentConnectionTransferStats = (connection: unknown, stats: ObservedTransferStats) => {
if (!isRecord(connection)) return;
if (!stats.connections.has(connection)) {
stats.connections.add(connection);
const newStream = connection.newStream;
if (typeof newStream === 'function') {
try {
connection.newStream = async function newStreamWithTransferStats(this: unknown, ...args: unknown[]) {
const stream = await newStream.apply(this, args);
instrumentStreamTransferStats(stream, stats);
return stream;
};
} catch {
// Some connection implementations may expose read-only methods.
}
}
}
for (const stream of toArray(connection.streams)) instrumentStreamTransferStats(stream, stats);
};
const getBrowserTransferStats = async (client?: Libp2pClientShape, connections: unknown[] = []): Promise<TransferStats> => {
try {
const helia = client?._helia;
const observedStats = getObservedTransferStats(client);
if (observedStats) connections.forEach((connection) => instrumentConnectionTransferStats(connection, observedStats));
const clientStats = getTransferStatsFromClientShape(client);
const counterStats = getTransferStatsFromHeliaCounters(helia);
const metricSources = [helia?.metrics, helia?.libp2p?.metrics].filter(Boolean);
const metricSnapshots = await Promise.all(metricSources.map((source) => getMetricSnapshot(source)));
@@ -259,7 +392,7 @@ const getBrowserTransferStats = async (client?: Libp2pClientShape): Promise<Tran
.map((snapshot) => getTransferStatsFromMetricSnapshot(snapshot))
.reduce<TransferStats>((stats, nextStats) => mergeTransferStats(stats, nextStats), {});
return mergeTransferStats(counterStats, metricStats);
return mergeTransferStats(mergeTransferStats(mergeTransferStats(clientStats, counterStats), metricStats), observedStats ?? {});
} catch {
return {};
}
@@ -290,33 +423,98 @@ const hasProviderPublishingRouter = (client?: Libp2pClientShape) =>
(client?._helia?.routing?.routers ?? []).some((router) => isRecord(router) && !isKnownNoopProvide(router.provide));
const getBrowserMode = (client?: Libp2pClientShape) => {
if (!client) return 'unknown';
return hasSupportedAdd(client) && hasProviderPublishingRouter(client) ? 'seeding' : 'leeching';
if (!client) return 'Unknown';
return hasSupportedAdd(client) && hasProviderPublishingRouter(client) ? 'Seeding' : 'Leeching';
};
const getTransportLabel = (address: string) => {
const normalizedAddress = address.toLowerCase();
let transport = 'Unknown transport';
if (normalizedAddress.includes('/webtransport')) transport = 'WebTransport';
else if (normalizedAddress.includes('/webrtc-direct')) transport = 'WebRTC direct';
else if (normalizedAddress.includes('/webrtc')) transport = 'WebRTC';
else if (normalizedAddress.includes('/wss')) transport = 'Secure WebSocket';
else if (normalizedAddress.includes('/ws')) transport = 'WebSocket';
else if (normalizedAddress.includes('/quic')) transport = 'QUIC';
else if (normalizedAddress.includes('/tcp')) transport = 'TCP';
else if (normalizedAddress.includes('/udp')) transport = 'UDP';
return normalizedAddress.includes('/p2p-circuit') ? `${transport} through relay` : transport;
};
const getConnectionPeerId = (connection: unknown) => (isRecord(connection) ? getStringValue(connection.remotePeer) : 'unknown');
const getConnectionAddress = (connection: unknown) => (isRecord(connection) ? getStringValue(connection.remoteAddr, 'address unavailable') : 'address unavailable');
const getBrowserConnectedPeersRow = (peers: unknown[], connections: unknown[]): ConnectedPeersStatRow => {
const entries = connections.map<ConnectedPeerEntry>((connection) => {
const address = getConnectionAddress(connection);
const peerId = getConnectionPeerId(connection);
const fallbackId = `${peerId}-${address}`;
return {
address,
direction: isRecord(connection) ? getStringValue(connection.direction, '') : undefined,
id: isRecord(connection) ? getStringValue(connection.id, fallbackId) : fallbackId,
peerId,
status: isRecord(connection) ? getStringValue(connection.status, '') : undefined,
transport: getTransportLabel(address),
};
});
const peerIds = [...peers, ...entries].reduce<Set<string>>((ids, peerOrEntry) => {
const peerId = isRecord(peerOrEntry) && 'peerId' in peerOrEntry ? getStringValue(peerOrEntry.peerId) : getStringValue(peerOrEntry);
if (peerId && peerId !== 'unknown') ids.add(peerId);
return ids;
}, new Set());
return {
connectionCount: connections.length,
entries,
name: 'Connected peers',
peerCount: peerIds.size || entries.length,
type: 'connectedPeers',
};
};
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;
return {
address,
direction,
id: `${peerId}-${address}-${direction ?? ''}`,
peerId,
transport: getTransportLabel(address),
};
});
const peerIds = entries.reduce<Set<string>>((ids, entry) => {
if (entry.peerId && entry.peerId !== 'unknown') ids.add(entry.peerId);
return ids;
}, new Set());
return {
connectionCount: entries.length,
entries,
name: 'Connected peers',
peerCount: peerIds.size || entries.length,
type: 'connectedPeers',
};
};
const getBrowserLibp2pStats = async (account?: AccountShape): Promise<StatRow[]> => {
const client = getFirstObjectValue(account?.pkc?.clients?.libp2pJsClients) as Libp2pClientShape | undefined;
const libp2p = client?._helia?.libp2p;
const pubsub = libp2p?.services?.pubsub;
const [peers, connections, multiaddrs, pubsubPeers, transferStats] = await Promise.all([
getSafeArray(() => libp2p?.getPeers?.()),
getSafeArray(() => libp2p?.getConnections?.()),
getSafeArray(() => libp2p?.getMultiaddrs?.()),
getSafeArray(() => pubsub?.getPeers?.()),
getBrowserTransferStats(client),
]);
const [peers, connections] = await Promise.all([getSafeArray(() => libp2p?.getPeers?.()), getSafeArray(() => libp2p?.getConnections?.())]);
const transferStats = await getBrowserTransferStats(client, connections);
return [
{ name: 'mode', value: getBrowserMode(client) },
{ name: 'peer id', value: libp2p?.peerId?.toString() ?? 'unknown' },
{ name: 'client key', value: client?.key ?? 'unknown' },
{ name: 'connected peers', value: formatCount(peers.length, 'peer') },
{ name: 'connections', value: formatCount(connections.length, 'connection') },
{ name: 'downloaded data', value: transferStats.downloadedBytes === undefined ? 'unknown' : formatBytes(transferStats.downloadedBytes) },
{ name: 'uploaded data', value: transferStats.uploadedBytes === undefined ? 'unknown' : formatBytes(transferStats.uploadedBytes) },
{ name: 'listen addresses', value: stringifyList(multiaddrs) },
{ name: 'pubsub peers', value: formatCount(pubsubPeers.length, 'peer') },
{ name: 'routers', value: stringifyList(account?.pkcOptions?.httpRoutersOptions) },
{ name: 'Mode', value: getBrowserMode(client) },
{ name: 'Peer ID', value: libp2p?.peerId?.toString() ?? 'unknown' },
getBrowserConnectedPeersRow(peers, connections),
{ name: 'Data received', value: transferStats.downloadedBytes === undefined ? 'unknown' : formatBytes(transferStats.downloadedBytes) },
{ name: 'Data sent', value: transferStats.uploadedBytes === undefined ? 'unknown' : formatBytes(transferStats.uploadedBytes) },
];
};
@@ -345,17 +543,17 @@ const getElectronKuboStats = async (rpcState?: string, signal?: AbortSignal): Pr
]);
return [
{ name: 'mode', value: 'desktop Kubo' },
{ 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: 'connected peers', value: formatCount(Array.isArray(peers.Peers) ? peers.Peers.length : 0, 'peer') },
{ 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') },
{ name: 'Peer ID', value: identity.ID ?? 'unknown' },
{ name: 'Agent', value: identity.AgentVersion ?? version.Version ?? 'unknown' },
getElectronConnectedPeersRow(peers),
{ 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') },
];
};
@@ -364,6 +562,33 @@ const getP2PStats = async (mode: P2PRuntimeMode, account?: AccountShape, rpcStat
return getElectronKuboStats(rpcState, signal);
};
const ConnectedPeersValue = ({ row }: { row: ConnectedPeersStatRow }) => (
<details className={styles.connectedPeers} data-testid='connected-peers'>
<summary>
{formatCount(row.peerCount, 'peer')}, {formatCount(row.connectionCount, 'connection')}
</summary>
<div className={styles.connectedPeerList}>
{row.entries.length ? (
row.entries.map((entry) => (
<div className={styles.connectedPeer} key={entry.id}>
<div className={styles.connectedPeerMeta}>
<span className={styles.connectionTransport}>{entry.transport}</span>
{entry.direction && <span>{entry.direction}</span>}
{entry.status && <span>{entry.status}</span>}
</div>
<div className={styles.peerId}>{entry.peerId}</div>
<code className={styles.connectionAddress}>{entry.address}</code>
</div>
))
) : (
<div className={styles.connectedPeerEmpty}>No active peer addresses</div>
)}
</div>
</details>
);
const StatValue = ({ row }: { row: StatRow }) => (row.type === 'connectedPeers' ? <ConnectedPeersValue row={row} /> : row.value);
const P2PStatsSettings = () => {
const { t } = useTranslation();
const account = useAccount() as AccountShape | undefined;
@@ -413,13 +638,21 @@ const P2PStatsSettings = () => {
{statsState.rows.map((row) => (
<tr key={row.name}>
<td className={styles.statName}>{row.name}</td>
<td className={styles.statValue}>{row.value}</td>
<td className={styles.statValue}>
<StatValue row={row} />
</td>
</tr>
))}
{updatedAtLabel && (
<tr>
<td className={styles.statName}>{t('p2p_stats_updated')}</td>
<td className={styles.statValue}>{updatedAtLabel}</td>
</tr>
)}
</tbody>
</table>
<div className={styles.statsMeta}>
{statsState.loading ? t('p2p_stats_loading') : updatedAtLabel ? `${t('p2p_stats_updated')} ${updatedAtLabel}` : null}
{statsState.loading ? t('p2p_stats_loading') : null}
{statsState.error && <div className={styles.error}>{statsState.error}</div>}
</div>
</>