diff --git a/.aegir.js b/.aegir.js index e7782801e..d2aeda92e 100644 --- a/.aegir.js +++ b/.aegir.js @@ -51,6 +51,7 @@ export default { 'jest-environment-jsdom', // in npm script via --env=jsdom '@testing-library/react', // jsx is not tested properly '@testing-library/jest-dom', // jsx is not tested properly + '@testing-library/user-event', // jsx is not tested properly // storybook deps diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index bd062b85d..d59d26d40 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -113,7 +113,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: test-results + name: test-results-shard-${{ matrix.shardIndex }} path: test-results/ retention-days: 7 diff --git a/.gitignore b/.gitignore index 2d5d3ef71..418195036 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ test/e2e/setup/ipfs-backend.json test/e2e/state.json storybook-static +test-results/ # production /build diff --git a/package-lock.json b/package-lock.json index ee587b8d9..a71fcc881 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,6 +106,7 @@ "@svgr/cli": "^8.1.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^13.5.0", "@types/esm": "^3.2.0", "@types/jest": "^29.5.14", "@types/node": "^14.18.36", @@ -139,7 +140,8 @@ "ipfsd-ctl": "^15.0.2", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "kubo": "^0.36.0", + "jest-mock": "^30.0.5", + "kubo": "^0.37.0", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", "os-browserify": "^0.3.0", @@ -5602,6 +5604,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@jest/environment/node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment/node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", @@ -5706,6 +5741,21 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/fake-timers/node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/fake-timers/node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -5778,6 +5828,63 @@ "dev": true, "license": "MIT" }, + "node_modules/@jest/globals/node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals/node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -17834,6 +17941,76 @@ "react": "^15.0.0-0 || ^16.0.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@testing-library/jest-dom": { "version": "5.17.0", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", @@ -17988,6 +18165,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/user-event": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", + "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -44751,6 +44945,21 @@ "dev": true, "license": "MIT" }, + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-environment-jsdom/node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -44825,6 +45034,21 @@ "dev": true, "license": "MIT" }, + "node_modules/jest-environment-node/node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-environment-node/node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -45590,74 +45814,104 @@ "license": "MIT" }, "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.0.5", "@types/node": "*", - "jest-util": "^29.7.0" + "jest-util": "30.0.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-mock/node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-mock/node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-mock/node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "version": "0.34.40", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.40.tgz", + "integrity": "sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==", "dev": true, "license": "MIT" }, + "node_modules/jest-mock/node_modules/ci-info": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jest-mock/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.0.5", "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/jest-pnp-resolver": { @@ -46349,6 +46603,21 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-runtime/node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-runtime/node_modules/jest-regex-util": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", @@ -47936,9 +48205,9 @@ } }, "node_modules/kubo": { - "version": "0.36.0", - "resolved": "https://registry.npmjs.org/kubo/-/kubo-0.36.0.tgz", - "integrity": "sha512-bZJ8RR+xtOzoU4TY3A7lfgNtLdvUJXddAUCVFwH2/yqITNsLpSEAJ2SsyIIkHr3beVqW2lzAIw4cQXz8RZSI/Q==", + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/kubo/-/kubo-0.37.0.tgz", + "integrity": "sha512-TjU04/gbJd+pFvc2s2r0mKF2LhgSCWNko0TYpaWDiWI9HPX6k6PvNQinMfwnP8EvyQgfhH50MNrVXNvJQ5dfoQ==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index 3ff077c24..0d9859b4d 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "@svgr/cli": "^8.1.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^13.5.0", "@types/esm": "^3.2.0", "@types/jest": "^29.5.14", "@types/node": "^14.18.36", @@ -169,7 +170,8 @@ "ipfsd-ctl": "^15.0.2", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "kubo": "^0.36.0", + "jest-mock": "^30.0.5", + "kubo": "^0.37.0", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", "os-browserify": "^0.3.0", diff --git a/public/locales/en/app.json b/public/locales/en/app.json index c40e0a833..fee465c63 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -9,29 +9,25 @@ "close": "Close", "copy": "Copy", "create": "Create", + "default": "Default", "remove": "Remove", "download": "Download", "edit": "Edit", "import": "Import", "inspect": "Inspect", "more": "More", - "moreInfo": "More info", - "noThanks": "No thanks", - "ok": "OK", - "pinVerb": "Pin", "rename": "Rename", "reset": "Reset", "save": "Save", "saving": "Saving…", - "selectAll": "Select all", "setPinning": "Set pinning", "submit": "Submit", - "unpin": "Unpin", "unselectAll": "Unselect all", "generate": "Generate", "publish": "Publish", "downloadCar": "Export CAR", - "done": "Done" + "done": "Done", + "checkRetrieval": "Check Retrieval" }, "cliModal": { "description": "Paste the following into your terminal to do this task in Kubo via the command line. Remember that you'll need to replace placeholders with your specific parameters." @@ -54,6 +50,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 new file mode 100644 index 000000000..6d1d289d9 --- /dev/null +++ b/public/locales/en/diagnostics.json @@ -0,0 +1,94 @@ +{ + "title": "Diagnostics", + "tabs": { + "logs": "Logs", + "retrieval-check": "Retrieval Check" + }, + "logs": { + "title": "Node Logs", + "streaming": { + "statusActive": "Streaming Active", + "statusStopped": "Streaming Stopped", + "rate": "{rate} logs/sec", + "highRate": "High Rate", + "autoDisabled": "Auto-disabled" + }, + "entries": { + "noEntries": "No log entries yet. Start streaming to see logs.", + "noEntriesStreaming": "Listening for logs... (tip: increase log levels to see more activity)", + "loading": "Loading log subsystems...", + "tooltipGoToLatest": "Scroll to latest logs", + "tooltipExpand": "Expand logs", + "tooltipCollapse": "Collapse logs", + "tooltipPlay": "Start streaming", + "tooltipPause": "Stop streaming", + "tooltipSettings": "Configure streaming" + }, + "storage": { + "title": "Storage Statistics", + "totalEntries": "Total Entries", + "estimatedSize": "Estimated Size", + "memoryBuffer": "Memory Buffer", + "trashTooltip": "Clear all logs" + }, + "config": { + "title": "Buffer Configuration", + "memoryBuffer": "Memory Buffer (entries)", + "persistentBuffer": "Persistent Buffer (entries)", + "warnThreshold": "Warning Threshold (logs/sec)", + "autoDisableThreshold": "Auto-disable Threshold (logs/sec)" + }, + "gologLevel": { + "title": "Log Level Configuration", + "placeholder": "e.g. error,bitswap=info,nat=debug", + "description": "Choose what gets logged and how much detail to show. Start with a base level like 'error' to see only problems, or add specific components to track (like 'error,nat=debug' to debug UPnP issues). Same syntax as GOLOG_LOG_LEVEL. <0>Learn more", + "invalidSubsystemLevel": "Subsystem \"{subsystem}\" must be followed by an equal sign and a log level." + }, + "autocomplete": { + "globalLevel": "Global level", + "subsystem": "Subsystem", + "level": "Log level" + }, + "warnings": { + "potentialIssues": "Potential Issues:", + "recommendations": "Recommendations:", + "cancel": "Cancel", + "debugGlobal": { + "title": "Enable Debug Logging Globally?", + "message": "You are about to enable DEBUG level logging for all subsystems. This will generate a very high volume of logs and may impact browser performance.", + "detail1": "Debug logs can generate 100+ messages per second", + "detail2": "High CPU usage and memory consumption", + "detail3": "Potential browser tab crashes or freezing", + "suggestion1": "Consider enabling debug logs for specific subsystems only", + "suggestion2": "Use for short debugging sessions (< 5 minutes)", + "suggestion3": "Monitor the log rate indicator and disable if needed", + "confirm": "Enable Debug Globally" + }, + "highRate": { + "title": "High Log Rate Detected", + "message": "Current log rate is {rate} logs/second, which may cause performance issues.", + "detail1": "High log rates can slow down the browser", + "detail2": "Memory usage will increase rapidly", + "suggestion1": "Consider reducing log levels for busy subsystems", + "suggestion2": "Use buffer configuration to limit memory usage", + "confirm": "Continue" + }, + "autoDisable": { + "title": "Log Streaming Auto-Disabled", + "message": "Log streaming was automatically disabled due to extremely high rate ({rate} logs/sec).", + "detail1": "This protects your browser from potential crashes", + "detail2": "Logs are still being cached in persistent storage", + "suggestion1": "Reduce log levels before re-enabling streaming", + "suggestion2": "Check subsystem log levels and disable debug mode", + "confirm": "Understood" + } + }, + "unsupported": { + "title": "Unsupported Kubo Version", + "description": "Your current version of Kubo ({version}) does not support the log level management feature required for this diagnostics page.", + "upgradeTitle": "Upgrade Required", + "upgradeDescription": "To use the log level management features, you need to upgrade to a newer version of Kubo that supports getting log levels and tailing logs from the RPC API endpoint.", + "downloadKubo": "Download Kubo" + } + } +} diff --git a/public/locales/en/files.json b/public/locales/en/files.json index a50098709..967e860b5 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -1,12 +1,9 @@ { "title": "Files", - "home": "Home", "breadcrumbs": "Breadcrumbs", - "finished": "Finished!", "totalSize": "Total size: {size}", "filesSelected": "{count, plural, one {Item selected} other {Items selected}}", "menuOptions": "Options for selected item", - "selectedAllEntries": "Select all table entries", "individualFilesOnly": "Only available for individual files", "noPDFSupport": "Your browser does not support PDFs. Please download the PDF to view it:", "downloadPDF": "Download PDF", diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index b76ed9a7c..98585c8e3 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -15,7 +15,7 @@ "language": "Language", "analytics": "Analytics", "cliTutorMode": "CLI Tutor Mode", - "config": "IPFS Config", + "config": "Kubo Config", "languageModal": { "title": "Change language", "description": "Pick your preferred language.", @@ -25,6 +25,10 @@ "apiDescription": "<0>If your node is configured with a <1>custom Kubo RPC API address, including a port other than the default 5001, enter it here.", "publicSubdomainGatewayDescription": "<0>Select a default <1>Subdomain Gateway for generating shareable links.", "publicPathGatewayDescription": "<0>Select a fallback <1>Path Gateway for generating shareable links for CIDs that exceed the 63-character DNS limit.", + "retrievalDiagnosticService": { + "title": "Retrieval Diagnostic Service", + "description": "Configure the URL of the <0>ipfs-check service used for <1>retrieval diagnostics. 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> 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.", "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." @@ -81,14 +85,14 @@ "false": "Off" }, "fetchingSettings": "Fetching settings...", - "ipfsDaemonOffline": "The IPFS daemon is offline. Please turn it on and try again.", - "settingsUnavailable": "Settings not available. Please check your IPFS daemon is running.", + "ipfsDaemonOffline": "The Kubo daemon is offline. Please turn it on and try again.", + "settingsUnavailable": "Settings not available. Please check your Kubo daemon is running.", "settingsHaveChanged": "The settings have changed; please click <1>Reset to update the editor contents.", "errorOccured": "An error occured while saving your changes", "checkConsole": "Check the browser console for more info.", "changesSaved": "Your changes have been saved.", - "settingsWillBeUsedNextTime": "The new settings will be used next time you restart the IPFS daemon.", - "ipfsConfigDescription": "The IPFS config file is a JSON document. It is read once when the IPFS daemon is started. Save your changes, then restart the IPFS daemon to apply them.", + "settingsWillBeUsedNextTime": "The new settings will be used next time you restart the Kubo daemon.", + "ipfsConfigDescription": "The Kubo config file is a JSON document. It is read once when the Kubo daemon is started. Save your changes, then restart the Kubo daemon to apply them.", "ipfsConfigHelp": "Check the documentation for further information.", "AnalyticsToggle": { "label": "Help improve this app by sending anonymous usage data", @@ -129,7 +133,7 @@ "tour": { "step1": { "title": "Settings page", - "paragraph1": "Here you can change the settings of your Web UI and IPFS node.", + "paragraph1": "Here you can change the settings of your Web UI and Kubo node.", "paragraph2": "If you're running IPFS Desktop you'll have some specific settings for it too." }, "step2": { @@ -154,8 +158,8 @@ "paragraph1": "Enable CLI tutor mode to see shortcuts to the command-line version of common IPFS commands — helpful if you're learning to use IPFS from the terminal, or if you just need a refresher." }, "step7": { - "title": "IPFS Config", - "paragraph1": "You can change the config of your IPFS node right from Web UI!", + "title": "Kubo Config", + "paragraph1": "You can change the config of your Kubo node right from Web UI!", "paragraph2": "Don't forget to restart the daemon to apply the changes." } }, diff --git a/src/bundles/files/actions.js b/src/bundles/files/actions.js index bc59e465c..afc1ede8f 100644 --- a/src/bundles/files/actions.js +++ b/src/bundles/files/actions.js @@ -542,11 +542,11 @@ const actions = () => ({ }), /** - * Triggers provide operation for a copied CID. + * Triggers provide operation for a CID. * @param {import('multiformats/cid').CID} cid */ - doFilesCopyCidProvide: (cid) => perform('FILES_COPY_CID_PROVIDE', async (ipfs) => { - dispatchAsyncProvide(cid, ipfs, 'COPY') + doFilesCidProvide: (cid) => perform('FILES_CID_PROVIDE', async (ipfs) => { + dispatchAsyncProvide(cid, ipfs) }), doFilesShareLink: (/** @type {FileStat[]} */ files) => perform(ACTIONS.SHARE_LINK, async (ipfs, { store }) => { @@ -556,7 +556,7 @@ const actions = () => ({ const { link: shareableLink, cid } = await getShareableLink(files, publicGateway, publicSubdomainGateway, ipfs) // Trigger background provide operation with the CID from getShareableLink - dispatchAsyncProvide(cid, ipfs, 'SHARE') + dispatchAsyncProvide(cid, ipfs) return shareableLink }), diff --git a/src/bundles/files/utils.js b/src/bundles/files/utils.js index bedf575d7..38caab7fd 100644 --- a/src/bundles/files/utils.js +++ b/src/bundles/files/utils.js @@ -324,13 +324,12 @@ export const ensureMFS = (store) => { * * @param {CID|null|undefined} cid - The CID to provide * @param {IPFSService} ipfs - The IPFS service instance - * @param {string} context - Context for logging */ -export const dispatchAsyncProvide = (cid, ipfs, context) => { +export const dispatchAsyncProvide = (cid, ipfs) => { if (cid != null) { - console.debug(`[${context}] Dispatching one-time ad-hoc provide for root CID ${cid.toString()} (non-recursive) for improved performance when sharing today`) + console.debug(`Dispatching one-time ad-hoc provide for root CID ${cid.toString()} (non-recursive)`) void debouncedProvide(cid, ipfs).catch((error) => { - console.error(`[${context}] debouncedProvide failed:`, error) + console.error('debouncedProvide failed:', error) }) } } diff --git a/src/bundles/gateway.js b/src/bundles/gateway.js index 978437cbd..15426a13f 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,12 +21,22 @@ 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() }) +/** + * @param {any} value + * @returns {boolean} + */ export const checkValidHttpUrl = (value) => { let url @@ -39,6 +50,7 @@ export const checkValidHttpUrl = (value) => { /** * Check if any hashes from IMG_ARRAY can be loaded from the provided gatewayUrl + * @param {string} gatewayUrl - The gateway URL to check * @see https://github.com/ipfs/ipfs-webui/issues/1937#issuecomment-1152894211 for more info */ export const checkViaImgSrc = (gatewayUrl) => { @@ -49,12 +61,17 @@ export const checkViaImgSrc = (gatewayUrl) => { * this is more robust check than loading js, as it won't be blocked * by privacy protections present in modern browsers or in extensions such as Privacy Badger */ + // @ts-expect-error - Promise.any requires ES2021 but we're on ES2020 return Promise.any(IMG_ARRAY.map(element => { const imgUrl = new URL(`${url.protocol}//${url.hostname}/ipfs/${element.hash}?now=${Date.now()}&filename=${element.name}#x-ipfs-companion-no-redirect`) return checkImgSrcPromise(imgUrl) })) } +/** + * @param {URL} imgUrl - The image URL to check + * @returns {Promise} + */ const checkImgSrcPromise = (imgUrl) => { const imgCheckTimeout = 15000 @@ -66,6 +83,7 @@ const checkImgSrcPromise = (imgUrl) => { return true } + /** @type {NodeJS.Timeout | null} */ let timer = setTimeout(() => { if (timeout()) reject(new Error(`Image load timed out after ${imgCheckTimeout / 1000} seconds for URL: ${imgUrl}`)) }, imgCheckTimeout) const img = new Image() @@ -80,7 +98,7 @@ const checkImgSrcPromise = (imgUrl) => { resolve() } - img.src = imgUrl + img.src = imgUrl.toString() }) } @@ -131,7 +149,9 @@ export async function checkSubdomainGateway (gatewayUrl) { // avoid sending probe requests to the default gateway every time Settings page is opened return true } + /** @type {URL} */ let imgSubdomainUrl + /** @type {URL} */ let imgRedirectedPathUrl try { const gwUrl = new URL(gatewayUrl) @@ -156,6 +176,11 @@ export async function checkSubdomainGateway (gatewayUrl) { const bundle = { name: 'gateway', + /** + * @param {any} state + * @param {any} action + * @returns {any} + */ reducer: (state = init(), action) => { if (action.type === 'SET_AVAILABLE_GATEWAY') { return { ...state, availableGateway: action.payload } @@ -169,26 +194,69 @@ const bundle = { return { ...state, publicSubdomainGateway: action.payload } } + if (action.type === 'SET_IPFS_CHECK_URL') { + return { ...state, ipfsCheckUrl: action.payload } + } + return state }, + /** + * @param {string} url + * @returns {function({dispatch: Function}): any} + */ doSetAvailableGateway: url => ({ dispatch }) => dispatch({ type: 'SET_AVAILABLE_GATEWAY', payload: url }), + /** + * @param {string} address + * @returns {function({dispatch: Function}): Promise} + */ doUpdatePublicGateway: (address) => async ({ dispatch }) => { await writeSetting('ipfsPublicGateway', address) dispatch({ type: 'SET_PUBLIC_GATEWAY', payload: address }) }, + /** + * @param {string} address + * @returns {function({dispatch: Function}): Promise} + */ doUpdatePublicSubdomainGateway: (address) => async ({ dispatch }) => { await writeSetting('ipfsPublicSubdomainGateway', address) dispatch({ type: 'SET_PUBLIC_SUBDOMAIN_GATEWAY', payload: address }) }, + /** + * @param {string} url + * @returns {function({dispatch: Function}): Promise} + */ + doUpdateIpfsCheckUrl: (url) => async ({ dispatch }) => { + await writeSetting('ipfsCheckUrl', url) + dispatch({ type: 'SET_IPFS_CHECK_URL', payload: url }) + }, + + /** + * @param {any} state + * @returns {string|null} + */ selectAvailableGateway: (state) => state?.gateway?.availableGateway, + /** + * @param {any} state + * @returns {string} + */ selectPublicGateway: (state) => state?.gateway?.publicGateway, - selectPublicSubdomainGateway: (state) => state?.gateway?.publicSubdomainGateway + /** + * @param {any} state + * @returns {string} + */ + selectPublicSubdomainGateway: (state) => state?.gateway?.publicSubdomainGateway, + + /** + * @param {any} state + * @returns {string} + */ + 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/ipfs-provider.js b/src/bundles/ipfs-provider.js index 310f6a0a4..fad79b0ee 100644 --- a/src/bundles/ipfs-provider.js +++ b/src/bundles/ipfs-provider.js @@ -539,6 +539,13 @@ const bundle = { contextBridge.setContext('doUpdateIpfsApiAddress', store.doUpdateIpfsApiAddress) }, + reactIpfsReadyToBridge: createSelector( + 'selectIpfsReady', + (ipfsReady) => { + contextBridge.setContext('selectIpfsReady', ipfsReady) + } + ), + /** * Bridge ipfs instance to context bridge for use by React contexts */ 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 6dd314578..5b1915c9e 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' 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/components/button/button.css b/src/components/button/button.css index 3c6ed1ae2..d06be2c59 100644 --- a/src/components/button/button.css +++ b/src/components/button/button.css @@ -1,5 +1,7 @@ .Button { opacity: 0.9; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; } .Button:hover { @@ -11,6 +13,18 @@ box-shadow: inset 0 0 8px rgba(0,0,0,0.3); } +.Button:focus { + outline: none; +} + +/* Improve touch responsiveness */ +@media (hover: none) and (pointer: coarse) { + .Button { + min-height: 44px; + padding: 12px 16px; + } +} + .Button:disabled { opacity: 0.8; box-shadow: initial; 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/components/is-not-connected/IsNotConnected.js b/src/components/is-not-connected/is-not-connected.tsx similarity index 100% rename from src/components/is-not-connected/IsNotConnected.js rename to src/components/is-not-connected/is-not-connected.tsx diff --git a/src/components/tooltip/icon-tooltip.css b/src/components/tooltip/icon-tooltip.css new file mode 100644 index 000000000..1c33a1ac3 --- /dev/null +++ b/src/components/tooltip/icon-tooltip.css @@ -0,0 +1,8 @@ +/* Non-selectable tooltip content */ +.noselect { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + pointer-events: none; +} \ No newline at end of file diff --git a/src/components/tooltip/icon-tooltip.tsx b/src/components/tooltip/icon-tooltip.tsx new file mode 100644 index 000000000..20bcd8f75 --- /dev/null +++ b/src/components/tooltip/icon-tooltip.tsx @@ -0,0 +1,137 @@ +/** + * A simple tooltip component for icons that displays text on hover. + * Duplicates some of the code used in the Tooltip component. + * + * Example usage: + * + */ + +import React, { ReactElement, useState, useMemo } from 'react' +import './icon-tooltip.css' + +interface IconTooltipProps { + children: ReactElement + text: string + position: 'top' | 'bottom' | 'left' | 'right' + forceShow?: boolean +} + +const IconTooltip: React.FC = ({ children, text, position, forceShow = false }) => { + const [show, setShow] = useState(false) + + const onMouseOver = () => setShow(true) + const onMouseLeave = () => setShow(false) + + const tooltipStyles = useMemo(() => { + const baseStyles = { + position: 'absolute' as const, + zIndex: 1000, + wordWrap: 'break-word' as const, + width: 'max-content' as const, + maxWidth: '200px' + } + + switch (position) { + case 'top': + return { + ...baseStyles, + bottom: '100%', + left: '50%', + transform: 'translateX(-50%)', + marginBottom: '8px' + } + case 'bottom': + return { + ...baseStyles, + top: '100%', + left: '50%', + transform: 'translateX(-50%)', + marginTop: '8px' + } + case 'left': + return { + ...baseStyles, + right: '100%', + top: '50%', + transform: 'translateY(-50%)', + marginRight: '8px' + } + case 'right': + return { + ...baseStyles, + left: '100%', + top: '50%', + transform: 'translateY(-50%)', + marginLeft: '8px' + } + } + }, [position]) + + const arrowStyles = useMemo(() => { + const baseArrowStyles = { + position: 'absolute' as const, + width: '8px', + height: '8px', + zIndex: -1 + } + + switch (position) { + case 'top': + return { + ...baseArrowStyles, + top: '100%', + left: '50%', + transform: 'translate(-50%, -50%) rotate(45deg)', + borderRadius: '2px 0px 0px' + } + case 'bottom': + return { + ...baseArrowStyles, + bottom: '100%', + left: '50%', + transform: 'translate(-50%, 50%) rotate(45deg)', + borderRadius: '0px 0px 2px 0px' + } + case 'left': + return { + ...baseArrowStyles, + left: '100%', + top: '50%', + transform: 'translate(-50%, -50%) rotate(45deg)', + borderRadius: '0px 2px 0px 0px' + } + case 'right': + return { + ...baseArrowStyles, + right: '100%', + top: '50%', + transform: 'translate(50%, -50%) rotate(45deg)', + borderRadius: '0px 0px 0px 2px' + } + } + }, [position]) + + const tooltipDisplayClass = useMemo(() => (show || forceShow) ? 'db' : 'dn', [show, forceShow]) + + return ( +
+
+ {children} +
+
+
+ {text} +
+
+ ) +} + +export default IconTooltip diff --git a/src/components/unsupported-kubo-version/unsupported-kubo-version.tsx b/src/components/unsupported-kubo-version/unsupported-kubo-version.tsx new file mode 100644 index 000000000..288ac2f5c --- /dev/null +++ b/src/components/unsupported-kubo-version/unsupported-kubo-version.tsx @@ -0,0 +1,63 @@ +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import Box from '../box/Box.js' +import Button from '../button/button' +import { useIdentity } from '../../contexts/identity-context' + +interface UnsupportedKuboVersionProps { +} + +/** + * TODO: support various features and accept translation keys for each feature instead of hardcoding them only for the logs screen. + */ +const UnsupportedKuboVersion: React.FC = () => { + const { t } = useTranslation('diagnostics') + const { identity, agentVersionObject } = useIdentity() + + // If user is using IPFS-Desktop, we need to send them to the IPFS-Desktop release page. + // Otherwise, we need to send them to the Kubo release page. + // TODO: do we need to link to any other release pages? + const openReleasePage = useCallback(() => { + let url = 'https://github.com/ipfs/kubo/releases' + if (agentVersionObject?.name === 'kubo' && agentVersionObject.suffix === 'desktop') { + url = 'https://github.com/ipfs/ipfs-desktop/releases' + } + window.open(url, '_blank') + }, [agentVersionObject]) + + if (agentVersionObject == null) { + return null + } + + return ( + +
+
⚠️
+

+ {t('logs.unsupported.title')} +

+

+ {t('logs.unsupported.description', { version: identity?.agentVersion })} +

+
+

+ {t('logs.unsupported.upgradeTitle')} +

+

+ {t('logs.unsupported.upgradeDescription')} +

+
+ +
+
+
+
+ ) +} + +export default UnsupportedKuboVersion diff --git a/src/contexts/identity-context.tsx b/src/contexts/identity-context.tsx index 45acc1b0c..61682cc80 100644 --- a/src/contexts/identity-context.tsx +++ b/src/contexts/identity-context.tsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, useReducer, useEffect, useCallback, useMemo, ReactNode, useRef } from 'react' +import { type AgentVersionObject, parseAgentVersion } from '../lib/parse-agent-version' import { useBridgeContext, useBridgeSelector } from '../helpers/context-bridge' /** @@ -35,6 +36,10 @@ export interface IdentityContextValue { * Whether identity is being updated (loading, but we already have a good identity response) */ isRefreshing: boolean + /** + * The parsed agent version object + */ + agentVersionObject: AgentVersionObject | null } /** @@ -165,14 +170,20 @@ const IdentityProviderImpl: React.FC = ({ children }) => return () => {} }, [shouldPoll, ipfsConnected, state.lastSuccess, fetchIdentity]) + const agentVersionObject = useMemo(() => { + if (identityStable?.agentVersion == null) return null + return parseAgentVersion(identityStable.agentVersion) + }, [identityStable]) + 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]) + refetch: fetchIdentity, + agentVersionObject + }), [identityStable, isInitialLoading, isRefreshing, state.hasError, state.lastSuccess, fetchIdentity, agentVersionObject]) useBridgeContext('identity', contextValue) diff --git a/src/contexts/logs/README.md b/src/contexts/logs/README.md new file mode 100644 index 000000000..1cf28c21a --- /dev/null +++ b/src/contexts/logs/README.md @@ -0,0 +1,52 @@ +# Logs System + +Real-time log streaming from IPFS nodes with batch processing and persistent storage. + +## Architecture + +``` +IPFS Node → Log Stream → Batch Processor → Log Storage → Context → Log Viewer +``` + +### Components + +- **LogsScreen**: Main UI component for logs display +- **LogViewer**: Renders individual log entries in scrollable container +- **LogsContext**: Central state management using React Context +- **BatchProcessor**: Batches incoming logs (50 entries or 100ms timeout) +- **LogStorage**: IndexedDB storage with circular buffer (200k entry limit) + +## Data Flow + +1. **Streaming**: `ipfs.log.tail()` provides raw log stream +2. **Parsing**: Raw logs converted to `LogEntry` objects +3. **Batching**: Batch processor collects entries and processes in batches +4. **Storage**: Logs stored in IndexedDB, oldest entries removed when limit reached +5. **UI**: Log viewer renders entries from React state + +## Configuration + +See `LogBufferConfig` interface in `reducer.ts` for buffer configuration options. + +## Usage + +```typescript +const { + entries, + isStreaming, + startStreaming, + stopStreaming, + setLogLevelsBatch +} = useLogs() +``` + +## File Structure + +``` +src/contexts/logs/ +├── logs-context.tsx # Main context provider +├── use-batch-processor.ts # Batch processing hook +├── log-storage.ts # IndexedDB storage +├── reducer.ts # State management +└── api.ts # IPFS API calls +``` diff --git a/src/contexts/logs/api.ts b/src/contexts/logs/api.ts new file mode 100644 index 000000000..ebcdec626 --- /dev/null +++ b/src/contexts/logs/api.ts @@ -0,0 +1,129 @@ +import type { KuboRPCClient } from 'kubo-rpc-client' + +/** + * Raw log entry from Kubo RPC API (before parsing) + */ +interface RawLogEntry { + /** + * The timestamp of the log + */ + ts: string + /** + * The level of the log + */ + level: string + /** + * The subsystem of the log + */ + logger: string + /** + * The src line of code where the log was called from + */ + caller: string + /** + * The message of the log + */ + msg: string +} + +/** + * Normalized log entry data structure + */ +export interface LogEntry { + timestamp: string + level: string + subsystem: string + message: string + id?: number +} + +/** + * API Response types for type safety + */ +export interface LogLevelsResponse { + levels: Record +} + +/** + * Log subsystem data structure + */ +export interface LogSubsystem { + name: string + level: string +} + +export async function getLogLevels (ipfs: KuboRPCClient, signal?: AbortSignal): Promise { + try { + // @ts-expect-error - kubo-rpc-client is not typed correctly since https://github.com/ipfs/kubo/pull/10885 was merged. + const response = await ipfs.log.level('*', undefined, { signal }) as LogLevelsResponse + return response.levels + } catch (e) { + console.error('Failed to fetch log levels', e) + throw e + } +} + +/** + * Set a single log level + */ +async function setLogLevel (ipfs: KuboRPCClient, subsystem: string, level: string, signal?: AbortSignal): Promise { + try { + await ipfs.log.level(subsystem, level, { signal }) + } catch (e) { + console.error(`Failed to set log level for ${subsystem} to ${level}`, e) + throw e + } +} + +/** + * Set multiple log levels in batch and return the final state + */ +export async function setLogLevelsBatch (ipfs: KuboRPCClient, levels: Array<{ subsystem: string; level: string }>, signal?: AbortSignal): Promise { + try { + // Separate global level from subsystem levels + const globalLevel = levels.find(({ subsystem }) => subsystem === '*' || subsystem === '(default)') + const subsystemLevels = levels.filter(({ subsystem }) => subsystem !== '*' && subsystem !== '(default)') + + // Set global level first (if present) + if (globalLevel) { + await setLogLevel(ipfs, globalLevel.subsystem, globalLevel.level, signal) + } + + // Then set individual subsystem levels + for (const { subsystem, level } of subsystemLevels) { + await setLogLevel(ipfs, subsystem, level, signal) + } + + // Fetch the final state after all changes + return getLogLevels(ipfs, signal) + } catch (e) { + console.error('Failed to set log levels in batch', e) + throw e + } +} + +/** + * Fetch subsystem list from IPFS instance + */ +export async function fetchLogSubsystems (ipfs: KuboRPCClient, signal?: AbortSignal): Promise { + const response = await ipfs.log.ls({ signal }) + const names: string[] = Array.isArray(response) ? response : response.Strings || [] + const levels = await getLogLevels(ipfs, signal) + const subsystems = names.map(name => ({ name, level: levels[name] ?? levels['(default)'] ?? 'unknown' })) + + // Sort subsystems alphabetically by name + return subsystems.sort((a, b) => a.name.localeCompare(b.name)) +} + +/** + * Parse raw log entry into structured LogEntry + */ +export function parseLogEntry (raw: unknown): LogEntry { + const obj = raw as RawLogEntry + return { + timestamp: obj.ts, + level: obj.level, + subsystem: obj.logger, + message: obj.msg + } +} diff --git a/src/contexts/logs/index.ts b/src/contexts/logs/index.ts new file mode 100644 index 000000000..d12124f9c --- /dev/null +++ b/src/contexts/logs/index.ts @@ -0,0 +1,7 @@ +// Export the context provider and hook +export { LogsProvider, useLogs } from './logs-context' + +// Export utilities (optional, for advanced usage) +export { getLogLevels, parseLogEntry } from './api' +export { logsReducer, initLogsState } from './reducer' +export { useBatchProcessor } from './use-batch-processor' diff --git a/src/contexts/logs/log-storage.ts b/src/contexts/logs/log-storage.ts new file mode 100644 index 000000000..6d07749bb --- /dev/null +++ b/src/contexts/logs/log-storage.ts @@ -0,0 +1,318 @@ +/** + * IndexedDB-based log storage service + */ +import type { LogEntry } from './api' + +export interface LogStorageConfig { + dbName: string + storeName: string + maxEntries: number + version: number +} + +export interface LogStorageStats { + totalEntries: number + oldestTimestamp: string | null + newestTimestamp: string | null + estimatedSize: number +} + +const DEFAULT_CONFIG: LogStorageConfig = { + dbName: 'ipfs-webui-logs', + storeName: 'log-entries', + maxEntries: 10000, + version: 1 +} + +export class LogStorage { + private db: IDBDatabase | null = null + private config: LogStorageConfig + private initPromise: Promise | null = null + // sentinal to prevent multiple trimming operations + private trimmingOldEntries = false + constructor (config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config } + } + + /** + * Initialize the IndexedDB connection + */ + async init (): Promise { + if (this.initPromise) { + return this.initPromise + } + + this.initPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(this.config.dbName, this.config.version) + + request.onerror = () => { + reject(new Error(`Failed to open IndexedDB: ${request.error?.message}`)) + } + + request.onsuccess = () => { + this.db = request.result + resolve() + } + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + + // Create object store if it doesn't exist + if (!db.objectStoreNames.contains(this.config.storeName)) { + const store = db.createObjectStore(this.config.storeName, { + keyPath: 'id', + autoIncrement: true + }) + + // Create index for timestamp-based queries + store.createIndex('timestamp', 'timestamp', { unique: false }) + store.createIndex('level', 'level', { unique: false }) + store.createIndex('subsystem', 'subsystem', { unique: false }) + } + } + }) + + return this.initPromise + } + + /** + * Append new log entries with circular buffer behavior + */ + async appendLogs (entries: LogEntry[]): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + const transaction = this.db.transaction([this.config.storeName], 'readwrite') + const store = transaction.objectStore(this.config.storeName) + + const done = new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onabort = () => reject(transaction.error ?? new Error('Transaction aborted')) + transaction.onerror = () => reject(transaction.error ?? new Error('Transaction error')) + }) + + // Queue all adds without per-item awaits + for (const entry of entries) { + const entryWithId: LogEntry = { + ...entry, + timestamp: entry.timestamp ?? new Date().toISOString() + } + const req = store.add(entryWithId) + req.onerror = () => { console.error(req.error) } + } + + // shouldn't be necessary, but may help. + if (typeof transaction.commit === 'function') transaction.commit() + + await done + + // Implement circular buffer - remove old entries if we exceed maxEntries + void this.enforceMaxEntries() + } + + /** + * Get the most recent logs + */ + async getRecentLogs (limit = 500): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + const tx = this.db.transaction([this.config.storeName], 'readonly') + const store = tx.objectStore(this.config.storeName) + const index = store.index('timestamp') // use your actual index name + + // Pre-size and fill from the end so final order is oldest→newest without reverse() + const out: LogEntry[] = new Array(Math.max(0, limit)) + let write = out.length - 1 + let settled = false + + return new Promise((resolve, reject) => { + tx.onabort = () => { + if (!settled) reject(tx.error ?? new Error('Transaction aborted')) + } + tx.onerror = () => { + if (!settled) reject(tx.error ?? new Error('Transaction error')) + } + + const req = index.openCursor(undefined, 'prev') // newest first + req.onerror = () => { + // don’t continue; let tx error/abort handlers fire + } + + req.onsuccess = () => { + const cursor = req.result + if (!cursor || write < 0) { + if (!settled) { + settled = true + // slice to the filled portion (trim any leading holes if fewer than limit) + resolve(out.slice(write + 1)) + } + return + } + + out[write--] = cursor.value // Includes the auto-generated ID from IndexedDB + + // Safe to continue while we're still in onsuccess (tx is active in this task) + cursor.continue() + } + }) + } + + /** + * Clear old entries to maintain circular buffer + */ + private async enforceMaxEntries (): Promise { + if (!this.db || this.trimmingOldEntries) return + + const transaction = this.db.transaction([this.config.storeName], 'readwrite') + const store = transaction.objectStore('log-entries') + + // Count total entries + const countRequest = store.count() + + return new Promise((resolve, reject) => { + countRequest.onsuccess = () => { + const totalCount = countRequest.result + + if (totalCount <= this.config.maxEntries) { + resolve() + return + } + this.trimmingOldEntries = true + + // Delete oldest entries + const entriesToDelete = totalCount - this.config.maxEntries + const index = store.index('timestamp') + const request = index.openCursor(null, 'next') // Oldest first + + let deleted = 0 + + request.onsuccess = () => { + const cursor = request.result + if (cursor && deleted < entriesToDelete) { + cursor.delete() + deleted++ + cursor.continue() + } else { + resolve() + } + } + + request.onerror = () => reject(request.error) + } + + countRequest.onerror = () => reject(countRequest.error) + }).finally(() => { + this.trimmingOldEntries = false + }) + } + + /** + * Get storage statistics + */ + async getStorageStats (): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([this.config.storeName], 'readonly') + const store = transaction.objectStore('log-entries') + const index = store.index('timestamp') + + let totalEntries = 0 + let oldestTimestamp: string | null = null + let newestTimestamp: string | null = null + let estimatedSize = 0 + + // Count entries and get timestamps + const countRequest = store.count() + + countRequest.onsuccess = () => { + totalEntries = countRequest.result + + if (totalEntries === 0) { + resolve({ + totalEntries: 0, + oldestTimestamp: null, + newestTimestamp: null, + estimatedSize: 0 + }) + return + } + + // Get oldest timestamp + const oldestRequest = index.openCursor(null, 'next') + oldestRequest.onsuccess = () => { + const cursor = oldestRequest.result + if (cursor) { + oldestTimestamp = cursor.value.timestamp + + // Estimate size (rough calculation) + estimatedSize = totalEntries * JSON.stringify(cursor.value).length + } + + // Get newest timestamp + const newestRequest = index.openCursor(null, 'prev') + newestRequest.onsuccess = () => { + const cursor = newestRequest.result + if (cursor) { + newestTimestamp = cursor.value.timestamp + } + + resolve({ + totalEntries, + oldestTimestamp, + newestTimestamp, + estimatedSize + }) + } + newestRequest.onerror = () => reject(newestRequest.error) + } + oldestRequest.onerror = () => reject(oldestRequest.error) + } + + countRequest.onerror = () => reject(countRequest.error) + }) + } + + /** + * Clear all stored logs + */ + async clearAllLogs (): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([this.config.storeName], 'readwrite') + const store = transaction.objectStore('log-entries') + const request = store.clear() + + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) + } + + /** + * Update configuration (requires reinit) + */ + updateConfig (newConfig: Partial): void { + this.config = { ...this.config, ...newConfig } + // Reset init promise to force reinitialization + this.initPromise = null + this.db = null + } + + /** + * Close the database connection + */ + close (): void { + if (this.db) { + this.db.close() + this.db = null + this.initPromise = null + } + } +} + +// Export singleton instance +export const logStorage = new LogStorage() diff --git a/src/contexts/logs/logs-context.tsx b/src/contexts/logs/logs-context.tsx new file mode 100644 index 000000000..b55896a55 --- /dev/null +++ b/src/contexts/logs/logs-context.tsx @@ -0,0 +1,370 @@ +import React, { createContext, useContext, useReducer, useEffect, useCallback, useMemo, useRef, useState } from 'react' +import { logsReducer, initLogsState } from './reducer' +import { parseLogEntry, getLogLevels, setLogLevelsBatch as setLogLevelsBatchApi } from './api' +import { useBatchProcessor } from './use-batch-processor' +import { logStorage } from './log-storage' +import { useBridgeSelector } from '../../helpers/context-bridge' +import { calculateGologLevelString } from '../../lib/golog-level-utils' +import type { KuboRPCClient } from 'kubo-rpc-client' +import type { LogEntry } from './api' +import type { LogBufferConfig, LogRateState } from './reducer' +import type { LogStorageStats } from './log-storage' +import { useAgentVersionMinimum } from '../../lib/hooks/use-agent-version-minimum' + +interface LogsProviderProps { + children: React.ReactNode + ipfs?: KuboRPCClient + ipfsConnected?: boolean +} + +/** + * Logs context value + */ +export interface LogsContextValue { + // Log entries and streaming + entries: LogEntry[] + isStreaming: boolean + + // Log levels + subsystemLevels: Record + actualLogLevels: Record + isLoadingLevels: boolean + + // Configuration and monitoring + bufferConfig: LogBufferConfig + rateState: LogRateState + storageStats: LogStorageStats | null + + // Computed values + gologLevelString: string | null + subsystems: Array<{ name: string; level: string }> + isAgentVersionSupported: boolean + + // Actions + startStreaming: () => void + stopStreaming: () => void + clearEntries: () => void + setLogLevelsBatch: (levels: Array<{ subsystem: string; level: string }>) => Promise + updateBufferConfig: (config: Partial) => void + + fetchLogLevels: () => void + updateStorageStats: () => void + showWarning: () => void +} + +/** + * Logs context + */ +const LogsContext = createContext(undefined) +LogsContext.displayName = 'LogsContext' + +/** + * Streamlined logs provider component with performance optimizations + */ +export const LogsProvider: React.FC = ({ children }) => { + // Use lazy initialization to avoid recreating deep objects on every render + const [state, dispatch] = useReducer(logsReducer, undefined, initLogsState) + const [bootstrapped, setBootstrapped] = useState(false) + const streamControllerRef = useRef(null) + const ipfs = useBridgeSelector('selectIpfs') as KuboRPCClient + const ipfsConnected = useBridgeSelector('selectIpfsConnected') as boolean + + /** + * Kubo only adds support for getting log levels in version 0.37.0 and later. + * + * Kubo fixed log tailing in version 0.36.0 and later. + * @see https://github.com/ipfs/kubo/issues/10867 + */ + const { ok: isAgentVersionSupported } = useAgentVersionMinimum({ + minimumVersion: '0.37.0', + requiredAgent: 'kubo' + }) + + // Use ref for mount status to avoid stale closures + const isMounted = useRef(true) + const addBatch = useCallback(async (entryCount: number) => { + // we always pull from storage to ensure we have the properly set logEntry with id + const entries = await logStorage.getRecentLogs(entryCount) + dispatch({ type: 'ADD_BATCH', entries }) + }, [dispatch]) + const onRateUpdate = useCallback((currentRate: number, recentCounts: Array<{ second: number; count: number }>, stats?: any) => { + dispatch({ + type: 'UPDATE_RATE_STATE', + rateState: { currentRate, recentCounts } + }) + + // Update storage stats if provided + if (stats != null) { + dispatch({ type: 'UPDATE_STORAGE_STATS', stats }) + } + }, []) + const onAutoDisable = useCallback(() => { + dispatch({ type: 'AUTO_DISABLE' }) + streamControllerRef.current?.abort() + }, []) + + // Use the improved batch processor hook + const batchProcessor = useBatchProcessor( + addBatch, + state.bufferConfig, + logStorage, + onRateUpdate, + onAutoDisable + ) + + const fetchLogLevelsInternal = useCallback(async () => { + if (!ipfsConnected || !ipfs) return + const controller = new AbortController() + + try { + dispatch({ type: 'FETCH_LEVELS' }) + const logLevels = await getLogLevels(ipfs, controller.signal) + dispatch({ type: 'UPDATE_LEVELS', levels: logLevels }) + } catch (error: unknown) { + console.error('Failed to fetch log levels:', error) + dispatch({ type: 'UPDATE_LEVELS', levels: {} }) + } + return () => { + controller.abort() + } + }, [ipfs, ipfsConnected]) + + const setLogLevelsBatch = useCallback(async (levels: Array<{ subsystem: string; level: string }>) => { + if (!ipfsConnected || !ipfs) return + + try { + // Set all levels in batch and get the final state + const finalLevels = await setLogLevelsBatchApi(ipfs, levels) + + // Update the state with the final levels from the API response + dispatch({ type: 'UPDATE_LEVELS', levels: finalLevels }) + } catch (error) { + console.error('Failed to set log levels in batch:', error) + throw error + } + }, [ipfs, ipfsConnected]) + + const processStream = useCallback(async (stream: AsyncIterable) => { + try { + for await (const entry of stream) { + if (streamControllerRef.current?.signal.aborted) break + const logEntry = parseLogEntry(entry) + if (logEntry) { + batchProcessor.addEntry(logEntry) + } + } + } catch (error) { + if (!streamControllerRef.current?.signal.aborted) { + console.error('Log streaming error:', error) + streamControllerRef.current?.abort() + dispatch({ type: 'STOP_STREAMING' }) + } + } + }, [batchProcessor, dispatch]) + + const stopStreaming = useCallback(() => { + dispatch({ type: 'STOP_STREAMING' }) + streamControllerRef.current?.abort() + batchProcessor.stop() + }, [batchProcessor, dispatch]) + + const startStreaming = useCallback(async () => { + if (!ipfsConnected || !ipfs) { + console.error('IPFS instance not available') + return + } + + const controller = new AbortController() + streamControllerRef.current = controller + dispatch({ type: 'START_STREAMING' }) + batchProcessor.start(streamControllerRef) + + try { + await logStorage.init() + + // Load recent logs from storage + try { + const recentLogs = await logStorage.getRecentLogs(state.bufferConfig.memory) + if (recentLogs.length > 0) { + dispatch({ type: 'ADD_BATCH', entries: recentLogs }) + } + + const stats = await logStorage.getStorageStats() + dispatch({ type: 'UPDATE_STORAGE_STATS', stats }) + } catch (error) { + console.warn('Failed to load recent logs from storage:', error) + } + + const stream = ipfs.log.tail({ signal: controller.signal }) + + if (stream && typeof stream[Symbol.asyncIterator] === 'function') { + void processStream(stream) + } else { + throw new Error('Log streaming not supported') + } + } catch (error) { + console.error('Failed to start log streaming:', error) + stopStreaming() + } + }, [ipfsConnected, ipfs, batchProcessor, state.bufferConfig.memory, processStream, stopStreaming]) + + const clearEntries = useCallback(async () => { + try { + await logStorage.clearAllLogs() + } catch (error) { + console.warn('Failed to clear IndexedDB logs:', error) + } + dispatch({ type: 'CLEAR_ENTRIES' }) + }, []) + + const updateBufferConfig = useCallback((config: Partial) => { + dispatch({ type: 'UPDATE_BUFFER_CONFIG', config }) + if (config.indexedDB != null) { + logStorage.updateConfig({ maxEntries: config.indexedDB }) + } + }, []) + + const updateStorageStatsInternal = useCallback(async () => { + try { + const stats = await logStorage.getStorageStats() + dispatch({ type: 'UPDATE_STORAGE_STATS', stats }) + } catch (error) { + console.error('Failed to update storage stats:', error) + } + }, []) + + const loadExistingEntries = useCallback(async () => { + try { + await logStorage.init() + const recentLogs = await logStorage.getRecentLogs(state.bufferConfig.memory) + if (recentLogs.length > 0) { + dispatch({ type: 'ADD_BATCH', entries: recentLogs }) + } + } catch (error) { + console.warn('Failed to load existing log entries:', error) + } + }, [state.bufferConfig.memory]) + + const showWarning = useCallback(() => { + dispatch({ type: 'SHOW_WARNING' }) + }, []) + + // Compute GOLOG_LOG_LEVEL equivalent string + const gologLevelString = useMemo(() => { + // Only calculate if log levels have been loaded + if (state.isLoadingLevels || Object.keys(state.actualLogLevels).length === 0) { + return null + } + + return calculateGologLevelString(state.actualLogLevels) + }, [state.isLoadingLevels, state.actualLogLevels]) + + // Compute subsystems list from actual log levels + const subsystems = useMemo(() => { + return Object.entries(state.actualLogLevels) + .filter(([name]) => name !== '(default)') + .map(([name, level]) => ({ + name, + level: level || 'info' + })) + .sort((a, b) => a.name.localeCompare(b.name)) + }, [state.actualLogLevels]) + + useEffect(() => { + // Update storage config with current buffer settings + logStorage.updateConfig({ maxEntries: state.bufferConfig.indexedDB }) + }, [state.bufferConfig.indexedDB]) + + // Cleanup effect - stops streaming on unmount + useEffect(() => { + isMounted.current = true + return () => { + stopStreaming() + isMounted.current = false + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Initialize log storage and bootstrap data + useEffect(() => { + if (bootstrapped || !isAgentVersionSupported) return + + async function bootstrap () { + if (!isMounted.current) return + + try { + await logStorage.init() + } catch (error) { + console.warn('Failed to initialize log storage:', error) + } + + if (!isMounted.current) return + + // Load initial data in parallel when IPFS is connected + if (ipfsConnected && ipfs) { + await Promise.allSettled([ + fetchLogLevelsInternal(), + updateStorageStatsInternal(), + loadExistingEntries() + ]).then(() => { + setBootstrapped(true) + }) + } + } + + bootstrap() + }, [ipfsConnected, ipfs, fetchLogLevelsInternal, updateStorageStatsInternal, loadExistingEntries, bootstrapped, isAgentVersionSupported]) + + // Group related actions for cleaner context value assembly + const logActions = useMemo(() => ({ + startStreaming, + stopStreaming, + clearEntries, + setLogLevelsBatch, + updateBufferConfig, + fetchLogLevels: fetchLogLevelsInternal, + updateStorageStats: updateStorageStatsInternal, + loadExistingEntries, + showWarning + }), [ + startStreaming, + stopStreaming, + clearEntries, + setLogLevelsBatch, + updateBufferConfig, + fetchLogLevelsInternal, + updateStorageStatsInternal, + loadExistingEntries, + showWarning + ]) + + // Ensure we have safe defaults for arrays + const safeLogEntries = useMemo(() => Array.from(state.entries.values()), [state.entries]) + + // Combine state, computed values, and actions - React will optimize this automatically + const contextValue: LogsContextValue = { + ...state, + ...logActions, + entries: safeLogEntries, + gologLevelString, + subsystems, + isAgentVersionSupported + } + + return ( + + {children} + + ) +} + +/** + * Hook to consume the logs context + */ +export function useLogs (): LogsContextValue { + const context = useContext(LogsContext) + if (context === undefined) { + throw new Error('useLogs must be used within a LogsProvider') + } + return context +} diff --git a/src/contexts/logs/reducer.ts b/src/contexts/logs/reducer.ts new file mode 100644 index 000000000..bae12e639 --- /dev/null +++ b/src/contexts/logs/reducer.ts @@ -0,0 +1,242 @@ +import type { LogEntry } from './api' +import type { LogStorageStats } from './log-storage' + +/** + * Log buffer configuration + */ +export interface LogBufferConfig { + memory: number + indexedDB: number + warnThreshold: number + autoDisableThreshold: number +} + +/** + * Log rate state for monitoring + */ +export interface LogRateState { + currentRate: number + recentCounts: Array<{ second: number; count: number }> + lastCountTime: number + hasWarned: boolean + autoDisabled: boolean +} + +/** + * Logs state for the reducer + */ +export interface LogsState { + entries: Map + isStreaming: boolean + bufferConfig: LogBufferConfig + rateState: LogRateState + storageStats: LogStorageStats | null + batchTimeout: number | null + subsystemLevels: Record + actualLogLevels: Record + isLoadingLevels: boolean +} + +/** + * Actions for the logs reducer + */ +export type LogsAction = + | { type: 'START_STREAMING' } + | { type: 'STOP_STREAMING' } + /** + * Be sure to only provide entries sorted by timestamp (oldest first), otherwise the resulting log entries will be out of order. + */ + | { type: 'ADD_BATCH'; entries: LogEntry[] } + | { type: 'CLEAR_ENTRIES' } + | { type: 'UPDATE_BUFFER_CONFIG'; config: Partial } + | { type: 'UPDATE_RATE_STATE'; rateState: Partial } + | { type: 'UPDATE_STORAGE_STATS'; stats: LogStorageStats } + | { type: 'SHOW_WARNING' } + | { type: 'AUTO_DISABLE' } + | { type: 'RESET_WARNING' } + | { type: 'FETCH_LEVELS' } + | { type: 'UPDATE_LEVELS'; levels: Record } + +/** + * Default buffer configuration + */ +export const DEFAULT_BUFFER_CONFIG: LogBufferConfig = { + memory: 1_000, // Keep last 1k entries in memory and rendered in the UI + indexedDB: 200_000, // Store up to 200k entries in IndexedDB + warnThreshold: 100, + autoDisableThreshold: 500 +} + +/** + * Default rate state + */ +export const DEFAULT_RATE_STATE: LogRateState = { + currentRate: 0, + recentCounts: [], + lastCountTime: Date.now(), + hasWarned: false, + autoDisabled: false +} + +/** + * Initial empty state shell - will be populated by initLogsState + */ +const initialStateShell: Partial = { + entries: new Map(), + isStreaming: false, + storageStats: null, + batchTimeout: null, + subsystemLevels: {}, + actualLogLevels: {}, + isLoadingLevels: false +} + +function trimMapToLastN (m: Map, n: number) { + if (m.size <= n) return + let toDrop = m.size - n + const it = m.keys() + while (toDrop-- > 0) { + const { value, done } = it.next() + if (done) break + m.delete(value) + } +} + +/** + * Lazy initialization function to avoid recreating deep objects on every render + */ +export function initLogsState (): LogsState { + return { + ...initialStateShell, + bufferConfig: { ...DEFAULT_BUFFER_CONFIG }, + rateState: { ...DEFAULT_RATE_STATE } + } as LogsState +} + +/** + * Logs reducer with immutable state updates + */ +export function logsReducer (state: LogsState, action: LogsAction): LogsState { + switch (action.type) { + case 'START_STREAMING': { + return { + ...state, + isStreaming: true, + rateState: { + ...state.rateState, + autoDisabled: false + // Don't reset hasWarned - let it persist across streaming sessions + } + } + } + + case 'STOP_STREAMING': { + // Clear batch timeout + if (state.batchTimeout) { + clearTimeout(state.batchTimeout) + } + return { + ...state, + isStreaming: false, + batchTimeout: null + } + } + + case 'ADD_BATCH': { + const { entries: prevEntries, bufferConfig } = state + const memoryLimit = bufferConfig.memory + + const entries = new Map(prevEntries) + + // Append only new ids, preserving batch order (expecting timestamp sorted entries) + for (const e of action.entries) { + const id = e.id! + if (!entries.has(id)) { + entries.set(id, e) + } + } + + // Keep only last N by arrival/time order (oldest are at the front) + trimMapToLastN(entries, memoryLimit) + + return { ...state, entries, batchTimeout: null } + } + + case 'CLEAR_ENTRIES': { + return { + ...state, + entries: new Map() + } + } + + case 'UPDATE_BUFFER_CONFIG': { + return { + ...state, + bufferConfig: { ...state.bufferConfig, ...action.config } + } + } + + case 'UPDATE_RATE_STATE': { + return { + ...state, + rateState: { ...state.rateState, ...action.rateState } + } + } + + case 'UPDATE_STORAGE_STATS': { + return { + ...state, + storageStats: action.stats + } + } + + case 'SHOW_WARNING': { + return { + ...state, + rateState: { + ...state.rateState, + hasWarned: true + } + } + } + + case 'AUTO_DISABLE': { + return { + ...state, + isStreaming: false, + rateState: { + ...state.rateState, + autoDisabled: true + } + } + } + + case 'RESET_WARNING': { + return { + ...state, + rateState: { + ...state.rateState, + hasWarned: false + } + } + } + + case 'FETCH_LEVELS': { + return { + ...state, + isLoadingLevels: true + } + } + + case 'UPDATE_LEVELS': { + return { + ...state, + actualLogLevels: action.levels, + isLoadingLevels: false + } + } + + default: + return state + } +} diff --git a/src/contexts/logs/use-batch-processor.ts b/src/contexts/logs/use-batch-processor.ts new file mode 100644 index 000000000..0160b8ce7 --- /dev/null +++ b/src/contexts/logs/use-batch-processor.ts @@ -0,0 +1,170 @@ +import { useRef, useCallback, useEffect } from 'react' +import { type LogStorage } from './log-storage' +import type { LogEntry } from './api' +import type { LogBufferConfig } from './reducer' +import type { MutableRefObject } from 'react' + +/** + * Batch processor interface + */ +export interface BatchProcessor { + start: (controller: MutableRefObject) => void + stop: () => void + addEntry: (entry: LogEntry) => void +} + +/** + * Custom hook for batch processing logs with rate monitoring + * Uses refs to maintain mutable state without recreating on every render + */ +export function useBatchProcessor ( + onBatch: (entryCount: number) => void, + bufferConfig: LogBufferConfig, + logStorage: LogStorage, + onRateUpdate?: (rate: number, counts: Array<{ second: number; count: number }>, stats?: any) => void, + onAutoDisable?: () => void +): BatchProcessor { + // Use refs to hold mutable state that doesn't trigger re-renders + const controllerRef = useRef(null) + const pendingEntriesRef = useRef([]) + const lastBatchTimeRef = useRef(Date.now()) + const entryCountsRef = useRef>([]) + const batchTimeoutRef = useRef(null) + const autoDisabledRef = useRef(false) + + const processBatch = useCallback(async () => { + if (pendingEntriesRef.current.length === 0) return + + const entries = [...pendingEntriesRef.current] + pendingEntriesRef.current = [] + batchTimeoutRef.current = null + + // Update rate monitoring + const now = Date.now() + const currentSecond = Math.floor(now / 1000) + + // Clean old counts (keep last 5 seconds for rate calculation) + entryCountsRef.current = entryCountsRef.current.filter( + ({ second }) => currentSecond - second < 5 + ) + + // Add current batch count + const existingCount = entryCountsRef.current.find( + ({ second }) => second === currentSecond + ) + if (existingCount) { + existingCount.count += entries.length + } else { + entryCountsRef.current.push({ second: currentSecond, count: entries.length }) + } + + // Calculate current rate (entries per second over last 5 seconds) + const totalEntries = entryCountsRef.current.reduce((sum, { count }) => sum + count, 0) + const currentRate = totalEntries / Math.max(entryCountsRef.current.length, 1) + + // Update rate state via callback + if (onRateUpdate) { + onRateUpdate(currentRate, [...entryCountsRef.current]) + } + + // Check for warnings and auto-disable + if (currentRate > bufferConfig.autoDisableThreshold && !autoDisabledRef.current) { + console.warn(`Log rate too high (${currentRate.toFixed(1)}/s), auto-disabling streaming`) + autoDisabledRef.current = true + if (onAutoDisable) { + onAutoDisable() + } + } + + // Store in IndexedDB (async, don't wait) - append new entries and remove old ones if needed + try { + // Always append new entries - the storage layer will handle circular buffer behavior + void logStorage.appendLogs(entries) + + // Update storage stats after adding entries + try { + const updatedStats = await logStorage.getStorageStats() + if (onRateUpdate) { + // Pass updated stats via the rate update callback + onRateUpdate(currentRate, [...entryCountsRef.current], updatedStats) + } + } catch (error) { + console.warn('Failed to update storage stats:', error) + } + } catch (error) { + console.warn('Failed to store logs in IndexedDB:', error) + } + + // Call the batch callback + onBatch(entries.length) + lastBatchTimeRef.current = now + }, [onBatch, bufferConfig, onRateUpdate, onAutoDisable, logStorage]) + + const start = useCallback((controller: MutableRefObject) => { + controllerRef.current = controller.current + // Reset rate counters when starting + autoDisabledRef.current = false + entryCountsRef.current = [] + lastBatchTimeRef.current = Date.now() + }, []) + + const stop = useCallback(() => { + // Clear any pending timeout + if (batchTimeoutRef.current) { + clearTimeout(batchTimeoutRef.current) + batchTimeoutRef.current = null + } + + controllerRef.current?.abort() + + // Clear the controller reference + controllerRef.current = null + + // Process any remaining entries before stopping + if (pendingEntriesRef.current.length > 0) { + setTimeout(processBatch, 10) + } + }, [processBatch]) + + const addEntry = useCallback((entry: LogEntry) => { + if (!controllerRef.current || controllerRef.current.signal.aborted) return + + pendingEntriesRef.current.push(entry) + + // Process batch if we have enough entries or enough time has passed + const shouldProcess = + pendingEntriesRef.current.length >= 50 || // Batch size threshold + (Date.now() - lastBatchTimeRef.current) >= 100 // Time threshold (100ms) + + // Clear any existing timeout before setting a new one + if (batchTimeoutRef.current) { + clearTimeout(batchTimeoutRef.current) + batchTimeoutRef.current = null + } + + if (shouldProcess) { + batchTimeoutRef.current = window.setTimeout(processBatch, 100) // Process every 100ms + } else { + // Set a fallback timeout to ensure batches are processed even with low rates + batchTimeoutRef.current = window.setTimeout(processBatch, 1000) + } + }, [processBatch]) + + // Set up cleanup when controller changes + useEffect(() => { + const controller = controllerRef.current + if (!controller) return + + const cleanup = () => { + if (batchTimeoutRef.current) { + clearTimeout(batchTimeoutRef.current) + batchTimeoutRef.current = null + } + } + + controller.signal.onabort = cleanup + return cleanup + }, []) + + return { start, stop, addEntry } +} diff --git a/src/diagnostics/check-screen/check-screen.tsx b/src/diagnostics/check-screen/check-screen.tsx new file mode 100644 index 000000000..9f42353c2 --- /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?.contentWindow) return + iframe.contentWindow.postMessage({ type: 'iframe-size:request' }, ipfsCheckOrigin) + }, 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...

+
+ )} +