feat(p2p settings): add browser p2p stats

This commit is contained in:
Tommaso Casaburi
2026-05-07 16:56:29 +07:00
parent 2ebd9ecc30
commit 14a4790c8b
51 changed files with 1457 additions and 249 deletions
@@ -8,6 +8,18 @@ import SettingsModal from '../settings-modal';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
account: {
pkcOptions: {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
} as Record<string, any>,
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
@@ -42,6 +54,10 @@ vi.mock('../subscriptions-setting', () => ({
default: () => <div data-testid='subscriptions-settings-panel'>subscriptions-settings</div>,
}));
vi.mock('../p2p-stats-settings', () => ({
default: () => <div data-testid='p2p-stats-settings-panel'>p2p-stats-settings</div>,
}));
const LocationProbe = () => {
const location = useLocation();
return <div data-testid='location'>{location.pathname + location.hash}</div>;
@@ -134,6 +150,7 @@ describe('SettingsModal', () => {
expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull();
expect(container.querySelector('[data-testid="subscriptions-settings-panel"]')).not.toBeNull();
expect(container.querySelector('[data-testid="advanced-settings-panel"]')).not.toBeNull();
expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).not.toBeNull();
const collapseAllControl = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) =>
(candidate.textContent ?? '').includes('collapse_all_settings'),
@@ -151,6 +168,13 @@ describe('SettingsModal', () => {
expect(container.querySelector('[data-testid="account-settings"]')).toBeNull();
expect(container.querySelector('[data-testid="subscriptions-settings-panel"]')).toBeNull();
expect(container.querySelector('[data-testid="advanced-settings-panel"]')).toBeNull();
expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).toBeNull();
});
it('opens the p2p stats section from its hash', () => {
render('/all/settings#p2p-stats-settings');
expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).not.toBeNull();
});
it('closes the modal when the overlay is clicked', async () => {
@@ -84,6 +84,7 @@ const clickButton = async (text: string) => {
describe('AdvancedSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
testState.account = {
mediaIpfsGatewayUrl: 'https://media.old.example',
chainProviders: {
@@ -192,6 +193,66 @@ describe('AdvancedSettings', () => {
expect(textInputs[2]?.value).toBe('/tmp/connected-node');
});
it('saves the browser pure p2p toggle through advanced settings', async () => {
await renderSettings(false);
const checkbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox?.checked).toBe(false);
expect(container.textContent).not.toContain('pure P2P:');
expect(checkbox?.closest('label')?.nextElementSibling?.textContent).toBe('enable_pure_p2p_tip');
await act(async () => {
checkbox?.click();
});
await clickButton('save_advanced_settings');
expect(testState.setAccountMock).toHaveBeenCalledWith(
expect.objectContaining({
pkcOptions: expect.objectContaining({
httpRoutersOptions: ['https://router.old.example'],
ipfsGatewayUrls: undefined,
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
pkcRpcClientsOptions: undefined,
pubsubKuboRpcClientsOptions: undefined,
}),
}),
);
expect(localStorage.getItem('5chan:pure-p2p-browser-enabled')).toBe('true');
expect(reloadMock).toHaveBeenCalledOnce();
});
it('saves gateway mode defaults when browser pure p2p is disabled', async () => {
testState.account = {
mediaIpfsGatewayUrl: 'https://media.old.example',
pkcOptions: {
httpRoutersOptions: ['https://peers.pleb.bot'],
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
};
await renderSettings(false);
const checkbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox?.checked).toBe(true);
await act(async () => {
checkbox?.click();
});
await clickButton('save_advanced_settings');
expect(testState.setAccountMock).toHaveBeenCalledWith(
expect.objectContaining({
pkcOptions: expect.objectContaining({
httpRoutersOptions: ['https://peers.pleb.bot'],
ipfsGatewayUrls: ['https://ipfsgateway.xyz', 'https://gateway.plebpubsub.xyz', 'https://gateway.forumindex.com'],
libp2pJsClientsOptions: undefined,
pubsubKuboRpcClientsOptions: ['https://pubsubprovider.xyz/api/v0', 'https://plebpubsub.xyz/api/v0', 'https://rannithepleb.com/api/v0'],
}),
}),
);
expect(localStorage.getItem('5chan:pure-p2p-browser-enabled')).toBe('false');
});
it('toggles the node rpc instructions panel', async () => {
await renderSettings(false);
@@ -78,6 +78,19 @@
padding-left: 1px;
}
.pureP2PSettings {
margin-bottom: 15px;
}
.pureP2PSettings .settingTip {
margin: 2px 0 5px 0;
padding-left: 19px;
}
.pureP2PCheckbox {
margin-right: 5px;
}
.p2pRPCSettings button {
margin-left: 5px;
}
@@ -1,6 +1,8 @@
import { memo, RefObject, useRef, useState } from 'react';
import { setAccount, useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks';
import { useTranslation } from 'react-i18next';
import { getBrowserGatewayPkcOptions, getBrowserPureP2PPkcOptions, setPureP2PBrowserPreference } from '../../../lib/p2p-browser-config';
import { canConfigureBrowserPureP2P, isBrowserPureP2PEnabled } from '../../../lib/p2p-runtime';
import styles from './advanced-settings.module.css';
interface SettingsProps {
@@ -12,6 +14,7 @@ interface SettingsProps {
solRpcRef?: RefObject<HTMLTextAreaElement>;
p2pRpcRef?: RefObject<HTMLInputElement>;
p2pDataPathRef?: RefObject<HTMLInputElement>;
pureP2PBrowserRef?: RefObject<HTMLInputElement>;
}
type AccountProtocolOptions = {
@@ -19,6 +22,8 @@ type AccountProtocolOptions = {
dataPath?: string;
httpRoutersOptions?: string[];
ipfsGatewayUrls?: string[];
kuboRpcClientsOptions?: unknown[];
libp2pJsClientsOptions?: unknown[];
pkcRpcClientsOptions?: string[];
pubsubHttpClientsOptions?: string[];
pubsubKuboRpcClientsOptions?: string[];
@@ -210,6 +215,21 @@ const P2pDataPathSettings = ({ p2pDataPathRef }: SettingsProps) => {
);
};
const PureP2PBrowserSettings = ({ pureP2PBrowserRef }: SettingsProps) => {
const { t } = useTranslation();
const account = useAccount() as AccountShape | undefined;
return (
<div className={styles.pureP2PSettings}>
<label>
<input className={styles.pureP2PCheckbox} type='checkbox' defaultChecked={isBrowserPureP2PEnabled(account)} ref={pureP2PBrowserRef} />
{t('enable_pure_p2p')}
</label>
<div className={styles.settingTip}>{t('enable_pure_p2p_tip')}</div>
</div>
);
};
const isElectron = window.electronApi?.isElectron === true;
const AdvancedSettings = () => {
@@ -225,6 +245,7 @@ const AdvancedSettings = () => {
const httpRoutersRef = useRef<HTMLTextAreaElement>(null);
const p2pRpcRef = useRef<HTMLInputElement>(null);
const p2pDataPathRef = useRef<HTMLInputElement>(null);
const pureP2PBrowserRef = useRef<HTMLInputElement>(null);
const handleSave = async () => {
const ipfsGatewayUrls = ipfsGatewayUrlsRef.current?.value
@@ -256,6 +277,7 @@ const AdvancedSettings = () => {
const pkcRpcClientsOptions = p2pRpcRef.current?.value.trim() ? [p2pRpcRef.current.value.trim()] : undefined;
const dataPath = p2pDataPathRef.current?.value.trim() || undefined;
const pureP2PBrowserPreference = canConfigureBrowserPureP2P() ? pureP2PBrowserRef.current?.checked : undefined;
const chainProviders: Record<string, { urls: string[] | undefined; chainId: number }> = {};
if (ethRpcUrls && ethRpcUrls.length > 0) {
@@ -265,20 +287,44 @@ const AdvancedSettings = () => {
chainProviders.sol = { urls: solRpcUrls, chainId: 101 };
}
let pkcOptions: AccountProtocolOptions = {
...protocolOptions,
ipfsGatewayUrls,
pubsubKuboRpcClientsOptions,
httpRoutersOptions,
pkcRpcClientsOptions,
dataPath,
};
if (pureP2PBrowserPreference !== undefined) {
if (pureP2PBrowserPreference) {
const pureP2POptions = getBrowserPureP2PPkcOptions();
pkcOptions = {
...pkcOptions,
...pureP2POptions,
httpRoutersOptions: httpRoutersOptions?.length ? httpRoutersOptions : pureP2POptions.httpRoutersOptions,
pkcRpcClientsOptions: undefined,
};
} else {
const gatewayOptions = getBrowserGatewayPkcOptions();
pkcOptions = {
...pkcOptions,
...gatewayOptions,
ipfsGatewayUrls: ipfsGatewayUrls?.length ? ipfsGatewayUrls : gatewayOptions.ipfsGatewayUrls,
pubsubKuboRpcClientsOptions: pubsubKuboRpcClientsOptions?.length ? pubsubKuboRpcClientsOptions : gatewayOptions.pubsubKuboRpcClientsOptions,
httpRoutersOptions: httpRoutersOptions?.length ? httpRoutersOptions : gatewayOptions.httpRoutersOptions,
};
}
}
try {
await setAccount({
...account,
mediaIpfsGatewayUrl,
chainProviders,
pkcOptions: {
...protocolOptions,
ipfsGatewayUrls,
pubsubKuboRpcClientsOptions,
httpRoutersOptions,
pkcRpcClientsOptions,
dataPath,
},
pkcOptions,
});
if (pureP2PBrowserPreference !== undefined) setPureP2PBrowserPreference(pureP2PBrowserPreference);
alert('Options saved, reloading...');
window.location.reload();
} catch (e) {
@@ -293,6 +339,7 @@ const AdvancedSettings = () => {
return (
<div className={styles.content}>
{canConfigureBrowserPureP2P() && <PureP2PBrowserSettings pureP2PBrowserRef={pureP2PBrowserRef} />}
<div className={styles.category}>
<span className={styles.categoryTitle}>IPFS gateways:</span>
<span className={styles.categorySettings}>
@@ -0,0 +1,226 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
account: {} as Record<string, any>,
rpcSettings: { state: 'disconnected' } as Record<string, any>,
setAccountMock: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
setAccount: (account: unknown) => testState.setAccountMock(account),
useAccount: () => testState.account,
usePkcRpcSettings: () => testState.rpcSettings,
}));
let container: HTMLDivElement;
let root: Root;
const loadComponent = async (isElectron = false) => {
vi.resetModules();
window.electronApi = isElectron ? ({ isElectron: true } as Window['electronApi']) : undefined;
window.isElectron = isElectron;
return (await import('../p2p-stats-settings')).default;
};
const renderSettings = async (isElectron = false) => {
const P2PStatsSettings = await loadComponent(isElectron);
await act(async () => {
root.render(createElement(P2PStatsSettings));
await Promise.resolve();
});
};
describe('P2PStatsSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
window.electronApi = undefined;
window.isElectron = false;
testState.account = {
id: 'account-1',
author: { address: 'author', wallets: {} },
pkcOptions: {
ipfsGatewayUrls: ['https://gateway.example'],
},
};
testState.rpcSettings = { state: 'disconnected' };
testState.setAccountMock.mockReset().mockResolvedValue(undefined);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders browser libp2p stats from the active PKC client', async () => {
testState.account = {
...testState.account,
pkcOptions: {
httpRoutersOptions: ['https://router.example'],
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
pkc: {
clients: {
libp2pJsClients: {
libp2pjs: {
key: 'libp2pjs',
heliaWithKuboRpcClientFunctions: {
add: async () => {
throw new Error("Helia 'add' is not supported at the moment in pkc-js API");
},
},
_helia: {
libp2p: {
getConnections: () => ['connection-1'],
getMultiaddrs: () => ['/ip4/127.0.0.1/tcp/4001'],
getPeers: () => ['peer-1', 'peer-2'],
metrics: {
toJSON: () => ({
helia_bitswap_data_received_bytes: { global: 2048, peer1: 2048 },
helia_bitswap_sent_data_bytes_total: 1024,
}),
},
peerId: { toString: () => 'self-peer' },
services: {
pubsub: {
getPeers: () => ['peer-1'],
},
},
},
routing: {
routers: [
{
async provide() {
// noop
},
},
],
},
},
},
},
},
},
};
await renderSettings(false);
await act(async () => Promise.resolve());
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(container.textContent).toContain('self-peer');
expect(container.textContent).toContain('2 peers');
expect(container.textContent).toContain('downloaded data');
expect(container.textContent).toContain('2.00 KB');
expect(container.textContent).toContain('uploaded data');
expect(container.textContent).toContain('1.00 KB');
expect(container.textContent).not.toContain('pubsub topics');
expect(container.textContent).not.toContain('topic subscribers');
});
it('reads browser transfer counters from Helia bitswap ledgers', async () => {
testState.account = {
...testState.account,
pkcOptions: {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
pkc: {
clients: {
libp2pJsClients: {
libp2pjs: {
key: 'libp2pjs',
_helia: {
blockstore: {
child: {
blockBrokers: [
{
bitswap: {
peerWantLists: {
ledgerMap: new Map([['peer-1', { bytesReceived: 4096, bytesSent: 2048 }]]),
},
},
},
],
},
},
libp2p: {
getConnections: () => [],
getMultiaddrs: () => [],
getPeers: () => [],
peerId: { toString: () => 'self-peer' },
},
},
},
},
},
},
};
await renderSettings(false);
await act(async () => Promise.resolve());
expect(container.textContent).toContain('downloaded data');
expect(container.textContent).toContain('4.00 KB');
expect(container.textContent).toContain('uploaded data');
expect(container.textContent).toContain('2.00 KB');
});
it('reports seeding only when browser Helia can add and publish provider records', async () => {
testState.account = {
...testState.account,
pkcOptions: {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
pkc: {
clients: {
libp2pJsClients: {
libp2pjs: {
key: 'libp2pjs',
heliaWithKuboRpcClientFunctions: {
add: async () => ({ cid: 'cid' }),
},
_helia: {
libp2p: {
getConnections: () => [],
getMultiaddrs: () => [],
getPeers: () => [],
peerId: { toString: () => 'self-peer' },
},
routing: {
routers: [
{
provide: async (cid: unknown) => cid,
},
],
},
},
},
},
},
},
};
await renderSettings(false);
await act(async () => Promise.resolve());
expect(container.textContent).toContain('seeding');
expect(container.textContent).not.toContain('seed mode');
});
});
@@ -0,0 +1 @@
export { default } from './p2p-stats-settings';
@@ -0,0 +1,54 @@
.content {
margin-left: 10px;
}
.setting {
margin-bottom: 15px;
}
.settingTip {
display: block;
font-size: 0.85em;
margin: 4px 0 0 20px;
}
.stats {
margin: 6px 0 0;
width: calc(100% - 8px);
border-collapse: collapse;
}
.stats th {
text-align: left;
font-weight: 700;
}
.stats th,
.stats td {
padding: 2px 4px 3px 0;
vertical-align: top;
}
.statName {
width: 34%;
text-transform: capitalize;
white-space: nowrap;
}
.statValue {
word-break: break-word;
}
.statsMeta {
margin-top: 5px;
font-size: 0.85em;
}
.statsMeta button {
margin-left: 5px;
}
.error {
color: var(--error-text-color, #b00020);
word-break: break-word;
}
@@ -0,0 +1,434 @@
import { memo, useEffect, useReducer } from 'react';
import { useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks';
import { useTranslation } from 'react-i18next';
import { getP2PRuntimeMode, type P2PRuntimeMode } from '../../../lib/p2p-runtime';
import styles from './p2p-stats-settings.module.css';
type AccountShape = Record<string, any>;
type StatRow = {
name: string;
value: string;
};
type StatsState = {
error?: string;
loading: boolean;
rows: StatRow[];
updatedAt?: number;
};
type StatsAction =
| {
type: 'loading';
}
| {
rows: StatRow[];
timestamp: number;
type: 'loaded';
}
| {
error: string;
timestamp: number;
type: 'failed';
};
type Libp2pClientShape = {
_helia?: {
libp2p?: {
getConnections?: () => unknown[] | Promise<unknown[]>;
getMultiaddrs?: () => unknown[] | Promise<unknown[]>;
getPeers?: () => unknown[] | Promise<unknown[]>;
peerId?: { toString: () => string };
services?: {
pubsub?: {
getPeers?: () => unknown[] | Promise<unknown[]>;
};
};
metrics?: unknown;
};
metrics?: unknown;
routing?: {
routers?: unknown[];
};
};
heliaWithKuboRpcClientFunctions?: {
add?: unknown;
};
key?: string;
};
type TransferStats = {
downloadedBytes?: number;
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 statsReducer = (state: StatsState, action: StatsAction): StatsState => {
if (action.type === 'loading') return { ...state, error: undefined, loading: true };
if (action.type === 'loaded') return { loading: false, rows: action.rows, updatedAt: action.timestamp };
return { error: action.error, loading: false, rows: [], updatedAt: action.timestamp };
};
const getErrorMessage = (error: unknown, fallback = 'Error') => (error instanceof Error ? error.message : String(error || fallback));
const formatCount = (count: number, singular: string, plural = `${singular}s`) => `${count} ${count === 1 ? singular : plural}`;
const formatBytes = (value: unknown) => {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) return String(value ?? 'unknown');
if (numericValue < 1024) return `${numericValue} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let size = numericValue / 1024;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
};
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 getFiniteNumber = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const numericValue = typeof value === 'bigint' ? Number(value) : Number(value);
return Number.isFinite(numericValue) ? numericValue : undefined;
};
const addTransferStats = (stats: TransferStats, direction: keyof TransferStats, value: unknown) => {
const numericValue = getFiniteNumber(value);
if (numericValue === undefined) return;
stats[direction] = (stats[direction] ?? 0) + numericValue;
};
const getEntries = (value: unknown): [string, unknown][] => {
try {
if (value instanceof Map) return Array.from(value.entries()).map(([key, entry]) => [String(key), entry]);
if (Array.isArray(value)) return value.map((entry, index) => [String(index), entry]);
if (isRecord(value)) return Object.entries(value);
return [];
} catch {
return [];
}
};
const toArray = (value: unknown): unknown[] => {
if (Array.isArray(value)) return value;
if (value && typeof value === 'object' && Symbol.iterator in value) return Array.from(value as Iterable<unknown>);
return [];
};
const getSafeArray = async (getValue?: () => unknown[] | Promise<unknown[]> | undefined): Promise<unknown[]> => {
try {
return toArray(getValue ? await getValue() : undefined);
} catch {
return [];
}
};
const getTransferStatsFromHeliaCounters = (helia: unknown): TransferStats => {
const stats: TransferStats = {};
const visited = new WeakSet<object>();
let objectsVisited = 0;
const visit = (value: unknown, depth: number) => {
try {
if (!isRecord(value) || visited.has(value) || depth > MAX_TRANSFER_COUNTER_DEPTH || objectsVisited > MAX_TRANSFER_COUNTER_OBJECTS) return;
visited.add(value);
objectsVisited++;
if ('bytesReceived' in value || 'bytesSent' in value) {
addTransferStats(stats, 'downloadedBytes', value.bytesReceived);
addTransferStats(stats, 'uploadedBytes', value.bytesSent);
}
for (const [key, entry] of getEntries(value)) {
if (typeof entry === 'function' || key === 'logger' || key === 'log' || key === 'events' || key === 'datastore' || key === 'routing') continue;
visit(entry, depth + 1);
}
} catch {
return;
}
};
visit(helia, 0);
return stats;
};
const classifyTransferMetricPath = (path: string[]) => {
const normalizedPath = path
.join('_')
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
if (normalizedPath.includes('rate')) return undefined;
if (
normalizedPath.includes('totalin') ||
normalizedPath.includes('bytesreceived') ||
normalizedPath.includes('receivedbytes') ||
normalizedPath.includes('datareceivedbytes')
) {
return 'downloadedBytes' as const;
}
if (
normalizedPath.includes('totalout') ||
normalizedPath.includes('bytessent') ||
normalizedPath.includes('sentbytes') ||
normalizedPath.includes('datasentbytes') ||
normalizedPath.includes('sentdatabytes')
) {
return 'uploadedBytes' as const;
}
return undefined;
};
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 getTransferStatsFromMetricSnapshot = (snapshot: unknown): TransferStats => {
const stats: TransferStats = {};
const visited = new WeakSet<object>();
const visit = (value: unknown, path: string[], depth: number) => {
const direction = classifyTransferMetricPath(path);
const numericValue = getFiniteNumber(value);
if (direction && numericValue !== undefined) {
addTransferStats(stats, direction, numericValue);
return;
}
if (!isRecord(value) || visited.has(value) || depth > MAX_TRANSFER_COUNTER_DEPTH) return;
visited.add(value);
if (direction && 'global' in value) {
addTransferStats(stats, direction, value.global);
return;
}
if (direction && 'value' in value) {
addTransferStats(stats, direction, value.value);
return;
}
for (const [key, entry] of getEntries(value)) visit(entry, [...path, key], depth + 1);
};
visit(snapshot, [], 0);
return stats;
};
const mergeTransferStats = (primary: TransferStats, fallback: TransferStats): TransferStats => ({
downloadedBytes: primary.downloadedBytes ?? fallback.downloadedBytes,
uploadedBytes: primary.uploadedBytes ?? fallback.uploadedBytes,
});
const getBrowserTransferStats = async (client?: Libp2pClientShape): Promise<TransferStats> => {
try {
const helia = client?._helia;
const counterStats = getTransferStatsFromHeliaCounters(helia);
const metricSources = [helia?.metrics, helia?.libp2p?.metrics].filter(Boolean);
let metricStats: TransferStats = {};
for (const source of metricSources) {
metricStats = mergeTransferStats(metricStats, getTransferStatsFromMetricSnapshot(await getMetricSnapshot(source)));
}
return mergeTransferStats(counterStats, metricStats);
} catch {
return {};
}
};
const getFunctionSource = (value: unknown) => {
if (typeof value !== 'function') return undefined;
try {
return Function.prototype.toString.call(value).toLowerCase();
} catch {
return undefined;
}
};
const hasSupportedAdd = (client?: Libp2pClientShape) => {
const add = client?.heliaWithKuboRpcClientFunctions?.add;
const source = getFunctionSource(add);
return typeof add === 'function' && !source?.includes('not supported') && !source?.includes('unsupported');
};
const isKnownNoopProvide = (provide: unknown) => {
const source = getFunctionSource(provide);
if (typeof provide !== 'function') return true;
return Boolean(source?.includes('noop') || source?.replace(/\s/g, '') === 'asyncprovide(){}');
};
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';
};
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),
]);
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) },
];
};
const kuboPostJson = async (path: string, params?: Record<string, string | boolean>, signal?: AbortSignal) => {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params ?? {})) searchParams.set(key, String(value));
const query = searchParams.toString();
const response = await fetch(`${KUBO_API_URL}/${path}${query ? `?${query}` : ''}`, { method: 'POST', signal });
if (!response.ok) throw new Error(`Kubo ${path} returned ${response.status}`);
const text = await response.text();
const firstJsonLine = text
.split('\n')
.map((line) => line.trim())
.find(Boolean);
return firstJsonLine ? JSON.parse(firstJsonLine) : {};
};
const getElectronKuboStats = async (rpcState?: string, signal?: AbortSignal): Promise<StatRow[]> => {
const [identity, version, peers, bandwidth, repo, bitswap] = await Promise.all([
kuboPostJson('id', undefined, signal),
kuboPostJson('version', undefined, signal),
kuboPostJson('swarm/peers', { direction: true, latency: true, streams: true }, signal),
kuboPostJson('stats/bw', undefined, signal),
kuboPostJson('repo/stat', undefined, signal),
kuboPostJson('bitswap/stat', undefined, signal),
]);
return [
{ name: 'mode', value: 'desktop Kubo' },
{ name: 'PKC RPC', value: rpcState ?? 'unknown' },
{ name: 'peer id', value: identity.ID ?? 'unknown' },
{ name: 'agent', value: identity.AgentVersion ?? version.Version ?? 'unknown' },
{ name: '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') },
];
};
const getP2PStats = async (mode: P2PRuntimeMode, account?: AccountShape, rpcState?: string, signal?: AbortSignal) => {
if (mode === 'browser-libp2p') return getBrowserLibp2pStats(account);
return getElectronKuboStats(rpcState, signal);
};
const P2PStatsSettings = () => {
const { t } = useTranslation();
const account = useAccount() as AccountShape | undefined;
const pkcRpcSettings = usePkcRpcSettings();
const mode = getP2PRuntimeMode(account);
const [statsState, dispatchStats] = useReducer(statsReducer, { loading: !!mode, rows: [] });
const updatedAtLabel = statsState.updatedAt ? new Date(statsState.updatedAt).toLocaleTimeString() : undefined;
useEffect(() => {
const abortController = new AbortController();
const { signal } = abortController;
const activeMode = mode;
const rpcState = pkcRpcSettings?.state;
if (!activeMode) return () => abortController.abort();
const refreshStats = async () => {
dispatchStats({ type: 'loading' });
try {
const rows = await getP2PStats(activeMode, account, rpcState, signal);
if (!signal.aborted) dispatchStats({ rows, timestamp: Date.now(), type: 'loaded' });
} catch (error) {
if (!signal.aborted) {
dispatchStats({
error: getErrorMessage(error),
timestamp: Date.now(),
type: 'failed',
});
}
}
};
void refreshStats();
const intervalId = window.setInterval(refreshStats, STATS_REFRESH_MS);
return () => {
abortController.abort();
window.clearInterval(intervalId);
};
}, [account, mode, pkcRpcSettings?.state]);
return (
<div className={styles.content} data-testid='p2p-stats-settings-panel'>
{mode ? (
<>
<table className={styles.stats}>
<tbody>
{statsState.rows.map((row) => (
<tr key={row.name}>
<td className={styles.statName}>{row.name}</td>
<td className={styles.statValue}>{row.value}</td>
</tr>
))}
</tbody>
</table>
<div className={styles.statsMeta}>
{statsState.loading ? t('p2p_stats_loading') : updatedAtLabel ? `${t('p2p_stats_updated')} ${updatedAtLabel}` : null}
{statsState.error && <div className={styles.error}>{statsState.error}</div>}
</div>
</>
) : (
<div className={styles.statsMeta}>{t('p2p_stats_starting')}</div>
)}
</div>
);
};
export default memo(P2PStatsSettings);
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useAccount } from '@bitsocial/bitsocial-react-hooks';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import styles from './settings-modal.module.css';
@@ -9,21 +10,25 @@ import InterfaceSettings from './interface-settings';
import MediaHostingSettings from './media-hosting-settings';
import AdvancedSettings from './advanced-settings';
import SubscriptionsSetting from './subscriptions-setting';
import P2PStatsSettings from './p2p-stats-settings';
import { P2P_STATS_SECTION_ID, shouldShowP2PSettingsSection } from '../../lib/p2p-runtime';
const allSectionIds = ['interface-settings', 'media-hosting-settings', 'account-settings', 'subscriptions-settings', 'advanced-settings'];
const hashToSection = (hash: string): string | null => {
const hashToSection = (hash: string, sectionIds = allSectionIds): string | null => {
if (hash === 'crypto-address-settings' || hash === 'crypto-wallet-settings') return 'account-settings';
if (allSectionIds.includes(hash)) return hash;
if (sectionIds.includes(hash)) return hash;
return null;
};
const SettingsModal = () => {
const { t } = useTranslation();
const account = useAccount();
const location = useLocation();
const navigate = useNavigate();
const hash = location.hash.slice(1);
const hashSection = hashToSection(hash);
const sectionIds = useMemo(() => (shouldShowP2PSettingsSection(account) ? [...allSectionIds, P2P_STATS_SECTION_ID] : allSectionIds), [account]);
const hashSection = hashToSection(hash, sectionIds);
const closeModal = useCallback(() => {
const newPath = location.pathname.replace(/\/settings$/, '');
@@ -59,8 +64,9 @@ const SettingsModal = () => {
const showAccountSettings = visibleExpandedSections.has('account-settings');
const showSubscriptionsSettings = visibleExpandedSections.has('subscriptions-settings');
const showAdvancedSettings = visibleExpandedSections.has('advanced-settings');
const showP2PStatsSettings = visibleExpandedSections.has(P2P_STATS_SECTION_ID);
const allExpanded = useMemo(() => allSectionIds.every((id) => visibleExpandedSections.has(id)), [visibleExpandedSections]);
const allExpanded = useMemo(() => sectionIds.every((id) => visibleExpandedSections.has(id)), [sectionIds, visibleExpandedSections]);
const basePath = location.pathname;
@@ -89,7 +95,7 @@ const SettingsModal = () => {
setExpandedSections(new Set());
navigate(basePath, { replace: true });
} else {
setExpandedSections(new Set(allSectionIds));
setExpandedSections(new Set(sectionIds));
navigate(basePath, { replace: true });
}
};
@@ -169,6 +175,17 @@ const SettingsModal = () => {
</label>
</div>
{showAdvancedSettings && <AdvancedSettings />}
{sectionIds.includes(P2P_STATS_SECTION_ID) && (
<>
<div id={P2P_STATS_SECTION_ID} className={`${styles.setting} ${styles.category}`}>
<label onClick={() => handleCategoryClick(P2P_STATS_SECTION_ID)}>
<span className={showP2PStatsSettings ? styles.hideButton : styles.showButton} />
{t('p2p_stats')}
</label>
</div>
{showP2PStatsSettings && <P2PStatsSettings />}
</>
)}
</div>
</>
);