From 58199ff0f2921608eb0a9c48630078a7ebb667dc Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:14:30 -0400 Subject: [PATCH 01/93] fix: create helpers for migrating from redux-bundler --- src/bundles/connected.js | 19 + src/bundles/identity.js | 34 -- src/bundles/index.js | 2 - src/bundles/ipfs-provider.js | 20 +- src/bundles/peer-locations.js | 14 +- src/contexts/identity-context.tsx | 211 ++++++++ src/helpers/REDUX-BUNDLER-MIGRATION-GUIDE.md | 477 +++++++++++++++++++ src/helpers/connected-context-provider.tsx | 113 +++++ src/helpers/context-bridge.tsx | 155 ++++++ src/index.js | 21 +- src/status/NodeInfo.js | 37 +- src/status/NodeInfoAdvanced.js | 11 +- src/status/StatusPage.js | 42 +- tsconfig.json | 4 +- 14 files changed, 1062 insertions(+), 98 deletions(-) delete mode 100644 src/bundles/identity.js create mode 100644 src/contexts/identity-context.tsx create mode 100644 src/helpers/REDUX-BUNDLER-MIGRATION-GUIDE.md create mode 100644 src/helpers/connected-context-provider.tsx create mode 100644 src/helpers/context-bridge.tsx diff --git a/src/bundles/connected.js b/src/bundles/connected.js index a3c1acff5..a7f0b3e6a 100644 --- a/src/bundles/connected.js +++ b/src/bundles/connected.js @@ -1,4 +1,5 @@ import { createSelector } from 'redux-bundler' +import { contextBridge } from '../helpers/context-bridge' /** * @typedef {Object} Model @@ -80,6 +81,24 @@ const connected = { return state } }, + + /** + * Bridge ipfsConnected state to context bridge for use by React contexts + */ + reactConnectedToBridge: createSelector( + 'selectIpfsConnected', + (ipfsConnected) => { + contextBridge.setContext('selectIpfsConnected', ipfsConnected) + } + ), + + reactIsNodeInfoOpenToBridge: createSelector( + 'selectIsNodeInfoOpen', + (isNodeInfoOpen) => { + contextBridge.setContext('selectIsNodeInfoOpen', isNodeInfoOpen) + } + ), + ...actions, ...selectors } diff --git a/src/bundles/identity.js b/src/bundles/identity.js deleted file mode 100644 index 990f2f2b6..000000000 --- a/src/bundles/identity.js +++ /dev/null @@ -1,34 +0,0 @@ -import { createAsyncResourceBundle, createSelector } from 'redux-bundler' - -// Matches APP_IDLE and peer bandwidth update intervals -export const IDENTITY_REFRESH_INTERVAL_MS = 5000 - -const bundle = createAsyncResourceBundle({ - name: 'identity', - actionBaseType: 'IDENTITY', - getPromise: ({ getIpfs }) => getIpfs().id().catch((err) => { - console.error('Failed to get identity', err) - }), - staleAfter: IDENTITY_REFRESH_INTERVAL_MS, - persist: false, - checkIfOnline: false -}) - -bundle.selectIdentityLastSuccess = state => state.identity.lastSuccess - -// Update identity after we (re)connect with ipfs -bundle.reactIdentityFetch = createSelector( - 'selectIpfsConnected', - 'selectIdentityIsLoading', - 'selectIdentityLastSuccess', - 'selectConnectedLastError', - (connected, isLoading, idLastSuccess, connLastError) => { - if (connected && !isLoading) { - if (!idLastSuccess || connLastError > idLastSuccess) { - return { actionCreator: 'doFetchIdentity' } - } - } - } -) - -export default bundle diff --git a/src/bundles/index.js b/src/bundles/index.js index 1021b308b..5e6f8312d 100644 --- a/src/bundles/index.js +++ b/src/bundles/index.js @@ -15,7 +15,6 @@ import toursBundle from './tours.js' import notifyBundle from './notify.js' import connectedBundle from './connected.js' import retryInitBundle from './retry-init.js' -import identityBundle from './identity.js' import bundleCache from '../lib/bundle-cache.js' import ipfsDesktop from './ipfs-desktop.js' import repoStats from './repo-stats.js' @@ -31,7 +30,6 @@ export default composeBundles( }), appIdle({ idleTimeout: 5000 }), ipfsProvider, - identityBundle, routesBundle, redirectsBundle, toursBundle, diff --git a/src/bundles/ipfs-provider.js b/src/bundles/ipfs-provider.js index 5a0cf3b9f..65ea3422f 100644 --- a/src/bundles/ipfs-provider.js +++ b/src/bundles/ipfs-provider.js @@ -6,6 +6,8 @@ import last from 'it-last' import * as Enum from '../lib/enum.js' import { perform } from './task.js' import { readSetting, writeSetting } from './local-storage.js' +import { contextBridge } from '../helpers/context-bridge' +import { createSelector } from 'redux-bundler' /** * @typedef {import('ipfs').IPFSService} IPFSService @@ -305,7 +307,12 @@ const selectors = { /** * @param {State} state */ - selectIpfsPendingFirstConnection: state => state.ipfs.pendingFirstConnection + selectIpfsPendingFirstConnection: state => state.ipfs.pendingFirstConnection, + /** + * Returns the IPFS instance. This is the same instance that getIpfs() returns. + * Used by the identity context to access IPFS directly. + */ + selectIpfs: () => ipfs } /** @@ -524,6 +531,17 @@ const bundle = { getExtraArgs () { return extra }, + + /** + * Bridge ipfs instance to context bridge for use by React contexts + */ + reactIpfsToBridge: createSelector( + 'selectIpfs', + (ipfsInstance) => { + contextBridge.setContext('selectIpfs', ipfsInstance) + } + ), + ...selectors, ...actions } diff --git a/src/bundles/peer-locations.js b/src/bundles/peer-locations.js index 6d1252360..141f0c24f 100644 --- a/src/bundles/peer-locations.js +++ b/src/bundles/peer-locations.js @@ -7,10 +7,22 @@ import { multiaddr } from '@multiformats/multiaddr' import ms from 'milliseconds' import ip from 'ip' import memoize from 'p-memoize' +import { createContextSelector } from '../helpers/context-bridge' import pkgJson from '../../package.json' const { dependencies } = pkgJson +/** + * Selector that reads identity from the context bridge + * Returns the same format as the original selectIdentity for compatibility + */ +const selectIdentityFromContext = createContextSelector('identity') + +const selectIdentityData = () => { + const identityContext = selectIdentityFromContext() + return identityContext?.identity +} + // After this time interval, we re-check the locations for each peer // once again through PeerLocationResolver. const UPDATE_EVERY = ms.seconds(1) @@ -55,7 +67,7 @@ function createPeersLocations (opts) { 'selectPeers', 'selectPeerLocations', 'selectBootstrapPeers', - 'selectIdentity', // ipfs.id info for local node, used for detecting local peers + selectIdentityData, // ipfs.id info from identity context, used for detecting local peers (peers, locations = {}, bootstrapPeers, identity) => peers && Promise.all(peers.map(async (peer) => { const peerId = peer.peer const locationObj = locations ? locations[peerId] : null diff --git a/src/contexts/identity-context.tsx b/src/contexts/identity-context.tsx new file mode 100644 index 000000000..6d17310b9 --- /dev/null +++ b/src/contexts/identity-context.tsx @@ -0,0 +1,211 @@ +import React, { createContext, useContext, useReducer, useEffect, useCallback, ReactNode } from 'react' +import { useBridgeContext, useBridgeSelector } from '../helpers/context-bridge' + +/** + * Identity data structure + */ +export interface IdentityData { + /** The IPFS peer ID */ + id?: string + /** The public key */ + publicKey?: string + /** List of multiaddresses */ + addresses?: string[] + /** Agent version */ + agentVersion?: string + /** Protocol version */ + protocolVersion?: string +} + +/** + * Identity context value + */ +export interface IdentityContextValue { + /** The identity data, undefined if not loaded */ + identity?: IdentityData + /** Whether identity is currently being fetched */ + isLoading: boolean + /** Whether there was an error fetching identity */ + hasError: boolean + /** Last successful fetch timestamp */ + lastSuccess?: number + /** Function to manually refetch identity */ + refetch: () => void +} + +/** + * Identity state for the reducer + */ +interface IdentityState { + identity?: IdentityData + isLoading: boolean + hasError: boolean + lastSuccess?: number +} + +/** + * Actions for the identity reducer + */ +type IdentityAction = + | { type: 'FETCH_START' } + | { type: 'FETCH_SUCCESS'; payload: { identity: IdentityData; timestamp: number } } + | { type: 'FETCH_ERROR' } + +/** + * Identity reducer + */ +function identityReducer (state: IdentityState, action: IdentityAction): IdentityState { + switch (action.type) { + case 'FETCH_START': + return { + ...state, + isLoading: true, + hasError: false + } + case 'FETCH_SUCCESS': + return { + ...state, + identity: action.payload.identity, + isLoading: false, + hasError: false, + lastSuccess: action.payload.timestamp + } + case 'FETCH_ERROR': + return { + ...state, + isLoading: false, + hasError: true + } + default: + return state + } +} + +/** + * Initial state + */ +const initialState: IdentityState = { + identity: undefined, + isLoading: false, + hasError: false, + lastSuccess: undefined +} + +/** + * Identity context + */ +const IdentityContext = createContext(undefined) +IdentityContext.displayName = 'IdentityContext' + +/** + * Identity Provider Props + */ +interface IdentityProviderProps { + children: ReactNode + /** Whether to poll for identity updates every 5 seconds (default: false) */ + shouldPoll?: boolean +} + +/** + * Identity provider component using context bridge for redux selectors + */ +const IdentityProviderImpl: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(identityReducer, initialState) + const shouldPoll = useBridgeSelector('selectIsNodeInfoOpen') || false + + // Access redux selectors through context bridge (reactive) + const ipfsConnected = useBridgeSelector('selectIpfsConnected') || false + const ipfs = useBridgeSelector('selectIpfs') + + /** + * Fetch identity from IPFS + */ + const fetchIdentity = useCallback(async () => { + if (!ipfsConnected || !ipfs) { + return + } + + try { + dispatch({ type: 'FETCH_START' }) + const identity = await ipfs.id() + dispatch({ + type: 'FETCH_SUCCESS', + payload: { + identity, + timestamp: Date.now() + } + }) + } catch (error) { + console.error('Failed to fetch identity:', error) + dispatch({ type: 'FETCH_ERROR' }) + } + }, [ipfs, ipfsConnected]) + + /** + * Auto-fetch identity when IPFS becomes available or connected + * Only fetches once on mount/connection, not repeatedly + */ + useEffect(() => { + if (ipfsConnected && !state.isLoading) { + // Only fetch if we don't have identity or if connection was restored + if (!state.identity || !state.lastSuccess) { + fetchIdentity() + } + } + }, [ipfsConnected, fetchIdentity, state.isLoading, state.identity, state.lastSuccess]) + + /** + * Periodic refresh of identity - only when shouldPoll is true + * This should only be enabled when advanced node info is displayed + */ + useEffect(() => { + if (!shouldPoll || !ipfsConnected || !state.lastSuccess) { + return + } + + const REFRESH_INTERVAL = 5000 // Same as IDENTITY_REFRESH_INTERVAL_MS + const timeSinceLastSuccess = Date.now() - state.lastSuccess + + if (timeSinceLastSuccess < REFRESH_INTERVAL) { + // Set a timeout for the remaining time + const timeout = setTimeout(fetchIdentity, REFRESH_INTERVAL - timeSinceLastSuccess) + return () => clearTimeout(timeout) + } else { + // Time to refresh now + fetchIdentity() + } + }, [shouldPoll, ipfsConnected, state.lastSuccess, fetchIdentity]) + + const contextValue: IdentityContextValue = { + identity: state.identity, + isLoading: state.isLoading, + hasError: state.hasError, + lastSuccess: state.lastSuccess, + refetch: fetchIdentity + } + + // Bridge the context value to redux bundles + useBridgeContext('identity', contextValue) + + return ( + + {children} + + ) +} + +/** + * Hook to consume the identity context + */ +export function useIdentity (): IdentityContextValue { + const context = useContext(IdentityContext) + if (context === undefined) { + throw new Error('useIdentity must be used within an IdentityProvider') + } + return context +} + +/** + * Identity provider component + */ +export const IdentityProvider = IdentityProviderImpl diff --git a/src/helpers/REDUX-BUNDLER-MIGRATION-GUIDE.md b/src/helpers/REDUX-BUNDLER-MIGRATION-GUIDE.md new file mode 100644 index 000000000..73586b404 --- /dev/null +++ b/src/helpers/REDUX-BUNDLER-MIGRATION-GUIDE.md @@ -0,0 +1,477 @@ +# Migration Guide: Redux-Bundler to React Context + +This guide shows how to completely replace redux-bundler bundles with React context while maintaining compatibility with existing dependent bundles. + +## Overview + +The migration approach has two main strategies: + +1. **React Components**: Migrate directly to use context hooks +2. **Redux Bundles**: Use the context bridge to access context values + +## Step 1: Set Up the Context Bridge + +First, add the context bridge to your app root and ensure redux bundles bridge their values: + +```tsx +// src/index.js +import { ContextBridgeProvider } from './helpers/context-bridge.jsx' + +function App() { + return ( + + + {/* Your app - contexts added per page as needed */} + + + ) +} +``` + +**Update redux bundles to bridge their values:** + +```js +// src/bundles/ipfs-provider.js +import { contextBridge } from '../helpers/context-bridge.jsx' +import { createSelector } from 'redux-bundler' + +const bundle = { + // ... existing bundle code + + // Bridge ipfs instance to context bridge + reactIpfsToBridge: createSelector( + 'selectIpfs', + (ipfsInstance) => { + contextBridge.setContext('selectIpfs', ipfsInstance) + } + ), + + // Bridge connection status to context bridge + reactIpfsConnectedToBridge: createSelector( + 'selectIpfsConnected', + (ipfsConnected) => { + contextBridge.setContext('selectIpfsConnected', ipfsConnected) + } + ) +} +``` + +## Step 2: Create Self-Contained Context + +### Before (Redux Bundle) +```js +// src/bundles/identity.js +const bundle = createAsyncResourceBundle({ + name: 'identity', + getPromise: ({ getIpfs }) => getIpfs().id(), + // ... +}) +``` + +### After (React Context) +```tsx +// src/contexts/identity-context.tsx - Current implementation +const IdentityProviderImpl: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(identityReducer, initialState) + + // βœ… Get values from redux bundles via bridge (redux bundles are still source of truth) + const ipfsConnected = useBridgeSelector('selectIpfsConnected') || false + const ipfs = useBridgeSelector('selectIpfs') + const shouldPoll = useBridgeSelector('selectIsNodeInfoOpen') || false + + // βœ… React context is now source of truth for identity data + const fetchIdentity = useCallback(async () => { + if (!ipfsConnected || !ipfs) return + const identity = await ipfs.id() + dispatch({ type: 'FETCH_SUCCESS', payload: { identity, timestamp: Date.now() } }) + }, [ipfs, ipfsConnected]) + + // Auto-fetch and polling logic + useEffect(() => { /* fetch logic */ }, [ipfsConnected, fetchIdentity]) + useEffect(() => { /* polling logic */ }, [shouldPoll, ipfsConnected, fetchIdentity]) + + const contextValue = { identity: state.identity, isLoading: state.isLoading, hasError: state.hasError, refetch: fetchIdentity } + + // βœ… Expose to redux bundles that still need identity data + useBridgeContext('identity', contextValue) + + return ( + + {children} + + ) +} +``` + +**πŸ“ Current State**: Identity context is the source of truth for identity data, but consumes IPFS/connection data from redux bundles that haven't been migrated yet. + +## Step 3: Update Dependent Bundles + +### Before (Using Redux Selector) +```js +// src/bundles/peer-locations.js +bundle.selectPeerLocationsForSwarm = createSelector( + 'selectPeers', + 'selectPeerLocations', + 'selectBootstrapPeers', + 'selectIdentity', // ❌ Redux selector + (peers, locations, bootstrapPeers, identity) => { + // Use identity.addresses... + } +) +``` + +### After (Using Context Bridge) +```js +// src/bundles/peer-locations.js +import { createContextSelector } from '../helpers/context-bridge.jsx' + +const selectIdentityFromContext = createContextSelector('identity') + +const selectIdentityData = () => { + const identityContext = selectIdentityFromContext() + return identityContext?.identity +} + +bundle.selectPeerLocationsForSwarm = createSelector( + 'selectPeers', + 'selectPeerLocations', + 'selectBootstrapPeers', + selectIdentityData, // βœ… Context bridge + (peers, locations, bootstrapPeers, identity) => { + // Same usage as before + } +) +``` + +## Step 4: Update React Components + +### Before (Redux Connect) +```js +// src/status/NodeInfo.js +const NodeInfo = ({ identity, t }) => { + return
{identity?.id}
+} + +export default connect('selectIdentity', NodeInfo) +``` + +### After (React Hooks) +```tsx +// src/status/NodeInfo.tsx +const NodeInfo = ({ t }) => { + const { identity, isLoading, hasError, refetch } = useIdentity() + + if (isLoading) return
Loading...
+ if (hasError) return + + return
{identity?.id}
+} + +export default NodeInfo // No connect needed! +``` + +## Step 5: Remove Original Bundle + +Once all dependencies are migrated: + +1. Remove the original bundle from `src/bundles/index.js` +2. Remove the bundle file entirely +3. Remove any remaining imports + +```js +// src/bundles/index.js +export default composeBundles( + // ...other bundles + // identityBundle, ❌ Remove this line + // ...other bundles +) +``` + +## Step 6: Final Migration to Pure React Contexts + +Once all `connect()` usage is removed and everything uses `useBridgeSelector`, you can start migrating to pure React contexts that use hooks directly instead of the bridge. + +### Phase 1: Mixed Context Dependencies + +Some contexts may depend on others, and while bundles still exist, we still need to use the bridge to expose those context values to bundles. Use direct hooks between contexts, and use the bridge to expose those context values to bundles: + +```tsx +// src/contexts/identity-context.tsx - Mixed: some dependencies migrated, some not +const IdentityProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [state, dispatch] = useReducer(identityReducer, initialState) + + // βœ… These contexts are migrated - use direct hooks + const { ipfs, isConnected } = useIpfs() // Direct hook (IPFS context is source of truth) + const { isNodeInfoOpen } = useConnected() // Direct hook (Connected context is source of truth) + + // ❌ Don't use bridge to consume - use direct hooks instead + // const ipfs = useBridgeSelector('selectIpfs') // Old way + + const fetchIdentity = useCallback(async () => { + if (!isConnected || !ipfs) return + const identity = await ipfs.id() + dispatch({ type: 'FETCH_SUCCESS', payload: { identity, timestamp: Date.now() } }) + }, [ipfs, isConnected]) + + // Auto-fetch and polling logic + useEffect(() => { /* fetch logic */ }, [isConnected, fetchIdentity]) + useEffect(() => { /* polling logic */ }, [isNodeInfoOpen, isConnected, fetchIdentity]) + + const contextValue = { identity: state.identity, isLoading: state.isLoading, hasError: state.hasError, refetch: fetchIdentity } + + // βœ… Still expose to redux bundles that haven't migrated yet + useBridgeContext('identity', contextValue) + + return {children} +} +``` + +### Phase 2: Context-to-Context Communication + +Direct context dependencies instead of bridge: + +```tsx +// src/contexts/peer-locations-context.tsx +const PeerLocationsProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [locations, setLocations] = useState({}) + + // βœ… All dependencies are React contexts - use direct hooks + const { peers } = usePeers() // Direct hook (Peers context is source of truth) + const { identity } = useIdentity() // Direct hook (Identity context is source of truth) + + // ❌ Don't use bridge selectors when React contexts are source of truth + // const identity = createContextSelector('identity')() // Old way + + const updateLocations = useCallback(async () => { + // Use identity.addresses directly from React context + const newLocations = await fetchLocationsForPeers(peers, identity?.addresses) + setLocations(newLocations) + }, [peers, identity?.addresses]) + + const contextValue = { locations, updateLocations } + + // βœ… Still expose to any remaining redux bundles that need peer location data + useBridgeContext('peerLocations', contextValue) + + return {children} +} +``` + +### Phase 3: Remove Bridge Entirely + +Once all bundles are migrated to contexts, remove bridge usage: + +```tsx +// src/contexts/identity-context.tsx - Pure React, no bridge +const IdentityProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [state, dispatch] = useReducer(identityReducer, initialState) + + // Pure React context dependencies + const { ipfs, isConnected } = useIpfs() + const { isNodeInfoOpen } = useConnected() + + // Same logic as before... + + const contextValue = { identity: state.identity, isLoading: state.isLoading, hasError: state.hasError, refetch: fetchIdentity } + + // No more bridge needed! πŸŽ‰ + return {children} +} +``` + +### Component Usage - Same Throughout + +React components use the same hooks regardless of implementation: + +```tsx +// Always the same - bridge or no bridge +const NodeInfo = () => { + const { identity, isLoading, hasError, refetch } = useIdentity() + const { locations } = usePeerLocations() + + // Component logic stays identical +} +``` + +## Migration Checklist + +### Phase 1: Bridge-Based Migration (Per Bundle) + +- [ ] **Step 1**: Ensure redux bundles bridge their values with `contextBridge.setContext` +- [ ] **Step 2**: Create self-contained context using `useBridgeSelector` for redux dependencies +- [ ] **Step 3**: Add bridge registration with `useBridgeContext()` to expose context to bundles +- [ ] **Step 4**: Identify all dependent bundles and update them to use `createContextSelector()` +- [ ] **Step 5**: Update React components to use context hooks instead of `connect()` +- [ ] **Step 6**: Test that all functionality works (initial load, updates, polling) +- [ ] **Step 7**: Remove original bundle from bundle composition +- [ ] **Step 8**: Delete original bundle file + +### Phase 2: Pure React Context Migration + +Once all bundles are migrated and no `connect()` usage remains: + +- [ ] **Step 9**: Migrate contexts to use direct hooks instead of `useBridgeSelector` +- [ ] **Step 10**: Implement context-to-context communication with direct hooks +- [ ] **Step 11**: Keep `useBridgeContext()` for any remaining redux bundles +- [ ] **Step 12**: Test all inter-context dependencies work correctly + +### Phase 3: Remove Bridge System (Final) + +When all redux bundles are migrated to contexts: + +- [ ] **Step 13**: Remove all `useBridgeContext()` calls from contexts +- [ ] **Step 14**: Remove `ContextBridgeProvider` from app root +- [ ] **Step 15**: Delete bridge system files (`context-bridge.tsx`, etc.) +- [ ] **Step 16**: Clean up any remaining bridge imports +- [ ] **Step 17**: Final testing - pure React context architecture! πŸŽ‰ + +### Key Patterns: + +**React Context Pattern:** +```tsx +// βœ… Consume redux-bundler values (redux is source of truth) +const ipfsConnected = useBridgeSelector('selectIpfsConnected') +const someReduxValue = useBridgeSelector('selectSomeValue') + +// βœ… Expose context values to redux bundles (React is source of truth) +useBridgeContext('myContext', contextValue) + +// βœ… Use direct hooks when both are React contexts +const { otherData } = useOtherContext() +``` + +**Redux Bundle Pattern:** +```js +// βœ… Expose redux values to React contexts (redux is source of truth) +reactSomethingToBridge: createSelector( + 'selectSomething', + (value) => contextBridge.setContext('selectSomething', value) +) + +// βœ… Consume React context values (React is source of truth) +const selectContextData = createContextSelector('myContext') +``` + +**🎯 Bridge Direction Rules:** +- **Redux β†’ React**: Use `contextBridge.setContext()` in redux reactors +- **React β†’ Redux**: Use `useBridgeContext()` in React providers +- **React β†’ React**: Use direct hooks (no bridge needed) + +### Bundle Dependencies Map + +Use this to track what needs updating: + +``` +identity bundle +β”œβ”€β”€ bundles/ +β”‚ β”œβ”€β”€ peer-locations.js (uses selectIdentity) +β”‚ └── other-bundle.js +β”œβ”€β”€ components/ +β”‚ β”œβ”€β”€ status/NodeInfo.js (uses selectIdentity) +β”‚ β”œβ”€β”€ status/NodeInfoAdvanced.js (uses selectIdentity) +β”‚ └── status/StatusPage.js (uses doFetchIdentity) +``` + +## Key Hooks and Bridge Functions + +### `useBridgeSelector(contextName: string): T | undefined` +**Use in React contexts** to reactively access redux-bundler values **when redux-bundler is the source of truth**: + +```tsx +const IdentityProvider = ({ children }) => { + // βœ… Redux-bundler bundles are still the source of truth for these values + const ipfsConnected = useBridgeSelector('selectIpfsConnected') || false + const ipfs = useBridgeSelector('selectIpfs') + const shouldPoll = useBridgeSelector('selectIsNodeInfoOpen') || false + + // Use these redux values in your React context logic... +} +``` + +**⚠️ Only use when:** Redux-bundler bundle is the source of truth and hasn't been migrated yet. + +### `useBridgeContext(contextName: string, value: T): void` +**Use in React contexts** to expose context values to redux bundles **when React context is the source of truth**: + +```tsx +const IdentityProvider = ({ children }) => { + // βœ… React context is the source of truth for identity data + const contextValue = { identity, isLoading, hasError, refetch } + + // Expose to redux bundles that haven't been migrated yet + useBridgeContext('identity', contextValue) + + return {children} +} +``` + +**⚠️ Only use when:** React context is the source of truth and redux bundles still need access to the context value. + +### `createContextSelector(contextName: string)` +**Use in redux bundles** to access context values: + +```js +// src/bundles/peer-locations.js +import { createContextSelector } from '../helpers/context-bridge.jsx' + +const selectIdentityFromContext = createContextSelector('identity') + +const selectIdentityData = () => { + const identityContext = selectIdentityFromContext() + return identityContext?.identity // Access specific properties +} +``` + +### `contextBridge.setContext(contextName: string, value: T)` +**Use in redux bundle reactors** to expose redux values to React contexts **when redux-bundler is the source of truth**: + +```js +// src/bundles/ipfs-provider.js +import { contextBridge } from '../helpers/context-bridge.jsx' + +const bundle = { + // βœ… Redux bundle is the source of truth for IPFS instance + reactIpfsToBridge: createSelector( + 'selectIpfs', + (ipfsInstance) => { + contextBridge.setContext('selectIpfs', ipfsInstance) + } + ) +} +``` + +**⚠️ Only use when:** Redux bundle is the source of truth and React contexts need access. + +## Benefits After Migration + +βœ… **Modern React patterns** - hooks instead of redux-bundler HOCs +βœ… **Better TypeScript support** - full type safety +βœ… **Easier testing** - mock context instead of redux store +βœ… **Simpler state management** - no redux boilerplate +βœ… **Performance optimizations** - targeted re-renders +βœ… **Reduced bundle size** - remove redux-bundler dependencies + +## Migration Pattern for Other Bundles + +This same pattern works for any redux-bundler bundle: + +1. **Files Bundle** β†’ `FilesProvider` + `useFiles()` +2. **Config Bundle** β†’ `ConfigProvider` + `useConfig()` +3. **Peers Bundle** β†’ `PeersProvider` + `usePeers()` + +Each context can still access IPFS and other redux state during the transition, making migration safe and gradual. + +## Troubleshooting + +**Context value is undefined in bundles** +- Check that the context provider is above the redux Provider +- Verify `useBridgeContext()` is called in the provider + +**Bundle selector not updating** +- Redux selectors may need to depend on additional state to trigger updates +- Consider using bundle reactors for context subscriptions + +**Performance issues** +- Use `useMemo()` for expensive context value calculations +- Split large contexts into smaller, focused ones diff --git a/src/helpers/connected-context-provider.tsx b/src/helpers/connected-context-provider.tsx new file mode 100644 index 000000000..6da32956f --- /dev/null +++ b/src/helpers/connected-context-provider.tsx @@ -0,0 +1,113 @@ +/** + * @see {@link ./REDUX-BUNDLER-MIGRATION-GUIDE.md} for more information + */ +import React, { createContext, useContext, useMemo, ReactNode } from 'react' +// @ts-expect-error - redux-bundler-react is not typed +import { connect } from 'redux-bundler-react' + +/** + * Configuration for creating a connected context provider + */ +export interface ConnectedContextConfig { + /** The name of the context (for debugging) */ + name: string + /** + * Function that takes redux selectors and returns the context value + * @param selectors - Object containing all the connected selectors/actions + * @returns The value to provide in the context + */ + selector: (selectors: any) => T + /** + * Array of selector/action names to connect to redux-bundler + * e.g., ['selectIdentity', 'selectIdentityIsLoading', 'doFetchIdentity'] + */ + reduxSelectors: string[] + /** Optional default value for the context */ + defaultValue?: T +} + +/** + * Result of creating a connected context + */ +export interface ConnectedContextResult { + /** The React context */ + Context: React.Context + /** The connected provider component */ + Provider: React.ComponentType<{ children: ReactNode }> + /** Hook to consume the context */ + useContext: () => T +} + +/** + * Creates a React context with a provider that is connected to redux-bundler selectors. + * This enables gradual migration from redux-bundler to React context while maintaining + * access to other redux state. + * + * See {@link ./REDUX-BUNDLER-MIGRATION-GUIDE.md} for more information + * + * @param config - Configuration for the connected context + * @returns Object containing the Context, Provider, and useContext hook + * + * @example + * ```tsx + * // Create a connected identity context + * const { Provider: IdentityProvider, useContext: useIdentity } = createConnectedContextProvider({ + * name: 'Identity', + * selector: ({ identity, identityIsLoading, doFetchIdentity }) => ({ + * identity, + * isLoading: identityIsLoading, + * refetch: doFetchIdentity + * }), + * reduxSelectors: ['selectIdentity', 'selectIdentityIsLoading', 'doFetchIdentity'] + * }) + * + * // Use in components + * function MyComponent() { + * const { identity, isLoading, refetch } = useIdentity() + * // ... + * } + * ``` + */ +export function createConnectedContextProvider ( + config: ConnectedContextConfig +): ConnectedContextResult { + const { name, selector, reduxSelectors, defaultValue } = config + + // Create the React context + const Context = createContext(defaultValue) + Context.displayName = `${name}Context` + + // Create the provider component that connects to redux + const ConnectedProvider = connect( + ...reduxSelectors, + (props: any) => { + const { children, ...reduxProps } = props + + // Use the selector function to transform redux state into context value + const contextValue = useMemo(() => { + return selector(reduxProps) + }, [reduxProps]) + + return ( + + {children} + + ) + } + ) + + // Create the hook to consume the context + const useContextHook = (): T => { + const context = useContext(Context) + if (context === undefined) { + throw new Error(`use${name} must be used within a ${name}Provider`) + } + return context + } + + return { + Context, + Provider: ConnectedProvider as unknown as React.FC<{ children: ReactNode }>, + useContext: useContextHook + } +} diff --git a/src/helpers/context-bridge.tsx b/src/helpers/context-bridge.tsx new file mode 100644 index 000000000..9d6b9334e --- /dev/null +++ b/src/helpers/context-bridge.tsx @@ -0,0 +1,155 @@ +import React, { useContext, useEffect, createContext, ReactNode } from 'react' + +/** + * Global store for context values that redux bundles can access + * + * See {@link ./REDUX-BUNDLER-MIGRATION-GUIDE.md} for more information + */ +class ContextBridge { + private contexts: Map = new Map() + private subscribers: Map void>> = new Map() + + /** + * Set a context value and notify subscribers + */ + setContext (name: string, value: T): void { + this.contexts.set(name, value) + const subs = this.subscribers.get(name) + if (subs) { + subs.forEach(callback => callback(value)) + } + } + + /** + * Get the current value of a context + */ + getContext (name: string): T | undefined { + return this.contexts.get(name) + } + + /** + * Subscribe to changes in a context value + */ + subscribe (name: string, callback: (value: T) => void): () => void { + if (!this.subscribers.has(name)) { + this.subscribers.set(name, new Set()) + } + this.subscribers.get(name)!.add(callback) + + // Return unsubscribe function + return () => { + const subs = this.subscribers.get(name) + if (subs) { + subs.delete(callback) + } + } + } + + /** + * Check if a context is available + */ + hasContext (name: string): boolean { + return this.contexts.has(name) + } +} + +/** + * Global instance of the context bridge + */ +export const contextBridge = new ContextBridge() + +/** + * Props for the ContextBridgeProvider + */ +interface ContextBridgeProviderProps { + children: ReactNode +} + +/** + * Context for the bridge itself (to trigger re-renders when needed) + */ +const BridgeContext = createContext(contextBridge) + +/** + * Provider that makes the context bridge available + */ +export const ContextBridgeProvider: React.FC = ({ children }) => { + return ( + + {children} + + ) +} + +/** + * Hook that registers a context value with the bridge + */ +export function useBridgeContext (name: string, contextValue: T): void { + const bridge = useContext(BridgeContext) + + useEffect(() => { + bridge.setContext(name, contextValue) + }, [bridge, name, contextValue]) +} + +/** + * Higher-order component that automatically bridges a context to redux bundles + */ +export function withContextBridge ( + contextName: string, + ContextToUse: React.Context +) { + return function BridgeWrapper ({ children }: { children: ReactNode }) { + const contextValue = useContext(ContextToUse) + const bridge = useContext(BridgeContext) + + useEffect(() => { + if (contextValue !== undefined) { + bridge.setContext(contextName, contextValue) + } + }, [bridge, contextValue]) + + return <>{children} + } +} + +/** + * Create a selector that reads from a context bridge (non-reactive) + */ +export function createContextSelector (contextName: string) { + return () => contextBridge.getContext(contextName) +} + +/** + * Hook that reactively subscribes to a context bridge value + */ +export function useBridgeSelector (contextName: string): T | undefined { + const [value, setValue] = React.useState(() => + contextBridge.getContext(contextName) + ) + + React.useEffect(() => { + // Set initial value + const currentValue = contextBridge.getContext(contextName) + setValue(currentValue) + + // Subscribe to changes + const unsubscribe = contextBridge.subscribe(contextName, (newValue) => { + setValue(newValue) + }) + + return unsubscribe + }, [contextName]) + + return value +} + +/** + * Create a subscription to context changes for use in bundle reactors + */ +export function createContextSubscription ( + contextName: string, + callback: (value: T) => void +): () => void { + return contextBridge.subscribe(contextName, callback) +} diff --git a/src/index.js b/src/index.js index 72b53a1ee..5ac1181b2 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ import i18n from './i18n.js' import { DndProvider } from 'react-dnd' import DndBackend from './lib/dnd-backend.js' import { HeliaProvider, ExploreProvider } from 'ipld-explorer-components/providers' +import { ContextBridgeProvider } from './helpers/context-bridge.jsx' const appVersion = process.env.REACT_APP_VERSION const gitRevision = process.env.REACT_APP_GIT_REV @@ -33,15 +34,17 @@ async function render () { const store = getStore(initialData) ReactDOM.render( - - - - - - - - - + + + + + + + + + + + , document.getElementById('root') ) diff --git a/src/status/NodeInfo.js b/src/status/NodeInfo.js index 64f2ec275..934d23e88 100644 --- a/src/status/NodeInfo.js +++ b/src/status/NodeInfo.js @@ -1,11 +1,14 @@ import React from 'react' -import { withTranslation } from 'react-i18next' -import { connect } from 'redux-bundler-react' +import { useTranslation } from 'react-i18next' +import { useIdentity } from '../contexts/identity-context.jsx' import VersionLink from '../components/version-link/VersionLink.js' import { Definition, DefinitionList } from '../components/definition/Definition.js' -class NodeInfo extends React.Component { - getField (obj, field, fn) { +const NodeInfo = () => { + const { identity, isLoading } = useIdentity() + const { t } = useTranslation('app') + + const getField = (obj, field, fn) => { if (obj && obj[field]) { if (fn) { return fn(obj[field]) @@ -17,25 +20,23 @@ class NodeInfo extends React.Component { return '' } - getVersion (identity) { - const raw = this.getField(identity, 'agentVersion') - return raw ? raw.split('/').join(' ') : '' - } - - render () { - const { t, identity } = this.props - + if (isLoading) { return ( - - } /> + + ) } + + return ( + + + } /> + + + ) } -export default connect( - 'selectIdentity', - withTranslation('app')(NodeInfo) -) +export default NodeInfo diff --git a/src/status/NodeInfoAdvanced.js b/src/status/NodeInfoAdvanced.js index 2b3e9a081..370094bf8 100644 --- a/src/status/NodeInfoAdvanced.js +++ b/src/status/NodeInfoAdvanced.js @@ -2,6 +2,7 @@ import React from 'react' import { multiaddr } from '@multiformats/multiaddr' import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' +import { useIdentity } from '../contexts/identity-context.jsx' import Address from '../components/address/Address.js' import Details from '../components/details/Details.js' import ProviderLink from '../components/provider-link/ProviderLink.js' @@ -27,7 +28,9 @@ const getField = (obj, field, fn) => { return '' } -const NodeInfoAdvanced = ({ t, identity, ipfsProvider, ipfsApiAddress, gatewayUrl, isNodeInfoOpen, doSetIsNodeInfoOpen }) => { +const NodeInfoAdvanced = ({ t, ipfsProvider, ipfsApiAddress, gatewayUrl, isNodeInfoOpen, doSetIsNodeInfoOpen }) => { + const { identity, isLoading } = useIdentity() + let publicKey = null let addresses = null if (identity) { @@ -35,6 +38,11 @@ const NodeInfoAdvanced = ({ t, identity, ipfsProvider, ipfsApiAddress, gatewayUr addresses = [...new Set(identity.addresses)].sort().map(addr =>
) } + if (isLoading) { + publicKey = t('loading') + addresses = t('loading') + } + const handleSummaryClick = (ev) => { doSetIsNodeInfoOpen(!isNodeInfoOpen) ev.preventDefault() @@ -71,7 +79,6 @@ const NodeInfoAdvanced = ({ t, identity, ipfsProvider, ipfsApiAddress, gatewayUr } export default connect( - 'selectIdentity', 'selectIpfsProvider', 'selectIpfsApiAddress', 'selectGatewayUrl', diff --git a/src/status/StatusPage.js b/src/status/StatusPage.js index 2c973f74d..1794e5d07 100644 --- a/src/status/StatusPage.js +++ b/src/status/StatusPage.js @@ -1,8 +1,9 @@ -import React, { useEffect } from 'react' +import React from 'react' import { Helmet } from 'react-helmet' import { withTranslation, Trans } from 'react-i18next' import { connect } from 'redux-bundler-react' import ReactJoyride from 'react-joyride' +import { IdentityProvider } from '../contexts/identity-context.jsx' import StatusConnected from './StatusConnected.js' import BandwidthStatsDisabled from './BandwidthStatsDisabled.js' import IsNotConnected from '../components/is-not-connected/IsNotConnected.js' @@ -15,9 +16,8 @@ import AnalyticsBanner from '../components/analytics-banner/AnalyticsBanner.js' import { statusTour } from '../lib/tours.js' import { getJoyrideLocales } from '../helpers/i8n.js' import withTour from '../components/tour/withTour.js' -import { IDENTITY_REFRESH_INTERVAL_MS } from '../bundles/identity.js' -const StatusPage = ({ +const StatusPageContent = ({ t, ipfsConnected, showAnalyticsComponents, @@ -27,27 +27,9 @@ const StatusPage = ({ doToggleShowAnalyticsBanner, toursEnabled, handleJoyrideCallback, - nodeBandwidthEnabled, - doFetchIdentity, - isNodeInfoOpen + nodeBandwidthEnabled }) => { - // Refresh identity when page mounts - useEffect(() => { - if (ipfsConnected) { - doFetchIdentity() - } - }, [ipfsConnected, doFetchIdentity]) - - // Refresh identity when Advanced section is open - useEffect(() => { - if (ipfsConnected && isNodeInfoOpen) { - const intervalId = setInterval(() => { - doFetchIdentity() - }, IDENTITY_REFRESH_INTERVAL_MS) - - return () => clearInterval(intervalId) - } - }, [ipfsConnected, isNodeInfoOpen, doFetchIdentity]) + // Identity refreshing is now handled automatically by IdentityProvider return (
@@ -60,10 +42,12 @@ const StatusPage = ({ ? (
- -
- -
+ + +
+ +
+
) : ( @@ -115,10 +99,8 @@ export default connect( 'selectShowAnalyticsBanner', 'selectShowAnalyticsComponents', 'selectToursEnabled', - 'selectIsNodeInfoOpen', 'doEnableAnalytics', 'doDisableAnalytics', 'doToggleShowAnalyticsBanner', - 'doFetchIdentity', - withTour(withTranslation('status')(StatusPage)) + withTour(withTranslation('status')(StatusPageContent)) ) diff --git a/tsconfig.json b/tsconfig.json index ac9429f20..116166797 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -101,6 +101,8 @@ "src/icons/GlyphPinCloud.js", "src/icons/GlyphPin.js", "src/icons/StrokeCube.js", - "src/files/type-from-ext/extToType.js" + "src/files/type-from-ext/extToType.js", + "src/helpers/context-bridge.tsx", + "src/helpers/connected-context-provider.tsx" ] } From b17378d8c1611bc0a4af14afccd13c9f05961e0e Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:12:45 -0400 Subject: [PATCH 02/93] fix: status page UI flash when identity updates --- src/contexts/identity-context.tsx | 62 +++++++++++++------------------ src/status/NodeInfo.js | 28 ++------------ src/status/NodeInfoAdvanced.js | 40 +++++++------------- 3 files changed, 42 insertions(+), 88 deletions(-) diff --git a/src/contexts/identity-context.tsx b/src/contexts/identity-context.tsx index 6d17310b9..9b93fffd6 100644 --- a/src/contexts/identity-context.tsx +++ b/src/contexts/identity-context.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useReducer, useEffect, useCallback, ReactNode } from 'react' +import React, { createContext, useContext, useReducer, useEffect, useCallback, useMemo, ReactNode, useRef } from 'react' import { useBridgeContext, useBridgeSelector } from '../helpers/context-bridge' /** @@ -23,7 +23,7 @@ export interface IdentityData { export interface IdentityContextValue { /** The identity data, undefined if not loaded */ identity?: IdentityData - /** Whether identity is currently being fetched */ + /** Whether identity is being fetched for the first time */ isLoading: boolean /** Whether there was an error fetching identity */ hasError: boolean @@ -31,6 +31,10 @@ export interface IdentityContextValue { lastSuccess?: number /** Function to manually refetch identity */ refetch: () => void + /** + * Whether identity is being updated (loading, but we already have a good identity response) + */ + isRefreshing: boolean } /** @@ -102,8 +106,6 @@ IdentityContext.displayName = 'IdentityContext' */ interface IdentityProviderProps { children: ReactNode - /** Whether to poll for identity updates every 5 seconds (default: false) */ - shouldPoll?: boolean } /** @@ -112,28 +114,27 @@ interface IdentityProviderProps { const IdentityProviderImpl: React.FC = ({ children }) => { const [state, dispatch] = useReducer(identityReducer, initialState) const shouldPoll = useBridgeSelector('selectIsNodeInfoOpen') || false - - // Access redux selectors through context bridge (reactive) const ipfsConnected = useBridgeSelector('selectIpfsConnected') || false const ipfs = useBridgeSelector('selectIpfs') - /** - * Fetch identity from IPFS - */ - const fetchIdentity = useCallback(async () => { - if (!ipfsConnected || !ipfs) { - return - } + // keep last good identity to prevent UI flash + const lastGoodIdentityRef = useRef(state.identity) + useEffect(() => { + if (state.identity) lastGoodIdentityRef.current = state.identity + }, [state.identity]) + const identityStable = state.identity ?? lastGoodIdentityRef.current + const isInitialLoading = identityStable == null && state.isLoading + const isRefreshing = identityStable != null && state.isLoading + + const fetchIdentity = useCallback(async () => { + if (!ipfsConnected || !ipfs) return try { dispatch({ type: 'FETCH_START' }) const identity = await ipfs.id() dispatch({ type: 'FETCH_SUCCESS', - payload: { - identity, - timestamp: Date.now() - } + payload: { identity, timestamp: Date.now() } }) } catch (error) { console.error('Failed to fetch identity:', error) @@ -141,50 +142,37 @@ const IdentityProviderImpl: React.FC = ({ children }) => } }, [ipfs, ipfsConnected]) - /** - * Auto-fetch identity when IPFS becomes available or connected - * Only fetches once on mount/connection, not repeatedly - */ useEffect(() => { if (ipfsConnected && !state.isLoading) { - // Only fetch if we don't have identity or if connection was restored if (!state.identity || !state.lastSuccess) { fetchIdentity() } } }, [ipfsConnected, fetchIdentity, state.isLoading, state.identity, state.lastSuccess]) - /** - * Periodic refresh of identity - only when shouldPoll is true - * This should only be enabled when advanced node info is displayed - */ useEffect(() => { - if (!shouldPoll || !ipfsConnected || !state.lastSuccess) { - return - } + if (!shouldPoll || !ipfsConnected || !state.lastSuccess) return - const REFRESH_INTERVAL = 5000 // Same as IDENTITY_REFRESH_INTERVAL_MS + const REFRESH_INTERVAL = 5000 const timeSinceLastSuccess = Date.now() - state.lastSuccess if (timeSinceLastSuccess < REFRESH_INTERVAL) { - // Set a timeout for the remaining time const timeout = setTimeout(fetchIdentity, REFRESH_INTERVAL - timeSinceLastSuccess) return () => clearTimeout(timeout) } else { - // Time to refresh now fetchIdentity() } }, [shouldPoll, ipfsConnected, state.lastSuccess, fetchIdentity]) - const contextValue: IdentityContextValue = { - identity: state.identity, - isLoading: state.isLoading, + const contextValue: IdentityContextValue = useMemo(() => ({ + identity: identityStable, + isLoading: isInitialLoading, + isRefreshing, hasError: state.hasError, lastSuccess: state.lastSuccess, refetch: fetchIdentity - } + }), [identityStable, isInitialLoading, isRefreshing, state.hasError, state.lastSuccess, fetchIdentity]) - // Bridge the context value to redux bundles useBridgeContext('identity', contextValue) return ( diff --git a/src/status/NodeInfo.js b/src/status/NodeInfo.js index 934d23e88..e5021258c 100644 --- a/src/status/NodeInfo.js +++ b/src/status/NodeInfo.js @@ -5,35 +5,13 @@ import VersionLink from '../components/version-link/VersionLink.js' import { Definition, DefinitionList } from '../components/definition/Definition.js' const NodeInfo = () => { - const { identity, isLoading } = useIdentity() + const { identity } = useIdentity() const { t } = useTranslation('app') - const getField = (obj, field, fn) => { - if (obj && obj[field]) { - if (fn) { - return fn(obj[field]) - } - - return obj[field] - } - - return '' - } - - if (isLoading) { - return ( - - - - - - ) - } - return ( - - } /> + + } /> ) diff --git a/src/status/NodeInfoAdvanced.js b/src/status/NodeInfoAdvanced.js index 370094bf8..b4d7ff6fa 100644 --- a/src/status/NodeInfoAdvanced.js +++ b/src/status/NodeInfoAdvanced.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo } from 'react' import { multiaddr } from '@multiformats/multiaddr' import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' @@ -16,32 +16,20 @@ function isMultiaddr (addr) { return false } } -const getField = (obj, field, fn) => { - if (obj && obj[field]) { - if (fn) { - return fn(obj[field]) - } - - return obj[field] - } - - return '' -} const NodeInfoAdvanced = ({ t, ipfsProvider, ipfsApiAddress, gatewayUrl, isNodeInfoOpen, doSetIsNodeInfoOpen }) => { const { identity, isLoading } = useIdentity() + const loadingString = t('loading') - let publicKey = null - let addresses = null - if (identity) { - publicKey = getField(identity, 'publicKey') - addresses = [...new Set(identity.addresses)].sort().map(addr =>
) - } + const addressComponent = useMemo(() => { + if (isLoading || identity?.addresses == null) return loadingString + return [...new Set(identity.addresses)].sort().map(addr =>
) + }, [identity?.addresses, isLoading, loadingString]) - if (isLoading) { - publicKey = t('loading') - addresses = t('loading') - } + const publicKeyComponent = useMemo(() => { + if (isLoading) return loadingString + return identity?.publicKey ?? null + }, [identity?.publicKey, isLoading, loadingString]) const handleSummaryClick = (ev) => { doSetIsNodeInfoOpen(!isNodeInfoOpen) @@ -61,18 +49,18 @@ const NodeInfoAdvanced = ({ t, ipfsProvider, ipfsApiAddress, gatewayUrl, isNodeI {ipfsProvider === 'httpClient' ? + (
{isMultiaddr(ipfsApiAddress) ? (
) : asAPIString(ipfsApiAddress) } - {t('app:actions.edit')} + {t('app:actions.edit')}
) } /> : } /> } - - + + ) From 6a7395282fbd0bd303cbeae67d27d4cc276cefc2 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Mon, 28 Jul 2025 15:35:37 -0400 Subject: [PATCH 03/93] Apply suggestion from @SgtPooki --- src/status/StatusPage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/status/StatusPage.js b/src/status/StatusPage.js index 1794e5d07..cd3749153 100644 --- a/src/status/StatusPage.js +++ b/src/status/StatusPage.js @@ -17,7 +17,7 @@ import { statusTour } from '../lib/tours.js' import { getJoyrideLocales } from '../helpers/i8n.js' import withTour from '../components/tour/withTour.js' -const StatusPageContent = ({ +const StatusPage = ({ t, ipfsConnected, showAnalyticsComponents, From 0276f634287d5dff783bae7593858416ffe66a2f Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Mon, 28 Jul 2025 15:35:55 -0400 Subject: [PATCH 04/93] Apply suggestion from @SgtPooki --- src/status/StatusPage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/status/StatusPage.js b/src/status/StatusPage.js index cd3749153..6535da291 100644 --- a/src/status/StatusPage.js +++ b/src/status/StatusPage.js @@ -102,5 +102,5 @@ export default connect( 'doEnableAnalytics', 'doDisableAnalytics', 'doToggleShowAnalyticsBanner', - withTour(withTranslation('status')(StatusPageContent)) + withTour(withTranslation('status')(StatusPage)) ) From 2effa3e84c33b26353ff507afef679b598e222e8 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Mon, 28 Jul 2025 15:36:06 -0400 Subject: [PATCH 05/93] Apply suggestion from @SgtPooki --- src/status/StatusPage.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/status/StatusPage.js b/src/status/StatusPage.js index 6535da291..214c5884e 100644 --- a/src/status/StatusPage.js +++ b/src/status/StatusPage.js @@ -29,7 +29,6 @@ const StatusPage = ({ handleJoyrideCallback, nodeBandwidthEnabled }) => { - // Identity refreshing is now handled automatically by IdentityProvider return (
From fd45ada7c918a66fea331f3ec5a2dabc927c3ab7 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Tue, 22 Jul 2025 22:25:29 -0400 Subject: [PATCH 06/93] feat: add diagnostics screen --- public/locales/en/diagnostics.json | 32 +++++++ src/bundles/routes.js | 2 + src/diagnostics/diagnostics-content.tsx | 87 +++++++++++++++++++ src/diagnostics/diagnostics-page.tsx | 38 ++++++++ src/diagnostics/loadable-diagnostics-page.tsx | 9 ++ src/navigation/NavBar.js | 2 + 6 files changed, 170 insertions(+) create mode 100644 public/locales/en/diagnostics.json create mode 100644 src/diagnostics/diagnostics-content.tsx create mode 100644 src/diagnostics/diagnostics-page.tsx create mode 100644 src/diagnostics/loadable-diagnostics-page.tsx diff --git a/public/locales/en/diagnostics.json b/public/locales/en/diagnostics.json new file mode 100644 index 000000000..8d7d2a3da --- /dev/null +++ b/public/locales/en/diagnostics.json @@ -0,0 +1,32 @@ +{ + "title": "Diagnostics", + "description": "View detailed diagnostic information about your IPFS node and system configuration.", + "nodeInfo": { + "title": "Node Information", + "peerId": "Peer ID", + "agent": "Agent", + "ui": "UI Version" + }, + "systemInfo": { + "title": "System Information", + "platform": "Platform", + "userAgent": "User Agent", + "language": "Language", + "online": "Online", + "offline": "Offline" + }, + "connection": { + "title": "Connection Status", + "status": "Status", + "connected": "Connected", + "disconnected": "Disconnected", + "addresses": "Addresses" + }, + "repo": { + "title": "Repository Information", + "path": "Repository Path", + "defaultPath": "Default IPFS repository path", + "version": "Repository Version", + "defaultVersion": "Latest" + } +} diff --git a/src/bundles/routes.js b/src/bundles/routes.js index 6dd314578..164a2eb91 100644 --- a/src/bundles/routes.js +++ b/src/bundles/routes.js @@ -8,6 +8,7 @@ import AnalyticsPage from '../settings/AnalyticsPage.js' import WelcomePage from '../welcome/LoadableWelcomePage.js' import BlankPage from '../blank/BlankPage.js' import ExplorePageRenderer from '../explore/explore-page-renderer.jsx' +import DiagnosticsPage from '../diagnostics/loadable-diagnostics-page.tsx' export default createRouteBundle({ '/explore': ExplorePageRenderer, @@ -21,6 +22,7 @@ export default createRouteBundle({ '/settings*': SettingsPage, '/welcome': WelcomePage, '/blank': BlankPage, + '/diagnostics': DiagnosticsPage, '/status*': StatusPage, '/': StatusPage, '': StatusPage diff --git a/src/diagnostics/diagnostics-content.tsx b/src/diagnostics/diagnostics-content.tsx new file mode 100644 index 000000000..fc1a1984f --- /dev/null +++ b/src/diagnostics/diagnostics-content.tsx @@ -0,0 +1,87 @@ +import React, { useEffect } from 'react' +import { withTranslation, WithTranslation } from 'react-i18next' +import { connect } from 'redux-bundler-react' +import Box from '../components/box/Box.js' +import { Definition, DefinitionList } from '../components/definition/Definition.js' +import VersionLink from '../components/version-link/VersionLink.js' + +interface Identity { + id: string + agentVersion: string + addresses?: string[] +} + +interface DiagnosticsContentProps extends WithTranslation { + identity: Identity | null + doFetchIdentity: () => void +} + +const DiagnosticsContent: React.FC = ({ t, identity, doFetchIdentity }) => { + useEffect(() => { + if (identity) { + doFetchIdentity() + } + }, [identity, doFetchIdentity]) + + const getField = (obj: any, field: string, fn?: (value: any) => string): string => { + if (obj && obj[field]) { + if (fn) { + return fn(obj[field]) + } + return obj[field] + } + return '' + } + + return ( +
+

{t('title')}

+

{t('description')}

+ + {/* Node Information */} + +

{t('nodeInfo.title')}

+ + + } /> + + +
+ + {/* System Information */} + +

{t('systemInfo.title')}

+ + + + + + +
+ + {/* Connection Status */} + +

{t('connection.title')}

+ + + addrs?.join(', ') || '')} /> + +
+ + {/* Repository Information */} + +

{t('repo.title')}

+ + + + +
+
+ ) +} + +export default connect( + 'selectIdentity', + 'doFetchIdentity', + withTranslation('diagnostics')(DiagnosticsContent) +) diff --git a/src/diagnostics/diagnostics-page.tsx b/src/diagnostics/diagnostics-page.tsx new file mode 100644 index 000000000..6e1b9af51 --- /dev/null +++ b/src/diagnostics/diagnostics-page.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { Helmet } from 'react-helmet' +import { useTranslation } from 'react-i18next' +import { connect } from 'redux-bundler-react' +import Box from '../components/box/Box.js' +import IsNotConnected from '../components/is-not-connected/IsNotConnected.js' +import DiagnosticsContent from './diagnostics-content.jsx' + +interface DiagnosticsPageProps { + ipfsConnected: boolean +} + +const DiagnosticsPage: React.FC = ({ ipfsConnected }) => { + const { t } = useTranslation('diagnostics') + return ( +
+ + {t('title')} | IPFS + + + +
+
+ { ipfsConnected + ? + : + } +
+
+
+
+ ) +} + +export default connect( + 'selectIpfsConnected', + DiagnosticsPage +) diff --git a/src/diagnostics/loadable-diagnostics-page.tsx b/src/diagnostics/loadable-diagnostics-page.tsx new file mode 100644 index 000000000..bda3b562e --- /dev/null +++ b/src/diagnostics/loadable-diagnostics-page.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import Loadable from '@loadable/component' +import ComponentLoader from '../loader/ComponentLoader.js' + +const LoadableDiagnosticsPage = Loadable(() => import('./diagnostics-page'), + { fallback: } +) + +export default LoadableDiagnosticsPage diff --git a/src/navigation/NavBar.js b/src/navigation/NavBar.js index 9925dd224..2dce13949 100644 --- a/src/navigation/NavBar.js +++ b/src/navigation/NavBar.js @@ -9,6 +9,7 @@ import StrokeWeb from '../icons/StrokeWeb.js' import StrokeCube from '../icons/StrokeCube.js' import StrokeSettings from '../icons/StrokeSettings.js' import StrokeIpld from '../icons/StrokeIpld.js' +import StrokeLab from '../icons/StrokeLab.js' // Styles import './NavBar.css' @@ -69,6 +70,7 @@ export const NavBar = ({ t }) => { {t('files:title')} {t('explore:tabName')} {t('peers:title')} + {t('diagnostics:title')} {t('settings:title')}
From 0221389452ac7ba596e90b4227f677a979083d90 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Tue, 22 Jul 2025 22:31:33 -0400 Subject: [PATCH 07/93] fix: create react-helmet wrapper for typescript --- src/components/helmet-wrapper.tsx | 13 +++++++++++++ src/diagnostics/diagnostics-page.tsx | 3 +-- 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 src/components/helmet-wrapper.tsx diff --git a/src/components/helmet-wrapper.tsx b/src/components/helmet-wrapper.tsx new file mode 100644 index 000000000..54eec044d --- /dev/null +++ b/src/components/helmet-wrapper.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { Helmet as ReactHelmet } from 'react-helmet' + +interface HelmetProps { + children?: React.ReactNode +} + +const Helmet: React.FC = ({ children }) => { + const HelmetComponent = ReactHelmet as unknown as React.FC + return {children} +} + +export default Helmet diff --git a/src/diagnostics/diagnostics-page.tsx b/src/diagnostics/diagnostics-page.tsx index 6e1b9af51..053543848 100644 --- a/src/diagnostics/diagnostics-page.tsx +++ b/src/diagnostics/diagnostics-page.tsx @@ -1,8 +1,8 @@ import React from 'react' -import { Helmet } from 'react-helmet' import { useTranslation } from 'react-i18next' import { connect } from 'redux-bundler-react' import Box from '../components/box/Box.js' +import Helmet from '../components/helmet-wrapper.jsx' import IsNotConnected from '../components/is-not-connected/IsNotConnected.js' import DiagnosticsContent from './diagnostics-content.jsx' @@ -17,7 +17,6 @@ const DiagnosticsPage: React.FC = ({ ipfsConnected }) => { {t('title')} | IPFS -
From 0ee16ac793a4e661c26dc2b4c6f5b954bf2ecd0b Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Tue, 22 Jul 2025 23:05:48 -0400 Subject: [PATCH 08/93] fix: fix typing issues for connected components --- .../api-address-form/ApiAddressForm.js | 18 +++++++++++---- src/components/connected-component.tsx | 23 +++++++++++++++++++ ...IsNotConnected.js => is-not-connected.tsx} | 15 ++++++++---- src/diagnostics/diagnostics-content.tsx | 19 +++++++++------ src/diagnostics/diagnostics-page.tsx | 19 ++++++++++----- src/status/StatusPage.js | 2 +- src/welcome/WelcomePage.js | 2 +- 7 files changed, 75 insertions(+), 23 deletions(-) create mode 100644 src/components/connected-component.tsx rename src/components/is-not-connected/{IsNotConnected.js => is-not-connected.tsx} (93%) diff --git a/src/components/api-address-form/ApiAddressForm.js b/src/components/api-address-form/ApiAddressForm.js index da30643ce..1ea5d8cc6 100644 --- a/src/components/api-address-form/ApiAddressForm.js +++ b/src/components/api-address-form/ApiAddressForm.js @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react' -import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' +import { createConnectedComponent } from '../connected-component.js' import Button from '../button/button.tsx' import { checkValidAPIAddress } from '../../bundles/ipfs-provider.js' @@ -69,9 +69,19 @@ const asAPIString = (value) => { return JSON.stringify(value) } -export default connect( +/** + * @typedef {Object} ReduxBundlerProps + * @property {(value: string) => void} doUpdateIpfsApiAddress + * @property {string} selectIpfsApiAddress + * @property {boolean} selectIpfsInitFailed + */ + +/** + * @template {ReduxBundlerProps} + */ +export default createConnectedComponent( + withTranslation('app')(ApiAddressForm), 'doUpdateIpfsApiAddress', 'selectIpfsApiAddress', - 'selectIpfsInitFailed', - withTranslation('app')(ApiAddressForm) + 'selectIpfsInitFailed' ) diff --git a/src/components/connected-component.tsx b/src/components/connected-component.tsx new file mode 100644 index 000000000..552dfd0af --- /dev/null +++ b/src/components/connected-component.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { connect } from 'redux-bundler-react' + +// Type for the connect HOC result - omits the props that redux-bundler will provide +type ConnectedComponent = React.ComponentType> + +// Helper function to create properly typed connected components +// Users must explicitly specify the props that will be provided by the connect HOC +export function createConnectedComponent ( + Component: React.ComponentType, + ...selectors: string[] +): ConnectedComponent, TReduxProps> { + return connect(...selectors, Component) as unknown as ConnectedComponent, TReduxProps> +} + +// Alternative approach using a wrapper component +export function withConnect ( + Component: React.ComponentType, + ...selectors: string[] +): ConnectedComponent, TReduxProps> { + const ConnectedComponent = connect(...selectors, Component) + return ConnectedComponent as unknown as ConnectedComponent, TReduxProps> +} diff --git a/src/components/is-not-connected/IsNotConnected.js b/src/components/is-not-connected/is-not-connected.tsx similarity index 93% rename from src/components/is-not-connected/IsNotConnected.js rename to src/components/is-not-connected/is-not-connected.tsx index 004ed68fe..526ac3f2a 100644 --- a/src/components/is-not-connected/IsNotConnected.js +++ b/src/components/is-not-connected/is-not-connected.tsx @@ -1,12 +1,17 @@ import React, { useState } from 'react' -import { connect } from 'redux-bundler-react' import { withTranslation, Trans } from 'react-i18next' +import { createConnectedComponent } from '../connected-component.js' import classNames from 'classnames' import ApiAddressForm from '../api-address-form/ApiAddressForm.js' import Box from '../box/Box.js' import Shell from '../shell/Shell.js' import GlyphAttention from '../../icons/GlyphAttention.js' +interface ReduxBundlerProps { + ipfsConnected: boolean + apiUrl: string +} + const TABS = { UNIX: 'unix', POWERSHELL: 'windowsPS', @@ -19,6 +24,7 @@ const IsNotConnected = ({ t, apiUrl, connected, sameOrigin, ipfsApiAddress, doUp const origin = window.location.origin const addOrigin = defaultDomains.indexOf(origin) === -1 return ( + // @ts-expect-error - expects required style prop
@@ -31,6 +37,7 @@ const IsNotConnected = ({ t, apiUrl, connected, sameOrigin, ipfsApiAddress, doUp
  • Is your IPFS daemon running? Try starting or restarting Kubo from your terminal:
  • + {/* @ts-expect-error - expects required className prop */} $ ipfs daemon Initializing daemon... @@ -88,8 +95,8 @@ const IsNotConnected = ({ t, apiUrl, connected, sameOrigin, ipfsApiAddress, doUp ) } -export default connect( +export default createConnectedComponent( + withTranslation('welcome')(IsNotConnected), 'selectIpfsConnected', - 'selectApiUrl', - withTranslation('welcome')(IsNotConnected) + 'selectApiUrl' ) diff --git a/src/diagnostics/diagnostics-content.tsx b/src/diagnostics/diagnostics-content.tsx index fc1a1984f..fc9821001 100644 --- a/src/diagnostics/diagnostics-content.tsx +++ b/src/diagnostics/diagnostics-content.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react' -import { withTranslation, WithTranslation } from 'react-i18next' -import { connect } from 'redux-bundler-react' +import { useTranslation } from 'react-i18next' import Box from '../components/box/Box.js' +import { createConnectedComponent } from '../components/connected-component.js' import { Definition, DefinitionList } from '../components/definition/Definition.js' import VersionLink from '../components/version-link/VersionLink.js' @@ -11,12 +11,17 @@ interface Identity { addresses?: string[] } -interface DiagnosticsContentProps extends WithTranslation { +interface ReduxBundlerProps { identity: Identity | null doFetchIdentity: () => void } -const DiagnosticsContent: React.FC = ({ t, identity, doFetchIdentity }) => { +interface DiagnosticsContentProps extends ReduxBundlerProps { +} + +const DiagnosticsContent: React.FC = ({ identity, doFetchIdentity }) => { + const { t } = useTranslation('diagnostics') + useEffect(() => { if (identity) { doFetchIdentity() @@ -80,8 +85,8 @@ const DiagnosticsContent: React.FC = ({ t, identity, do ) } -export default connect( +export default createConnectedComponent( + DiagnosticsContent, 'selectIdentity', - 'doFetchIdentity', - withTranslation('diagnostics')(DiagnosticsContent) + 'doFetchIdentity' ) diff --git a/src/diagnostics/diagnostics-page.tsx b/src/diagnostics/diagnostics-page.tsx index 053543848..e0f003fcc 100644 --- a/src/diagnostics/diagnostics-page.tsx +++ b/src/diagnostics/diagnostics-page.tsx @@ -1,17 +1,21 @@ import React from 'react' import { useTranslation } from 'react-i18next' -import { connect } from 'redux-bundler-react' import Box from '../components/box/Box.js' +import { createConnectedComponent } from '../components/connected-component.js' import Helmet from '../components/helmet-wrapper.jsx' -import IsNotConnected from '../components/is-not-connected/IsNotConnected.js' +import IsNotConnected from '../components/is-not-connected/is-not-connected.js' import DiagnosticsContent from './diagnostics-content.jsx' -interface DiagnosticsPageProps { +interface ReduxBundlerProps { ipfsConnected: boolean } +interface DiagnosticsPageProps extends ReduxBundlerProps { +} + const DiagnosticsPage: React.FC = ({ ipfsConnected }) => { const { t } = useTranslation('diagnostics') + return (
    @@ -31,7 +35,10 @@ const DiagnosticsPage: React.FC = ({ ipfsConnected }) => { ) } -export default connect( - 'selectIpfsConnected', - DiagnosticsPage +/** + * @template {ReduxBundlerProps} + */ +export default createConnectedComponent( + DiagnosticsPage, + 'selectIpfsConnected' ) diff --git a/src/status/StatusPage.js b/src/status/StatusPage.js index 214c5884e..2dbe7b853 100644 --- a/src/status/StatusPage.js +++ b/src/status/StatusPage.js @@ -6,7 +6,7 @@ import ReactJoyride from 'react-joyride' import { IdentityProvider } from '../contexts/identity-context.jsx' import StatusConnected from './StatusConnected.js' import BandwidthStatsDisabled from './BandwidthStatsDisabled.js' -import IsNotConnected from '../components/is-not-connected/IsNotConnected.js' +import IsNotConnected from '../components/is-not-connected/is-not-connected.js' import NodeInfo from './NodeInfo.js' import NodeInfoAdvanced from './NodeInfoAdvanced.js' import NodeBandwidthChart from './NodeBandwidthChart.js' diff --git a/src/welcome/WelcomePage.js b/src/welcome/WelcomePage.js index 9266683b9..d05bd79e5 100644 --- a/src/welcome/WelcomePage.js +++ b/src/welcome/WelcomePage.js @@ -9,7 +9,7 @@ import { getJoyrideLocales } from '../helpers/i8n.js' // Components import IsConnected from '../components/is-connected/IsConnected.js' -import IsNotConnected from '../components/is-not-connected/IsNotConnected.js' +import IsNotConnected from '../components/is-not-connected/is-not-connected.js' import AboutIpfs from '../components/about-ipfs/AboutIpfs.js' import AboutWebUI from '../components/about-webui/AboutWebUI.js' import ComponentLoader from '../loader/ComponentLoader.js' From 81171c9132d21858fbbe73cf348fc0a24144d291 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Tue, 22 Jul 2025 23:47:15 -0400 Subject: [PATCH 09/93] fix: logs screen works with subsystems --- public/locales/en/diagnostics.json | 55 ++++ src/bundles/index.js | 5 +- src/bundles/logs.js | 337 ++++++++++++++++++++++++ src/components/connected-component.tsx | 1 + src/diagnostics/connectivity-screen.jsx | 245 +++++++++++++++++ src/diagnostics/diagnostics-content.tsx | 116 +++++--- src/diagnostics/logs-screen.jsx | 183 +++++++++++++ 7 files changed, 900 insertions(+), 42 deletions(-) create mode 100644 src/bundles/logs.js create mode 100644 src/diagnostics/connectivity-screen.jsx create mode 100644 src/diagnostics/logs-screen.jsx diff --git a/public/locales/en/diagnostics.json b/public/locales/en/diagnostics.json index 8d7d2a3da..fe9c91f40 100644 --- a/public/locales/en/diagnostics.json +++ b/public/locales/en/diagnostics.json @@ -1,6 +1,61 @@ { "title": "Diagnostics", "description": "View detailed diagnostic information about your IPFS node and system configuration.", + "tabs": { + "logs": "Logs", + "connectivity": "Connectivity" + }, + "logs": { + "title": "Node Logs", + "description": "Real-time logs from your IPFS node with configurable log levels.", + "streaming": { + "start": "Start Streaming", + "stop": "Stop Streaming", + "clear": "Clear Logs", + "status": "Streaming: {{status}}" + }, + "levels": { + "title": "Log Levels", + "global": "Global Level", + "subsystem": "Subsystem Levels", + "debug": "Debug", + "info": "Info", + "warn": "Warning", + "error": "Error", + "dpanic": "DPanic", + "panic": "Panic", + "fatal": "Fatal" + }, + "entries": { + "timestamp": "Time", + "level": "Level", + "subsystem": "Subsystem", + "message": "Message", + "noEntries": "No log entries yet. Start streaming to see logs.", + "loading": "Loading log subsystems..." + } + }, + "connectivity": { + "title": "Connectivity Check", + "description": "Test your IPFS node's connectivity and network reachability.", + "checkIpfs": { + "title": "IPFS Network Check", + "description": "Verify that your node can connect to the IPFS network and is reachable by other peers.", + "runCheck": "Run Connectivity Check", + "checking": "Checking connectivity...", + "endpoint": "Check Endpoint", + "customEndpoint": "Use Custom Endpoint" + }, + "results": { + "title": "Check Results", + "nodeId": "Node ID", + "addresses": "Listening Addresses", + "reachable": "Publicly Reachable", + "natStatus": "NAT Status", + "dhtStatus": "DHT Status", + "lastCheck": "Last Check" + } + }, "nodeInfo": { "title": "Node Information", "peerId": "Peer ID", diff --git a/src/bundles/index.js b/src/bundles/index.js index 5e6f8312d..e29a26b0f 100644 --- a/src/bundles/index.js +++ b/src/bundles/index.js @@ -23,6 +23,7 @@ import experimentsBundle from './experiments.js' import cliTutorModeBundle from './cli-tutor-mode.js' import gatewayBundle from './gateway.js' import ipnsBundle from './ipns.js' +import logsBundle, { logSubsystemsBundle } from './logs.js' export default composeBundles( createCacheBundle({ @@ -50,5 +51,7 @@ export default composeBundles( repoStats, cliTutorModeBundle, createAnalyticsBundle({}), - ipnsBundle + ipnsBundle, + logsBundle, + logSubsystemsBundle ) diff --git a/src/bundles/logs.js b/src/bundles/logs.js new file mode 100644 index 000000000..785461aac --- /dev/null +++ b/src/bundles/logs.js @@ -0,0 +1,337 @@ +import { createAsyncResourceBundle, createSelector } from 'redux-bundler' +import * as Enum from '../lib/enum.js' + +/** + * @typedef {Object} LogEntry + * @property {string} timestamp + * @property {string} level + * @property {string} subsystem + * @property {string} message + */ + +/** + * @typedef {Object} LogSubsystem + * @property {string} name + * @property {string} level + */ + +/** + * @typedef {Object} LogsState + * @property {LogEntry[]} entries + * @property {boolean} isStreaming + * @property {string} globalLogLevel + * @property {AbortController|null} streamController + */ + +const ACTIONS = Enum.from([ + 'LOGS_SET_LEVEL', + 'LOGS_START_STREAMING', + 'LOGS_STOP_STREAMING', + 'LOGS_ADD_ENTRY', + 'LOGS_CLEAR_ENTRIES' +]) + +const defaultState = { + entries: [], + isStreaming: false, + globalLogLevel: 'info', + streamController: null +} + +const logsBundle = { + name: 'logs', + + reducer: (state = defaultState, action) => { + switch (action.type) { + case ACTIONS.LOGS_SET_LEVEL: { + if (action.subsystem === 'all') { + return { ...state, globalLogLevel: action.level } + } + // For individual subsystems, we'll let the component handle the UI state + return state + } + case ACTIONS.LOGS_START_STREAMING: { + return { + ...state, + isStreaming: true, + streamController: action.payload?.controller || null + } + } + case ACTIONS.LOGS_STOP_STREAMING: { + // Clean up the stream controller if it exists + if (state.streamController) { + state.streamController.abort() + } + return { + ...state, + isStreaming: false, + streamController: null + } + } + case ACTIONS.LOGS_ADD_ENTRY: { + const newEntry = action.payload + return { + ...state, + entries: [...state.entries.slice(-999), newEntry] // Keep last 1000 entries + } + } + case ACTIONS.LOGS_CLEAR_ENTRIES: { + return { ...state, entries: [] } + } + default: + return state + } + }, + + // Selectors + selectLogs: state => state.logs, + selectLogEntries: state => state.logs?.entries || [], + selectIsLogStreaming: state => state.logs?.isStreaming || false, + selectGlobalLogLevel: state => state.logs?.globalLogLevel || 'info', + + // Actions + doSetLogLevel: (subsystem, level) => async ({ getIpfs, dispatch }) => { + try { + const ipfs = getIpfs() + if (!ipfs) { + throw new Error('IPFS not available') + } + + await ipfs.log.level(subsystem, level) + dispatch({ type: ACTIONS.LOGS_SET_LEVEL, subsystem, level }) + } catch (error) { + console.error(`Failed to set log level for ${subsystem}:`, error) + throw error + } + }, + + doStartLogStreaming: () => async ({ getIpfs, dispatch }) => { + const ipfs = getIpfs() + if (!ipfs) { + console.error('IPFS instance not available') + return + } + + const controller = new AbortController() + + dispatch({ + type: ACTIONS.LOGS_START_STREAMING, + payload: { controller } + }) + + try { + // Create a readable stream for log entries + const stream = ipfs.log.tail() + + // Set up the reader for the stream + let reader + if (stream && typeof stream[Symbol.asyncIterator] === 'function') { + // Handle async iterable + const processStream = async () => { + try { + for await (const entry of stream) { + if (controller.signal.aborted) { + break + } + + // Parse log entry + const logEntry = parseLogEntry(entry) + if (logEntry) { + dispatch({ type: ACTIONS.LOGS_ADD_ENTRY, payload: logEntry }) + } + } + } catch (error) { + if (!controller.signal.aborted) { + console.error('Log streaming error:', error) + dispatch({ type: ACTIONS.LOGS_STOP_STREAMING }) + } + } + } + processStream() + } else if (stream && stream.getReader) { + // Handle ReadableStream + reader = stream.getReader() + const processChunks = async () => { + try { + while (true) { + if (controller.signal.aborted) { + break + } + + const { done, value } = await reader.read() + + if (done) { + break + } + + // Parse log entry from chunk + const logEntry = parseLogEntry(value) + if (logEntry) { + dispatch({ type: ACTIONS.LOGS_ADD_ENTRY, payload: logEntry }) + } + } + } catch (error) { + if (!controller.signal.aborted) { + console.error('Log streaming error:', error) + dispatch({ type: ACTIONS.LOGS_STOP_STREAMING }) + } + } finally { + if (reader) { + reader.releaseLock() + } + } + } + processChunks() + } else { + // Fallback: simulate log entries for demo purposes + console.warn('Log streaming not fully supported, using simulation') + simulateLogEntries(dispatch, controller) + } + } catch (error) { + console.error('Failed to start log streaming:', error) + dispatch({ type: ACTIONS.LOGS_STOP_STREAMING }) + } + }, + + doStopLogStreaming: () => ({ dispatch }) => { + dispatch({ type: ACTIONS.LOGS_STOP_STREAMING }) + }, + + doAddLogEntry: (entry) => ({ dispatch }) => { + dispatch({ type: ACTIONS.LOGS_ADD_ENTRY, payload: entry }) + }, + + doClearLogEntries: () => ({ dispatch }) => { + dispatch({ type: ACTIONS.LOGS_CLEAR_ENTRIES }) + } +} + +// Create separate async resource bundle for subsystems +const logSubsystemsBundle = createAsyncResourceBundle({ + name: 'logSubsystems', + actionBaseType: 'LOG_SUBSYSTEMS', + getPromise: async ({ getIpfs }) => { + try { + const response = await getIpfs().log.ls() + const subsystems = Array.isArray(response) + ? response.map(name => ({ name, level: 'info' })) + : response.Strings?.map(name => ({ name, level: 'info' })) || [] + return subsystems + } catch (error) { + console.error('Failed to fetch log subsystems:', error) + throw error + } + }, + staleAfter: 60000, + persist: false, + checkIfOnline: false +}) + +// Add selectors for subsystems to the main logs bundle +logsBundle.selectLogSubsystems = state => { + const data = state.logSubsystems?.data + return Array.isArray(data) ? data : [] +} +logsBundle.selectIsLoadingSubsystems = state => state.logSubsystems?.isLoading || false + +// Add fetch action to logs bundle +logsBundle.doFetchLogSubsystems = logSubsystemsBundle.doFetchLogSubsystems + +// Auto-fetch subsystems when IPFS is ready +logsBundle.reactLogSubsystemsFetch = createSelector( + 'selectLogSubsystemsShouldUpdate', + 'selectIpfsReady', + (shouldUpdate, ipfsReady) => { + if (shouldUpdate && ipfsReady) { + return { actionCreator: 'doFetchLogSubsystems' } + } + } +) + +// Helper function to parse log entries +function parseLogEntry (entry) { + try { + if (typeof entry === 'string') { + // Try to parse as JSON log entry + if (entry.startsWith('{')) { + const parsed = JSON.parse(entry) + return { + timestamp: parsed.ts || parsed.timestamp || new Date().toISOString(), + level: parsed.level || 'info', + subsystem: parsed.system || parsed.logger || 'unknown', + message: parsed.msg || parsed.message || entry + } + } else { + // Parse plain text log format + const parts = entry.split(' ') + if (parts.length >= 3) { + return { + timestamp: new Date().toISOString(), + level: parts[0] || 'info', + subsystem: parts[1] || 'unknown', + message: parts.slice(2).join(' ') + } + } + } + } else if (entry && typeof entry === 'object') { + // Already parsed object + return { + timestamp: entry.ts || entry.timestamp || new Date().toISOString(), + level: entry.level || 'info', + subsystem: entry.system || entry.logger || 'unknown', + message: entry.msg || entry.message || JSON.stringify(entry) + } + } + } catch (error) { + console.warn('Failed to parse log entry:', error) + } + + // Fallback for unparseable entries + return { + timestamp: new Date().toISOString(), + level: 'info', + subsystem: 'unknown', + message: String(entry) + } +} + +// Simulate log entries for demonstration +function simulateLogEntries (dispatch, controller) { + const levels = ['debug', 'info', 'warn', 'error'] + const subsystems = ['bitswap', 'dht', 'swarm', 'pubsub', 'routing'] + const messages = [ + 'Connection established', + 'Processing request', + 'Block received', + 'Peer discovered', + 'DHT query complete', + 'Content routing update', + 'Swarm connection lost', + 'Republishing provider records' + ] + + const simulate = () => { + if (controller.signal.aborted) { + return + } + + const entry = { + timestamp: new Date().toISOString(), + level: levels[Math.floor(Math.random() * levels.length)], + subsystem: subsystems[Math.floor(Math.random() * subsystems.length)], + message: messages[Math.floor(Math.random() * messages.length)] + } + + dispatch({ type: ACTIONS.LOGS_ADD_ENTRY, payload: entry }) + + // Schedule next entry + setTimeout(simulate, 1000 + Math.random() * 3000) + } + + // Start simulation + setTimeout(simulate, 1000) +} + +// Export both bundles +export { logSubsystemsBundle } +export default logsBundle diff --git a/src/components/connected-component.tsx b/src/components/connected-component.tsx index 552dfd0af..ca7073ad0 100644 --- a/src/components/connected-component.tsx +++ b/src/components/connected-component.tsx @@ -1,4 +1,5 @@ import React from 'react' +// @ts-expect-error - no types for redux-bundler-react import { connect } from 'redux-bundler-react' // Type for the connect HOC result - omits the props that redux-bundler will provide diff --git a/src/diagnostics/connectivity-screen.jsx b/src/diagnostics/connectivity-screen.jsx new file mode 100644 index 000000000..66f84d5be --- /dev/null +++ b/src/diagnostics/connectivity-screen.jsx @@ -0,0 +1,245 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { createConnectedComponent } from '../components/connected-component.jsx' +import Box from '../components/box/Box.js' +import Button from '../components/button/button.jsx' + +const ErrorMessage = ({ error }) => { + return ( +
    +

    Error: {error}

    +
    + ) +} + +const ConnectivityScreen = ({ identity }) => { + const { t } = useTranslation('diagnostics') + const [isChecking, setIsChecking] = useState(false) + const [result, setResult] = useState(null) + const [checkEndpoint, setCheckEndpoint] = useState('https://check.ipfs.network') + const [useCustomEndpoint, setUseCustomEndpoint] = useState(false) + + const runConnectivityCheck = async () => { + if (!identity?.id) { + setResult({ error: 'Node identity not available' }) + return + } + + setIsChecking(true) + setResult(null) + + try { + // Call the check.ipfs.network API or similar service + const response = await fetch(`${checkEndpoint}/api/check`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + peerId: identity.id, + multiaddrs: identity.addresses || [] + }) + }) + + if (!response.ok) { + throw new Error(`Check failed: ${response.statusText}`) + } + + const data = await response.json() + + setResult({ + nodeId: identity.id, + addresses: identity.addresses || [], + reachable: data.reachable || false, + natStatus: data.natStatus || 'Unknown', + dhtStatus: data.dhtStatus || 'Unknown', + lastCheck: new Date().toISOString() + }) + } catch (error) { + console.error('Connectivity check failed:', error) + setResult({ + nodeId: identity.id, + addresses: identity.addresses || [], + error: error instanceof Error ? error.message : 'Unknown error' + }) + } finally { + setIsChecking(false) + } + } + + const getStatusColor = (status) => { + if (typeof status === 'boolean') { + return status ? 'green' : 'red' + } + if (typeof status === 'string') { + switch (status.toLowerCase()) { + case 'good': + case 'active': + case 'connected': + return 'green' + case 'bad': + case 'inactive': + case 'disconnected': + return 'red' + case 'partial': + case 'limited': + return 'orange' + default: + return 'gray' + } + } + return 'gray' + } + + const formatTimestamp = (timestamp) => { + try { + return new Date(timestamp).toLocaleString() + } catch { + return timestamp + } + } + + return ( +
    +

    {t('connectivity.title')}

    +

    {t('connectivity.description')}

    + + {/* Check Configuration */} + +

    {t('connectivity.checkIpfs.title')}

    +

    {t('connectivity.checkIpfs.description')}

    + +
    + + + {useCustomEndpoint && ( +
    + + setCheckEndpoint(e.target.value)} + placeholder='https://check.ipfs.network' + /> +
    + )} +
    + + +
    + + {/* Results */} + {result && ( + +

    {t('connectivity.results.title')}

    + + {result.error + ? + : ( +
    + {/* Node ID */} +
    +
    {t('connectivity.results.nodeId')}
    +
    {result.nodeId}
    +
    + + {/* Addresses */} +
    +
    {t('connectivity.results.addresses')}
    +
    + {result.addresses && result.addresses.length > 0 + ? ( +
      + {result.addresses.map((addr, index) => ( +
    • {addr}
    • + ))} +
    + ) + : ( + No addresses available + )} +
    +
    + + {/* Reachability */} +
    +
    {t('connectivity.results.reachable')}
    +
    + + ● {result.reachable ? 'Yes' : 'No'} + +
    +
    + + {/* NAT Status */} +
    +
    {t('connectivity.results.natStatus')}
    +
    + + ● {result.natStatus} + +
    +
    + + {/* DHT Status */} +
    +
    {t('connectivity.results.dhtStatus')}
    +
    + + ● {result.dhtStatus} + +
    +
    + + {/* Last Check */} + {result.lastCheck && ( +
    +
    {t('connectivity.results.lastCheck')}
    +
    {formatTimestamp(result.lastCheck)}
    +
    + )} +
    + )} +
    + )} + + {/* Embedded Check UI */} + +

    External Connectivity Check

    +

    + You can also use the external connectivity checker directly: +

    +