diff --git a/public/locales/en/app.json b/public/locales/en/app.json
index c40e0a833..3704b0a8c 100644
--- a/public/locales/en/app.json
+++ b/public/locales/en/app.json
@@ -54,6 +54,10 @@
"publicSubdomainGatewayForm": {
"placeholder": "Enter a URL (https://dweb.link)"
},
+ "ipfsCheckForm": {
+ "label": "Retrieval Check Service URL",
+ "placeholder": "Enter a URL (https://check.ipfs.network)"
+ },
"terms": {
"address": "Address",
"addresses": "Addresses",
diff --git a/public/locales/en/diagnostics.json b/public/locales/en/diagnostics.json
index db53eecc1..8af190760 100644
--- a/public/locales/en/diagnostics.json
+++ b/public/locales/en/diagnostics.json
@@ -3,6 +3,7 @@
"description": "View detailed diagnostic information about your IPFS node and system configuration.",
"tabs": {
"logs": "Logs",
+ "check": "Retrieval Check",
"connectivity": "Connectivity"
},
"logs": {
diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json
index b76ed9a7c..49319c69f 100644
--- a/public/locales/en/settings.json
+++ b/public/locales/en/settings.json
@@ -25,6 +25,10 @@
"apiDescription": "<0>If your node is configured with a <1>custom Kubo RPC API address1>, including a port other than the default 5001, enter it here.0>",
"publicSubdomainGatewayDescription": "<0>Select a default <1>Subdomain Gateway1> for generating shareable links.0>",
"publicPathGatewayDescription": "<0>Select a fallback <1>Path Gateway1> for generating shareable links for CIDs that exceed the 63-character DNS limit.0>",
+ "retrievalDiagnosticService": {
+ "title": "Retrieval Diagnostic Service",
+ "description": "Configure the URL of the <0>ipfs-check0> service used for <1>retrieval diagnostics1>. This service checks if content can be successfully fetched from your node and other nodes hosting a specific CID, helping you troubleshoot sharing issues."
+ },
"cliDescription": "<0>Enable this option to display a \"view code\" <1>1> icon next to common IPFS commands. Clicking it opens a modal with that command's CLI code, so you can paste it into the IPFS command-line interface in your terminal.0>",
"cliModal": {
"extraNotesJsonConfig": "If you've made changes to the config in this page's code editor that you'd like to save, click the download icon next to the copy button to download it as a JSON file."
diff --git a/src/bundles/gateway.js b/src/bundles/gateway.js
index 978437cbd..2e5188460 100644
--- a/src/bundles/gateway.js
+++ b/src/bundles/gateway.js
@@ -3,6 +3,7 @@ import { readSetting, writeSetting } from './local-storage.js'
// TODO: switch to dweb.link when https://github.com/ipfs/kubo/issues/7318
export const DEFAULT_PATH_GATEWAY = 'https://ipfs.io'
export const DEFAULT_SUBDOMAIN_GATEWAY = 'https://dweb.link'
+export const DEFAULT_IPFS_CHECK_URL = 'https://check.ipfs.network'
const IMG_HASH_1PX = 'bafkreib6wedzfupqy7qh44sie42ub4mvfwnfukmw6s2564flajwnt4cvc4' // 1x1.png
const IMG_ARRAY = [
{ id: 'IMG_HASH_1PX', name: '1x1.png', hash: IMG_HASH_1PX },
@@ -20,10 +21,16 @@ const readPublicSubdomainGatewaySetting = () => {
return setting || DEFAULT_SUBDOMAIN_GATEWAY
}
+const readIpfsCheckUrlSetting = () => {
+ const setting = readSetting('ipfsCheckUrl')
+ return setting || DEFAULT_IPFS_CHECK_URL
+}
+
const init = () => ({
availableGateway: null,
publicGateway: readPublicGatewaySetting(),
- publicSubdomainGateway: readPublicSubdomainGatewaySetting()
+ publicSubdomainGateway: readPublicSubdomainGatewaySetting(),
+ ipfsCheckUrl: readIpfsCheckUrlSetting()
})
export const checkValidHttpUrl = (value) => {
@@ -169,6 +176,10 @@ const bundle = {
return { ...state, publicSubdomainGateway: action.payload }
}
+ if (action.type === 'SET_IPFS_CHECK_URL') {
+ return { ...state, ipfsCheckUrl: action.payload }
+ }
+
return state
},
@@ -184,11 +195,18 @@ const bundle = {
dispatch({ type: 'SET_PUBLIC_SUBDOMAIN_GATEWAY', payload: address })
},
+ doUpdateIpfsCheckUrl: (url) => async ({ dispatch }) => {
+ await writeSetting('ipfsCheckUrl', url)
+ dispatch({ type: 'SET_IPFS_CHECK_URL', payload: url })
+ },
+
selectAvailableGateway: (state) => state?.gateway?.availableGateway,
selectPublicGateway: (state) => state?.gateway?.publicGateway,
- selectPublicSubdomainGateway: (state) => state?.gateway?.publicSubdomainGateway
+ selectPublicSubdomainGateway: (state) => state?.gateway?.publicSubdomainGateway,
+
+ selectIpfsCheckUrl: (state) => state?.gateway?.ipfsCheckUrl
}
export default bundle
diff --git a/src/bundles/index.js b/src/bundles/index.js
index 5e6f8312d..df5d6ad1d 100644
--- a/src/bundles/index.js
+++ b/src/bundles/index.js
@@ -1,4 +1,4 @@
-import { composeBundles, createCacheBundle } from 'redux-bundler'
+import { composeBundles, createCacheBundle, createSelector } from 'redux-bundler'
import ipfsProvider from './ipfs-provider.js'
import appIdle from './app-idle.js'
import nodeBandwidthChartBundle from './node-bandwidth-chart.js'
@@ -23,8 +23,18 @@ import experimentsBundle from './experiments.js'
import cliTutorModeBundle from './cli-tutor-mode.js'
import gatewayBundle from './gateway.js'
import ipnsBundle from './ipns.js'
+import { contextBridge } from '../helpers/context-bridge'
export default composeBundles(
+ {
+ name: 'bridgedContextCatchAll',
+ reactRouteInfoToBridge: createSelector(
+ 'selectRouteInfo',
+ (routeInfo) => {
+ contextBridge.setContext('selectRouteInfo', routeInfo)
+ }
+ )
+ },
createCacheBundle({
cacheFn: bundleCache.set
}),
diff --git a/src/bundles/routes-types.ts b/src/bundles/routes-types.ts
new file mode 100644
index 000000000..c2c8faf1d
--- /dev/null
+++ b/src/bundles/routes-types.ts
@@ -0,0 +1,33 @@
+import type React from 'react'
+
+/**
+ * src/bundles/routes.js creates a RouteBundle from redux-bundler that provides some objects that are not typed.
+ */
+
+/**
+ * The type for the object provided by `selectRouteInfo` selector.
+ *
+ * These types are not 100% accurate and only filled out as accurately as possible as needed.
+ */
+export interface RouteInfo {
+ /**
+ * The value of the currently matched pattern from src/bundles/routes.js
+ */
+ page: React.ReactNode
+
+ params: {
+ // if you are on #/diagnostics/logs, this will be equal to '/logs'
+ path: string
+ }
+
+ /**
+ * This will match whatever key is set in src/bundles/routes.js for the page that is currently active.
+ * For the diagnostics page, this will be equal to '/diagnostics*'
+ */
+ pattern: string
+
+ /**
+ * The hash of the url, without the hash symbol.
+ */
+ url: string
+}
diff --git a/src/bundles/routes.js b/src/bundles/routes.js
index 2524925c9..5b1915c9e 100644
--- a/src/bundles/routes.js
+++ b/src/bundles/routes.js
@@ -22,7 +22,7 @@ export default createRouteBundle({
'/settings*': SettingsPage,
'/welcome': WelcomePage,
'/blank': BlankPage,
- '/diagnostics': DiagnosticsPage,
+ '/diagnostics*': DiagnosticsPage,
'/status*': StatusPage,
'/': StatusPage,
'': StatusPage
diff --git a/src/components/ipfs-check-form/IpfsCheckForm.js b/src/components/ipfs-check-form/IpfsCheckForm.js
new file mode 100644
index 000000000..3e569a267
--- /dev/null
+++ b/src/components/ipfs-check-form/IpfsCheckForm.js
@@ -0,0 +1,90 @@
+import React, { useState, useEffect } from 'react'
+import { connect } from 'redux-bundler-react'
+import { withTranslation } from 'react-i18next'
+import Button from '../button/button.tsx'
+import { checkValidHttpUrl, DEFAULT_IPFS_CHECK_URL } from '../../bundles/gateway.js'
+
+const IpfsCheckForm = ({ t, doUpdateIpfsCheckUrl, ipfsCheckUrl }) => {
+ const [value, setValue] = useState(ipfsCheckUrl)
+ const initialIsValidUrl = !checkValidHttpUrl(value)
+ const [showFailState, setShowFailState] = useState(initialIsValidUrl)
+ const [isValidUrl, setIsValidUrl] = useState(initialIsValidUrl)
+
+ // Updates the border of the input to indicate validity
+ useEffect(() => {
+ setShowFailState(!isValidUrl)
+ }, [isValidUrl])
+
+ // Updates the border of the input to indicate validity
+ useEffect(() => {
+ const isValid = checkValidHttpUrl(value)
+ setIsValidUrl(isValid)
+ setShowFailState(!isValid)
+ }, [value])
+
+ const onChange = (event) => setValue(event.target.value)
+
+ const onSubmit = async (event) => {
+ event.preventDefault()
+
+ if (!isValidUrl) {
+ setShowFailState(true)
+ return
+ }
+
+ doUpdateIpfsCheckUrl(value)
+ }
+
+ const onDefault = async (event) => {
+ event.preventDefault()
+ setValue(DEFAULT_IPFS_CHECK_URL)
+ doUpdateIpfsCheckUrl(DEFAULT_IPFS_CHECK_URL)
+ }
+
+ const onKeyPress = (event) => {
+ if (event.key === 'Enter') {
+ onSubmit(event)
+ }
+ }
+
+ return (
+
+ )
+}
+
+export default connect(
+ 'doUpdateIpfsCheckUrl',
+ 'selectIpfsCheckUrl',
+ withTranslation('app')(IpfsCheckForm)
+)
diff --git a/src/diagnostics/check-screen/check-screen.tsx b/src/diagnostics/check-screen/check-screen.tsx
new file mode 100644
index 000000000..847809e54
--- /dev/null
+++ b/src/diagnostics/check-screen/check-screen.tsx
@@ -0,0 +1,88 @@
+import React, { useRef, useEffect, useState } from 'react'
+import { connect } from 'redux-bundler-react'
+import { useDebouncedCallback } from '../../lib/hooks/use-debounced-callback'
+import { DEFAULT_IPFS_CHECK_URL } from '../../bundles/gateway.js'
+
+interface CheckScreenProps {
+ cid?: string
+ ipfsCheckUrl?: string
+}
+
+const CheckScreen: React.FC = ({ cid, ipfsCheckUrl }) => {
+ const ipfsCheckBaseUrl = ipfsCheckUrl || DEFAULT_IPFS_CHECK_URL
+ const baseUrl = ipfsCheckBaseUrl.endsWith('/') ? ipfsCheckBaseUrl : `${ipfsCheckBaseUrl}/`
+ const ipfsCheckOrigin = new URL(baseUrl).origin
+ const ref = useRef(null)
+ const [isLoading, setIsLoading] = useState(true)
+
+ const requestSize = useDebouncedCallback(() => {
+ const iframe = ref.current
+ if (!iframe) return
+ ref.current?.contentWindow?.postMessage({ type: 'iframe-size:request' }, '*')
+ }, 100)
+
+ useEffect(() => {
+ const iframe = ref.current
+ if (!iframe) return
+
+ const onMsg = (e: MessageEvent) => {
+ // Validate origin to prevent XSS attacks
+ if (e.origin !== ipfsCheckOrigin) return
+ if (e.data?.type !== 'iframe-size:report') return
+
+ // Hide loading message as soon as we get first message from iframe
+ setIsLoading(false)
+
+ // Update iframe height based on content
+ iframe.style.height = `${e.data.height}px`
+ }
+ const onLoad = () => {
+ setIsLoading(false)
+ requestSize()
+ }
+
+ window.addEventListener('message', onMsg)
+ window.addEventListener('resize', requestSize)
+ iframe.addEventListener('load', onLoad)
+
+ // initial size request since the iframe ref is available.
+ requestSize()
+
+ return () => {
+ window.removeEventListener('message', onMsg)
+ window.removeEventListener('resize', requestSize)
+ iframe.removeEventListener('load', onLoad)
+ }
+ }, [requestSize, ipfsCheckOrigin])
+
+ // Build the iframe URL with optional CID parameter
+ const iframeSrc = cid ? `${baseUrl}?cid=${encodeURIComponent(cid)}` : baseUrl
+
+ return (
+
+ {isLoading && (
+
+
Loading retrieval check...
+
+ )}
+
+
+ )
+}
+
+export default connect(
+ 'selectIpfsCheckUrl',
+ CheckScreen
+)
diff --git a/src/diagnostics/diagnostics-content.tsx b/src/diagnostics/diagnostics-content.tsx
index 3ad08a207..2775be52b 100644
--- a/src/diagnostics/diagnostics-content.tsx
+++ b/src/diagnostics/diagnostics-content.tsx
@@ -1,31 +1,81 @@
-import React, { useState } from 'react'
+import React, { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import LogsScreen from './logs-screen/logs-screen.js'
import { LogsProvider } from '../contexts/logs/index'
import { IdentityProvider } from '../contexts/identity-context'
+import CheckScreen from './check-screen/check-screen.js'
+import { useBridgeSelector } from '../helpers/context-bridge'
+import { RouteInfo } from '../bundles/routes-types'
interface DiagnosticsContentProps {
}
-type TabKey = 'logs'
+type TabKey = 'logs' | 'check'
+
+function getTabKeyFromUrl (path: string): { tab: TabKey, remainder?: string } {
+ const parts = path.split('/').filter(p => p) // Remove empty strings
+
+ if (parts.length === 0) {
+ // Default to logs for empty path
+ return { tab: 'logs' }
+ }
+
+ const tab = parts[0] as TabKey
+ const remainder = parts.slice(1).join('/')
+
+ return {
+ tab,
+ remainder: remainder || undefined
+ }
+}
+
+interface TabButtonProps {
+ tabKey: TabKey
+ label: string
+ active: boolean
+}
+
+const TabButton = ({ tabKey, label, active }: TabButtonProps) => (
+
+ {label}
+
+)
const DiagnosticsContent: React.FC = () => {
const { t } = useTranslation('diagnostics')
- const [activeTab, setActiveTab] = useState('logs')
-
- const renderTabButton = (tabKey: TabKey, label: string) => (
-
- )
+ const routeInfo = useBridgeSelector('selectRouteInfo')
+ const path = routeInfo?.params.path ?? ''
+ const { tab: activeTab, remainder } = getTabKeyFromUrl(path)
+
+ // Redirect from /diagnostics or /diagnostics/ to /diagnostics/logs
+ useEffect(() => {
+ if (path === '' || path === '/') {
+ window.location.replace('#/diagnostics/logs')
+ }
+ }, [path])
+
+ const isMounted = useRef(false)
+ useEffect(() => {
+ isMounted.current = true
+ return () => {
+ isMounted.current = false
+ }
+ }, [])
const renderTabContent = () => {
switch (activeTab) {
@@ -33,10 +83,14 @@ const DiagnosticsContent: React.FC = () => {
return (
-
+
)
+ case 'check':
+ return (
+
+ )
default:
return null
}
@@ -45,9 +99,10 @@ const DiagnosticsContent: React.FC = () => {
return (
{/* Tab Navigation */}
-
-