mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(p2p settings): add browser p2p stats
This commit is contained in:
@@ -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}>
|
||||
|
||||
+226
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { configureP2PBrowserPkcOptions, isP2PBrowserHostname, P2P_BROWSER_PKC_OPTIONS } from '../p2p-browser-config';
|
||||
import {
|
||||
configureP2PBrowserPkcOptions,
|
||||
getPureP2PBrowserPreference,
|
||||
isP2PBrowserHostname,
|
||||
P2P_BROWSER_PKC_OPTIONS,
|
||||
PURE_P2P_BROWSER_SETTING_KEY,
|
||||
setPureP2PBrowserPreference,
|
||||
shouldUsePureP2PBrowser,
|
||||
} from '../p2p-browser-config';
|
||||
|
||||
const createStorage = (values: Record<string, string | undefined> = {}) => ({
|
||||
getItem: (key: string) => values[key] ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values[key] = value;
|
||||
},
|
||||
});
|
||||
|
||||
describe('p2p-browser-config', () => {
|
||||
it('detects p2p subdomains', () => {
|
||||
@@ -10,31 +25,78 @@ describe('p2p-browser-config', () => {
|
||||
expect(isP2PBrowserHostname('www.p2p.5chan.app')).toBe(false);
|
||||
});
|
||||
|
||||
it('configures browser PKC options for p2p hostnames', () => {
|
||||
it('leaves browser PKC options untouched by default', () => {
|
||||
const targetWindow = {
|
||||
location: { hostname: 'p2p.5chan.app' },
|
||||
location: { hostname: '5chan.app' },
|
||||
localStorage: createStorage(),
|
||||
defaultPkcOptions: {
|
||||
ipfsGatewayUrls: ['https://gateway.example'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(configureP2PBrowserPkcOptions(targetWindow)).toBe(false);
|
||||
expect(targetWindow.defaultPkcOptions).toEqual({
|
||||
ipfsGatewayUrls: ['https://gateway.example'],
|
||||
});
|
||||
});
|
||||
|
||||
it('configures browser PKC options when pure p2p is enabled', () => {
|
||||
const targetWindow = {
|
||||
location: { hostname: '5chan.app' },
|
||||
localStorage: createStorage({ [PURE_P2P_BROWSER_SETTING_KEY]: 'true' }),
|
||||
defaultPkcOptions: {
|
||||
ipfsGatewayUrls: ['https://gateway.example'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(configureP2PBrowserPkcOptions(targetWindow)).toBe(true);
|
||||
expect(targetWindow.defaultPkcOptions).toEqual({
|
||||
ipfsGatewayUrls: ['https://gateway.example'],
|
||||
...P2P_BROWSER_PKC_OPTIONS,
|
||||
});
|
||||
expect(targetWindow.defaultPkcOptions).toEqual(P2P_BROWSER_PKC_OPTIONS);
|
||||
});
|
||||
|
||||
it('leaves normal hostnames untouched', () => {
|
||||
it('leaves browser PKC options untouched when pure p2p is disabled', () => {
|
||||
const defaultPkcOptions = {
|
||||
ipfsGatewayUrls: ['https://gateway.example'],
|
||||
};
|
||||
const targetWindow = {
|
||||
location: { hostname: '5chan.app' },
|
||||
localStorage: createStorage({ [PURE_P2P_BROWSER_SETTING_KEY]: 'false' }),
|
||||
defaultPkcOptions,
|
||||
};
|
||||
|
||||
expect(configureP2PBrowserPkcOptions(targetWindow)).toBe(false);
|
||||
expect(targetWindow.defaultPkcOptions).toBe(defaultPkcOptions);
|
||||
});
|
||||
|
||||
it('leaves electron defaults untouched', () => {
|
||||
const defaultPkcOptions = {
|
||||
pkcRpcClientsOptions: ['ws://localhost:9138'],
|
||||
};
|
||||
const targetWindow = {
|
||||
electronApi: { isElectron: true },
|
||||
location: { hostname: 'localhost' },
|
||||
localStorage: createStorage(),
|
||||
defaultPkcOptions,
|
||||
};
|
||||
|
||||
expect(configureP2PBrowserPkcOptions(targetWindow)).toBe(false);
|
||||
expect(targetWindow.defaultPkcOptions).toBe(defaultPkcOptions);
|
||||
});
|
||||
|
||||
it('persists and reads the browser pure p2p preference', () => {
|
||||
const targetWindow = {
|
||||
location: { hostname: '5chan.app' },
|
||||
localStorage: createStorage(),
|
||||
};
|
||||
|
||||
expect(getPureP2PBrowserPreference(targetWindow)).toBeUndefined();
|
||||
expect(shouldUsePureP2PBrowser(targetWindow)).toBe(false);
|
||||
|
||||
setPureP2PBrowserPreference(false, targetWindow);
|
||||
expect(getPureP2PBrowserPreference(targetWindow)).toBe(false);
|
||||
expect(shouldUsePureP2PBrowser(targetWindow)).toBe(false);
|
||||
|
||||
setPureP2PBrowserPreference(true, targetWindow);
|
||||
expect(getPureP2PBrowserPreference(targetWindow)).toBe(true);
|
||||
expect(shouldUsePureP2PBrowser(targetWindow)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getBrowserGatewayAccountOptions,
|
||||
getBrowserPureP2PAccountOptions,
|
||||
getP2PRuntimeMode,
|
||||
isBrowserPureP2PEnabled,
|
||||
shouldShowP2PSettingsSection,
|
||||
} from '../p2p-runtime';
|
||||
|
||||
const browserWindow = {
|
||||
electronApi: undefined,
|
||||
isElectron: false,
|
||||
localStorage: {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
},
|
||||
} as unknown as Window;
|
||||
|
||||
const electronWindow = {
|
||||
electronApi: { isElectron: true },
|
||||
isElectron: true,
|
||||
} as unknown as Window;
|
||||
|
||||
describe('p2p-runtime', () => {
|
||||
it('detects browser libp2p accounts from options and live clients', () => {
|
||||
expect(getP2PRuntimeMode({ pkcOptions: { libp2pJsClientsOptions: [{ key: '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', () => {
|
||||
const account = { pkcOptions: { pkcRpcClientsOptions: ['ws://localhost:9138'] } };
|
||||
|
||||
expect(getP2PRuntimeMode(account, electronWindow)).toBe('electron-kubo-rpc');
|
||||
expect(getP2PRuntimeMode(account, browserWindow)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows p2p settings in browsers only when pure p2p is enabled or active', () => {
|
||||
expect(shouldShowP2PSettingsSection(undefined, browserWindow)).toBe(false);
|
||||
expect(shouldShowP2PSettingsSection({ pkcOptions: { ipfsGatewayUrls: ['https://gateway.example'] } }, browserWindow)).toBe(false);
|
||||
expect(isBrowserPureP2PEnabled({ pkcOptions: { ipfsGatewayUrls: ['https://gateway.example'] } }, browserWindow)).toBe(false);
|
||||
});
|
||||
|
||||
it('builds browser p2p and gateway account options without a direct pkc-js import', () => {
|
||||
const account = {
|
||||
pkcOptions: {
|
||||
httpRoutersOptions: ['https://custom-router.example'],
|
||||
ipfsGatewayUrls: ['https://gateway.example'],
|
||||
pkcRpcClientsOptions: ['ws://remote.example'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(getBrowserPureP2PAccountOptions(account)).toMatchObject({
|
||||
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
|
||||
ipfsGatewayUrls: undefined,
|
||||
pkcRpcClientsOptions: undefined,
|
||||
});
|
||||
expect(getBrowserGatewayAccountOptions(account)).toMatchObject({
|
||||
ipfsGatewayUrls: ['https://ipfsgateway.xyz', 'https://gateway.plebpubsub.xyz', 'https://gateway.forumindex.com'],
|
||||
libp2pJsClientsOptions: undefined,
|
||||
pkcRpcClientsOptions: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,85 @@
|
||||
export const PURE_P2P_BROWSER_SETTING_KEY = '5chan:pure-p2p-browser-enabled';
|
||||
|
||||
export const P2P_BROWSER_PKC_OPTIONS = {
|
||||
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
|
||||
ipfsGatewayUrls: undefined,
|
||||
kuboRpcClientsOptions: undefined,
|
||||
pubsubHttpClientsOptions: undefined,
|
||||
pubsubKuboRpcClientsOptions: undefined,
|
||||
httpRoutersOptions: ['https://peers.pleb.bot', 'https://peers.forumindex.com'],
|
||||
};
|
||||
|
||||
const GATEWAY_BROWSER_PKC_OPTIONS = {
|
||||
ipfsGatewayUrls: ['https://ipfsgateway.xyz', 'https://gateway.plebpubsub.xyz', 'https://gateway.forumindex.com'],
|
||||
kuboRpcClientsOptions: undefined,
|
||||
libp2pJsClientsOptions: undefined,
|
||||
pubsubHttpClientsOptions: undefined,
|
||||
pubsubKuboRpcClientsOptions: ['https://pubsubprovider.xyz/api/v0', 'https://plebpubsub.xyz/api/v0', 'https://rannithepleb.com/api/v0'],
|
||||
httpRoutersOptions: ['https://routing.lol', 'https://peers.pleb.bot', 'https://peers.plebpubsub.xyz', 'https://peers.forumindex.com'],
|
||||
};
|
||||
|
||||
type P2PBrowserConfigWindow = {
|
||||
location: Pick<Location, 'hostname'>;
|
||||
defaultPkcOptions?: Record<string, unknown>;
|
||||
electronApi?: { isElectron?: boolean };
|
||||
isElectron?: boolean;
|
||||
localStorage?: Pick<Storage, 'getItem' | 'setItem'>;
|
||||
};
|
||||
|
||||
export const isP2PBrowserHostname = (hostname: string) => hostname.toLowerCase().startsWith('p2p.');
|
||||
|
||||
export const getBrowserPureP2PPkcOptions = () => ({
|
||||
...P2P_BROWSER_PKC_OPTIONS,
|
||||
libp2pJsClientsOptions: P2P_BROWSER_PKC_OPTIONS.libp2pJsClientsOptions.map((options) => ({ ...options })),
|
||||
httpRoutersOptions: [...P2P_BROWSER_PKC_OPTIONS.httpRoutersOptions],
|
||||
});
|
||||
|
||||
export const getBrowserGatewayPkcOptions = () => ({
|
||||
...GATEWAY_BROWSER_PKC_OPTIONS,
|
||||
ipfsGatewayUrls: [...GATEWAY_BROWSER_PKC_OPTIONS.ipfsGatewayUrls],
|
||||
pubsubKuboRpcClientsOptions: [...GATEWAY_BROWSER_PKC_OPTIONS.pubsubKuboRpcClientsOptions],
|
||||
httpRoutersOptions: [...GATEWAY_BROWSER_PKC_OPTIONS.httpRoutersOptions],
|
||||
});
|
||||
|
||||
export const getPureP2PBrowserPreference = (targetWindow: P2PBrowserConfigWindow = window) => {
|
||||
try {
|
||||
const storedValue = targetWindow.localStorage?.getItem(PURE_P2P_BROWSER_SETTING_KEY);
|
||||
if (storedValue === 'true') return true;
|
||||
if (storedValue === 'false') return false;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const setPureP2PBrowserPreference = (enabled: boolean, targetWindow: P2PBrowserConfigWindow = window) => {
|
||||
try {
|
||||
targetWindow.localStorage?.setItem(PURE_P2P_BROWSER_SETTING_KEY, String(enabled));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export const isElectronRuntime = (targetWindow: P2PBrowserConfigWindow = window) => targetWindow.electronApi?.isElectron === true || targetWindow.isElectron === true;
|
||||
|
||||
export const shouldUsePureP2PBrowser = (targetWindow: P2PBrowserConfigWindow = window) => {
|
||||
if (isElectronRuntime(targetWindow)) return false;
|
||||
|
||||
const preference = getPureP2PBrowserPreference(targetWindow);
|
||||
if (preference !== undefined) return preference;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const configureP2PBrowserPkcOptions = (targetWindow: P2PBrowserConfigWindow = window) => {
|
||||
if (!isP2PBrowserHostname(targetWindow.location.hostname)) {
|
||||
if (!shouldUsePureP2PBrowser(targetWindow)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
targetWindow.defaultPkcOptions = {
|
||||
...targetWindow.defaultPkcOptions,
|
||||
libp2pJsClientsOptions: P2P_BROWSER_PKC_OPTIONS.libp2pJsClientsOptions.map((options) => ({ ...options })),
|
||||
httpRoutersOptions: [...P2P_BROWSER_PKC_OPTIONS.httpRoutersOptions],
|
||||
...getBrowserPureP2PPkcOptions(),
|
||||
};
|
||||
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getBrowserGatewayPkcOptions, getBrowserPureP2PPkcOptions, isElectronRuntime, shouldUsePureP2PBrowser } from './p2p-browser-config';
|
||||
|
||||
export const P2P_STATS_SECTION_ID = 'p2p-stats-settings';
|
||||
|
||||
export type P2PRuntimeMode = 'browser-libp2p' | 'electron-kubo-rpc';
|
||||
|
||||
type AccountProtocolOptions = {
|
||||
httpRoutersOptions?: string[];
|
||||
ipfsGatewayUrls?: string[];
|
||||
kuboRpcClientsOptions?: unknown[];
|
||||
libp2pJsClientsOptions?: unknown[];
|
||||
pkcRpcClientsOptions?: string[];
|
||||
pubsubHttpClientsOptions?: unknown[];
|
||||
pubsubKuboRpcClientsOptions?: unknown[];
|
||||
};
|
||||
|
||||
type AccountShape = {
|
||||
pkc?: {
|
||||
clients?: {
|
||||
libp2pJsClients?: Record<string, unknown>;
|
||||
pkcRpcClients?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
pkcOptions?: AccountProtocolOptions;
|
||||
};
|
||||
|
||||
const toAccountShape = (account: unknown) => account as AccountShape | undefined;
|
||||
|
||||
const hasArrayItems = (value: unknown) => Array.isArray(value) && value.length > 0;
|
||||
|
||||
const hasObjectItems = (value: unknown) => !!value && typeof value === 'object' && Object.keys(value).length > 0;
|
||||
|
||||
export const getP2PRuntimeMode = (account?: unknown, targetWindow: Window = window): P2PRuntimeMode | null => {
|
||||
const accountShape = toAccountShape(account);
|
||||
const protocolOptions = accountShape?.pkcOptions;
|
||||
const clients = accountShape?.pkc?.clients;
|
||||
|
||||
if (hasArrayItems(protocolOptions?.libp2pJsClientsOptions) || hasObjectItems(clients?.libp2pJsClients)) {
|
||||
return 'browser-libp2p';
|
||||
}
|
||||
|
||||
if (isElectronRuntime(targetWindow) && (hasArrayItems(protocolOptions?.pkcRpcClientsOptions) || hasObjectItems(clients?.pkcRpcClients))) {
|
||||
return 'electron-kubo-rpc';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const canConfigureBrowserPureP2P = (targetWindow: Window = window) => !isElectronRuntime(targetWindow);
|
||||
|
||||
export const shouldShowP2PSettingsSection = (account?: unknown, targetWindow: Window = window) =>
|
||||
getP2PRuntimeMode(account, targetWindow) !== null || (canConfigureBrowserPureP2P(targetWindow) && isBrowserPureP2PEnabled(account, targetWindow));
|
||||
|
||||
export const isBrowserPureP2PEnabled = (account?: unknown, targetWindow: Window = window) => {
|
||||
const accountShape = toAccountShape(account);
|
||||
if (!canConfigureBrowserPureP2P(targetWindow)) return false;
|
||||
if (getP2PRuntimeMode(account, targetWindow) === 'browser-libp2p') return true;
|
||||
if (hasArrayItems(accountShape?.pkcOptions?.ipfsGatewayUrls) || hasArrayItems(accountShape?.pkcOptions?.pubsubKuboRpcClientsOptions)) return false;
|
||||
return shouldUsePureP2PBrowser(targetWindow);
|
||||
};
|
||||
|
||||
export const getBrowserPureP2PAccountOptions = (account?: unknown) => ({
|
||||
...toAccountShape(account)?.pkcOptions,
|
||||
...getBrowserPureP2PPkcOptions(),
|
||||
pkcRpcClientsOptions: undefined,
|
||||
});
|
||||
|
||||
export const getBrowserGatewayAccountOptions = (account?: unknown) => ({
|
||||
...toAccountShape(account)?.pkcOptions,
|
||||
...getBrowserGatewayPkcOptions(),
|
||||
pkcRpcClientsOptions: undefined,
|
||||
});
|
||||
@@ -592,18 +592,13 @@ const FAQ_SECTIONS: FAQSection[] = [
|
||||
question: 'Can browsers connect peer-to-peer?',
|
||||
answer: (
|
||||
<>
|
||||
Yes, experimentally. The standard web app can use HTTP gateways, routers, and pubsub providers as compatibility helpers. The{' '}
|
||||
<a href='https://p2p.5chan.app' {...externalLinkProps}>
|
||||
p2p.5chan.app
|
||||
</a>{' '}
|
||||
subdomain instead runs a{' '}
|
||||
Yes. The web app can run a{' '}
|
||||
<a href='https://helia.io' {...externalLinkProps}>
|
||||
Helia
|
||||
</a>{' '}
|
||||
IPFS node in the browser, so it can load boards peer-to-peer without centralized IPFS RPC gateways. Browser nodes still have restrictions, such as limited
|
||||
inbound connectivity, so they can load data peer-to-peer but are not a stable way to host board data yet. Helia pubsub is currently unstable for 5chan's
|
||||
needs, which is why the pure browser P2P site is experimental. The desktop apps are the stable pure-P2P option today because they run a full Bitsocial node
|
||||
with IPFS Kubo.
|
||||
IPFS node in the browser, so it can load boards peer-to-peer without centralized IPFS RPC gateways. You can turn pure browser P2P on in advanced settings and
|
||||
inspect the connection in <Link to='/all/settings#p2p-stats-settings'>P2P stats</Link>. Browser nodes still have restrictions, such as limited inbound
|
||||
connectivity, so desktop apps remain the best way to host board data because they run a full Bitsocial node with IPFS Kubo.
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user