diff --git a/.deps/EXCLUDED/dev.md b/.deps/EXCLUDED/dev.md index 237c38b161..b283cf8e7f 100755 --- a/.deps/EXCLUDED/dev.md +++ b/.deps/EXCLUDED/dev.md @@ -3,37 +3,49 @@ This file contains a manual contribution to .deps/dev.md and it's needed because | Packages | Resolved CQs | | --- | --- | | `@eclipse-che/license-tool@2.0.0` | ecd.che | +| `tar@7.5.11` | #25796 | +| `tar@7.5.7` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/tar/7.5.7) | +| `@commitlint/cli@21.2.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/cli/21.2.1) | +| `@commitlint/config-conventional@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/config-conventional/21.2.0) | +| `@commitlint/config-validator@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/config-validator/21.2.0) | +| `@commitlint/ensure@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/ensure/21.2.0) | +| `@commitlint/format@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/format/21.2.0) | +| `@commitlint/is-ignored@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/is-ignored/21.2.0) | +| `@commitlint/lint@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/lint/21.2.0) | +| `@commitlint/load@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/load/21.2.0) | +| `@commitlint/message@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/message/21.2.0) | +| `@commitlint/parse@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/parse/21.2.0) | +| `@commitlint/read@21.2.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/read/21.2.1) | +| `@commitlint/resolve-extends@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/resolve-extends/21.2.0) | +| `@commitlint/rules@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/rules/21.2.0) | +| `@commitlint/top-level@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/top-level/21.2.0) | +| `@commitlint/types@21.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/types/21.2.0) | +| `@conventional-changelog/git-client@3.1.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@conventional-changelog/git-client/3.1.0) | +| `@conventional-changelog/template@1.2.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@conventional-changelog/template/1.2.1) | +| `@simple-libs/child-process-utils@2.0.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@simple-libs/child-process-utils/2.0.0) | +| `@simple-libs/stream-utils@2.0.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@simple-libs/stream-utils/2.0.0) | +| `argue-cli@3.1.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/argue-cli/3.1.0) | +| `conventional-changelog-angular@9.2.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/conventional-changelog-angular/9.2.1) | +| `conventional-changelog-conventionalcommits@10.2.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/conventional-changelog-conventionalcommits/10.2.1) | +| `conventional-commits-parser@7.1.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/conventional-commits-parser/7.1.0) | +| `cosmiconfig-typescript-loader@6.3.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/cosmiconfig-typescript-loader/6.3.0) | +| `es-toolkit@1.49.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/es-toolkit/1.49.0) | | `jsbn@0.1.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/jsbn/0.1.1) | -| `brace-expansion@1.1.15` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/brace-expansion/1.1.15) | -| `@commitlint/cli@21.0.2` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/cli/21.0.2) | | `@commitlint/config-conventional@21.0.2` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/config-conventional/21.0.2) | | `@commitlint/config-validator@21.0.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/config-validator/21.0.1) | -| `@commitlint/ensure@21.0.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/ensure/21.0.1) | -| `@commitlint/execute-rule@21.0.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/execute-rule/21.0.1) | -| `@commitlint/format@21.0.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/format/21.0.1) | -| `@commitlint/is-ignored@21.0.2` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/is-ignored/21.0.2) | -| `@commitlint/lint@21.0.2` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/lint/21.0.2) | | `@commitlint/load@21.0.2` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/load/21.0.2) | -| `@commitlint/message@21.0.2` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/message/21.0.2) | -| `@commitlint/parse@21.0.2` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/parse/21.0.2) | -| `@commitlint/read@21.0.2` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/read/21.0.2) | -| `@commitlint/resolve-extends@21.0.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/resolve-extends/21.0.1) | -| `@commitlint/rules@21.0.2` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/rules/21.0.2) | -| `@commitlint/to-lines@21.0.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/to-lines/21.0.1) | -| `@commitlint/top-level@21.0.2` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/top-level/21.0.2) | -| `@commitlint/types@21.0.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/types/21.0.1) | | `@conventional-changelog/git-client@2.7.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@conventional-changelog/git-client/2.7.0) | -| `@simple-libs/child-process-utils@1.0.2` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@simple-libs/child-process-utils/1.0.2) | | `@simple-libs/stream-utils@1.2.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@simple-libs/stream-utils/1.2.0) | +| `@types/react@18.3.31` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@types/react/18.3.31) | +| `brace-expansion@1.1.15` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/brace-expansion/1.1.15) | | `conventional-changelog-angular@8.3.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/conventional-changelog-angular/8.3.1) | | `conventional-changelog-conventionalcommits@9.3.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/conventional-changelog-conventionalcommits/9.3.1) | | `conventional-commits-parser@6.4.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/conventional-commits-parser/6.4.0) | -| `cosmiconfig@9.0.2` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/cosmiconfig/9.0.2) | -| `cosmiconfig-typescript-loader@6.3.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/cosmiconfig-typescript-loader/6.3.0) | | `es-toolkit@1.47.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/es-toolkit/1.47.0) | -| `git-raw-commits@5.0.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/git-raw-commits/5.0.1) | -| `pidtree@0.6.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/pidtree/0.6.1) | -| `semver@7.8.3` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/semver/7.8.3) | -| `tinyexec@1.2.4` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/tinyexec/1.2.4) | + + + + + diff --git a/.deps/EXCLUDED/prod.md b/.deps/EXCLUDED/prod.md index fd619d1bf3..38b2efde27 100755 --- a/.deps/EXCLUDED/prod.md +++ b/.deps/EXCLUDED/prod.md @@ -13,5 +13,9 @@ This file lists dependencies that do not need CQs or auto-detection does not wor | `launder@1.7.1` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/launder/1.7.1) | | `brace-expansion@5.0.7` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/brace-expansion/5.0.7) | | `js-yaml@4.3.0` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/js-yaml/4.3.0) | +| `@eclipse-che/common@workspace:packages/common` | ecd.che | +| `sanitize-html@2.17.6` | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/sanitize-html/2.17.6) | + + diff --git a/.deps/dev.md b/.deps/dev.md index 3c19b0ef8f..0ccd2f812d 100644 --- a/.deps/dev.md +++ b/.deps/dev.md @@ -58,23 +58,23 @@ | `@babel/types@7.27.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@babel/types/7.27.0) | | `@babel/types@7.28.5` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@babel/types/7.28.5) | | `@bcoe/v8-coverage@0.2.3` | ISC AND MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@bcoe/v8-coverage/0.2.3) | -| `@commitlint/cli@21.0.2` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/cli/21.0.2) | +| `@commitlint/cli@21.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/cli/21.0.2) | | `@commitlint/config-conventional@21.0.2` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/config-conventional/21.0.2) | | `@commitlint/config-validator@21.0.1` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/config-validator/21.0.1) | -| `@commitlint/ensure@21.0.1` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/ensure/21.0.1) | -| `@commitlint/execute-rule@21.0.1` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/execute-rule/21.0.1) | -| `@commitlint/format@21.0.1` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/format/21.0.1) | -| `@commitlint/is-ignored@21.0.2` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/is-ignored/21.0.2) | -| `@commitlint/lint@21.0.2` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/lint/21.0.2) | +| `@commitlint/ensure@21.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/ensure/21.0.1) | +| `@commitlint/execute-rule@21.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/execute-rule/21.0.1) | +| `@commitlint/format@21.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/format/21.0.1) | +| `@commitlint/is-ignored@21.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/is-ignored/21.0.2) | +| `@commitlint/lint@21.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/lint/21.0.2) | | `@commitlint/load@21.0.2` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/load/21.0.2) | -| `@commitlint/message@21.0.2` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/message/21.0.2) | -| `@commitlint/parse@21.0.2` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/parse/21.0.2) | -| `@commitlint/read@21.0.2` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/read/21.0.2) | -| `@commitlint/resolve-extends@21.0.1` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/resolve-extends/21.0.1) | -| `@commitlint/rules@21.0.2` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/rules/21.0.2) | -| `@commitlint/to-lines@21.0.1` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/to-lines/21.0.1) | -| `@commitlint/top-level@21.0.2` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/top-level/21.0.2) | -| `@commitlint/types@21.0.1` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/types/21.0.1) | +| `@commitlint/message@21.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/message/21.0.2) | +| `@commitlint/parse@21.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/parse/21.0.2) | +| `@commitlint/read@21.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/read/21.0.2) | +| `@commitlint/resolve-extends@21.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/resolve-extends/21.0.1) | +| `@commitlint/rules@21.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/rules/21.0.2) | +| `@commitlint/to-lines@21.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/to-lines/21.0.1) | +| `@commitlint/top-level@21.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/top-level/21.0.2) | +| `@commitlint/types@21.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@commitlint/types/21.0.1) | | `@conventional-changelog/git-client@2.7.0` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@conventional-changelog/git-client/2.7.0) | | `@csstools/css-parser-algorithms@3.0.4` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@csstools/css-parser-algorithms/3.0.4) | | `@csstools/css-tokenizer@3.0.3` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@csstools/css-tokenizer/3.0.3) | @@ -147,7 +147,7 @@ | `@pkgr/utils@2.4.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@pkgr/utils/2.4.2) | | `@polka/url@1.0.0-next.23` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@polka/url/1.0.0-next.23) | | `@react-mock/state@0.1.8` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@react-mock/state/0.1.8) | -| `@simple-libs/child-process-utils@1.0.2` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@simple-libs/child-process-utils/1.0.2) | +| `@simple-libs/child-process-utils@1.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@simple-libs/child-process-utils/1.0.2) | | `@simple-libs/stream-utils@1.2.0` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@simple-libs/stream-utils/1.2.0) | | `@sinclair/typebox@0.27.8` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@sinclair/typebox/0.27.8) | | `@sinclair/typebox@0.34.41` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@sinclair/typebox/0.34.41) | @@ -202,6 +202,7 @@ | `@types/react-router@5.1.20` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@types/react-router/5.1.20) | | `@types/react-test-renderer@18.3.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@types/react-test-renderer/18.3.0) | | `@types/react@18.2.36` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@types/react/18.2.36) | +| `@types/react@18.3.31` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@types/react/18.3.31) | | `@types/redux-logger@3.0.13` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@types/redux-logger/3.0.13) | | `@types/redux-mock-store@1.0.6` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@types/redux-mock-store/1.0.6) | | `@types/request@2.48.12` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@types/request/2.48.12) | @@ -415,7 +416,7 @@ | `core-util-is@1.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/core-util-is/1.0.2) | | `cosmiconfig-typescript-loader@6.3.0` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/cosmiconfig-typescript-loader/6.3.0) | | `cosmiconfig@9.0.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/cosmiconfig/9.0.0) | -| `cosmiconfig@9.0.2` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/cosmiconfig/9.0.2) | +| `cosmiconfig@9.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/cosmiconfig/9.0.2) | | `css-declaration-sorter@6.4.1` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/css-declaration-sorter/6.4.1) | | `css-functions-list@3.2.3` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/css-functions-list/3.2.3) | | `css-loader@6.11.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/css-loader/6.11.0) | @@ -432,6 +433,7 @@ | `cssom@0.5.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/cssom/0.5.0) | | `cssstyle@2.3.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/cssstyle/2.3.0) | | `csstype@3.1.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/csstype/3.1.2) | +| `csstype@3.2.3` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/csstype/3.2.3) | | `dashdash@1.14.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/dashdash/1.14.1) | | `data-urls@3.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/data-urls/3.0.2) | | `debounce@1.2.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/debounce/1.2.1) | @@ -576,7 +578,7 @@ | `get-stream@8.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/get-stream/8.0.1) | | `get-symbol-description@1.0.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/get-symbol-description/1.0.0) | | `getpass@0.1.7` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/getpass/0.1.7) | -| `git-raw-commits@5.0.1` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/git-raw-commits/5.0.1) | +| `git-raw-commits@5.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/git-raw-commits/5.0.1) | | `glob-parent@6.0.2` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/glob-parent/6.0.2) | | `glob-to-regexp@0.4.1` | BSD-2-Clause | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/glob-to-regexp/0.4.1) | | `global-directory@5.0.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/global-directory/5.0.0) | @@ -881,7 +883,7 @@ | `performance-now@2.1.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/performance-now/2.1.0) | | `picocolors@1.0.0` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/picocolors/1.0.0) | | `picocolors@1.1.1` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/picocolors/1.1.1) | -| `pidtree@0.6.1` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/pidtree/0.6.1) | +| `pidtree@0.6.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/pidtree/0.6.1) | | `pify@3.0.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/pify/3.0.0) | | `pify@4.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/pify/4.0.1) | | `pirates@4.0.7` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/pirates/4.0.7) | @@ -993,7 +995,7 @@ | `semver@7.5.4` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/semver/7.5.4) | | `semver@7.7.3` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/semver/7.7.3) | | `semver@7.7.4` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/semver/7.7.4) | -| `semver@7.8.3` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/semver/7.8.3) | +| `semver@7.8.3` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/semver/7.8.3) | | `serialize-javascript@6.0.2` | BSD-3-Clause | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/serialize-javascript/6.0.2) | | `set-function-length@1.1.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/set-function-length/1.1.1) | | `set-function-name@2.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/set-function-name/2.0.1) | @@ -1073,7 +1075,7 @@ | `test-exclude@6.0.0` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/test-exclude/6.0.0) | | `text-decoder@1.2.3` | Apache-2.0 | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/text-decoder/1.2.3) | | `text-table@0.2.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/text-table/0.2.0) | -| `tinyexec@1.2.4` | | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/tinyexec/1.2.4) | +| `tinyexec@1.2.4` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/tinyexec/1.2.4) | | `titleize@3.0.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/titleize/3.0.0) | | `tmpl@1.0.5` | BSD-3-Clause | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/tmpl/1.0.5) | | `to-regex-range@5.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/to-regex-range/5.0.1) | diff --git a/.deps/prod.md b/.deps/prod.md index 5b91b18bf7..403a729206 100644 --- a/.deps/prod.md +++ b/.deps/prod.md @@ -13,7 +13,7 @@ | `@codemirror/view@6.37.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@codemirror/view/6.37.1) | | `@devfile/api@2.3.0-1757407014` | Apache-2.0 | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@devfile/api/2.3.0-1757407014) | | `@eclipse-che/che-devworkspace-generator@7.113.0-next-7b6a101` | Apache-2.0 AND EPL-2.0 | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@eclipse-che/che-devworkspace-generator/7.113.0-next-7b6a101) | -| `@fastify/accept-negotiator@2.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@fastify/accept-negotiator/2.0.1) | +| [`@eclipse-che/common@workspace:packages/common`](https://github.com/eclipse-che/che-dashboard) | EPL-2.0 | ecd.che | | `@fastify/ajv-compiler@4.0.5` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@fastify/ajv-compiler/4.0.5) | | `@fastify/cookie@11.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@fastify/cookie/11.0.2) | | `@fastify/cors@11.2.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@fastify/cors/11.2.0) | @@ -26,8 +26,6 @@ | `@fastify/proxy-addr@5.1.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@fastify/proxy-addr/5.1.0) | | `@fastify/rate-limit@10.3.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@fastify/rate-limit/10.3.0) | | `@fastify/reply-from@12.6.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@fastify/reply-from/12.6.2) | -| `@fastify/send@4.1.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@fastify/send/4.1.0) | -| `@fastify/static@9.1.3` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@fastify/static/9.1.3) | | `@fastify/swagger-ui@5.2.5` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@fastify/swagger-ui/5.2.5) | | `@fastify/swagger@9.7.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@fastify/swagger/9.7.0) | | `@fastify/websocket@11.2.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/@fastify/websocket/11.2.0) | @@ -86,7 +84,6 @@ | `call-bound@1.0.4` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/call-bound/1.0.4) | | `camelcase@5.0.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/camelcase/5.0.0) | | `codemirror@6.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/codemirror/6.0.1) | -| `content-disposition@1.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/content-disposition/1.0.1) | | `cookie@1.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/cookie/1.0.1) | | `cookie@1.1.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/cookie/1.1.1) | | `copy-to-clipboard@3.3.3` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/copy-to-clipboard/3.3.3) | @@ -100,7 +97,6 @@ | `date-fns@3.6.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/date-fns/3.6.0) | | `dateformat@4.6.3` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/dateformat/4.6.3) | | `dayjs@1.11.21` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/dayjs/1.11.21) | -| `depd@2.0.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/depd/2.0.0) | | `des.js@1.1.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/des.js/1.1.0) | | `detect-browser@5.3.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/detect-browser/5.3.0) | | `diffie-hellman@5.0.3` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/diffie-hellman/5.0.3) | @@ -111,7 +107,6 @@ | `es-define-property@1.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/es-define-property/1.0.1) | | `es-errors@1.3.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/es-errors/1.3.0) | | `es-object-atoms@1.1.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/es-object-atoms/1.1.1) | -| `escape-html@1.0.3` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/escape-html/1.0.3) | | `event-target-shim@5.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/event-target-shim/5.0.1) | | `evp_bytestokey@1.0.3` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/evp_bytestokey/1.0.3) | | `fast-content-type-parse@3.0.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/fast-content-type-parse/3.0.0) | @@ -140,7 +135,6 @@ | `help-me@4.2.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/help-me/4.2.0) | | `history@4.10.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/history/4.10.1) | | `htmlparser2@10.1.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/htmlparser2/10.1.0) | -| `http-errors@2.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/http-errors/2.0.1) | | `https@1.0.0` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/https/1.0.0) | | `immer@10.1.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/immer/10.1.1) | | `inherits@2.0.4` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/inherits/2.0.4) | @@ -164,7 +158,6 @@ | `math-intrinsics@1.1.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/math-intrinsics/1.1.0) | | `md5.js@1.3.5` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/md5.js/1.3.5) | | `miller-rabin@4.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/miller-rabin/4.0.1) | -| `mime@3.0.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/mime/3.0.0) | | `minimalistic-assert@1.0.1` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/minimalistic-assert/1.0.1) | | `mri@1.1.4` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/mri/1.1.4) | | `multi-ini@2.3.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/multi-ini/2.3.2) | @@ -218,7 +211,6 @@ | `secure-json-parse@2.7.0` | BSD-3-Clause | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/secure-json-parse/2.7.0) | | `secure-json-parse@4.1.0` | BSD-3-Clause | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/secure-json-parse/4.1.0) | | `set-cookie-parser@2.7.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/set-cookie-parser/2.7.2) | -| `setprototypeof@1.2.0` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/setprototypeof/1.2.0) | | `side-channel-list@1.0.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/side-channel-list/1.0.0) | | `side-channel-map@1.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/side-channel-map/1.0.1) | | `side-channel-weakmap@1.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/side-channel-weakmap/1.0.2) | @@ -227,7 +219,6 @@ | `sonic-boom@3.7.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/sonic-boom/3.7.0) | | `sonic-boom@4.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/sonic-boom/4.0.1) | | `split2@4.2.0` | ISC | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/split2/4.2.0) | -| `statuses@2.0.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/statuses/2.0.2) | | `stream-shift@1.0.3` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/stream-shift/1.0.3) | | `string_decoder@1.3.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/string_decoder/1.3.0) | | `style-mod@4.1.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/style-mod/4.1.2) | @@ -238,7 +229,6 @@ | `tiny-warning@1.0.3` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/tiny-warning/1.0.3) | | `toad-cache@3.7.0` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/toad-cache/3.7.0) | | `toggle-selection@1.0.6` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/toggle-selection/1.0.6) | -| `toidentifier@1.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/toidentifier/1.0.1) | | `universalify@2.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/universalify/2.0.1) | | `use-sync-external-store@1.2.2` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/use-sync-external-store/1.2.2) | | `value-equal@1.0.1` | MIT | [clearlydefined](https://clearlydefined.io/definitions/npm/npmjs/-/value-equal/1.0.1) | diff --git a/.gitignore b/.gitignore index e009a1b437..bf07d10b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,17 @@ run/.che-dashboard-pod run/.custom-resources run/public-certs +# Exclude binary/installer files +*.dmg +*.msi +*.pkg +*.deb +*.rpm +*.zip +!plugins/*.zip +*.tar.gz +*.tgz + .claude/settings.local.json .worktrees/ @@ -40,3 +51,17 @@ coverage/ # npm cache .npm/ + +# Plugin symlinks (auto-generated by scripts/prepare-plugins.sh) +packages/dashboard-frontend/src/plugins/ai-selector +packages/dashboard-backend/src/plugins/ai-selector +packages/dashboard-frontend/src/plugins/dashboard-ai-agent +packages/dashboard-backend/src/plugins/dashboard-ai-agent + +# Auto-generated plugin registration +packages/dashboard-frontend/src/plugins/index.ts + +# Plugin bundles fetched at build time from https://github.com/olexii4/che-dashboard-plugins +plugins/ +packages/dashboard-plugins/lib/ +packages/dashboard-plugins/tsconfig.tsbuildinfo diff --git a/build/dockerfiles/Dockerfile b/build/dockerfiles/Dockerfile index 8150b61b7c..6b2f83555c 100644 --- a/build/dockerfiles/Dockerfile +++ b/build/dockerfiles/Dockerfile @@ -14,7 +14,7 @@ FROM --platform=$BUILDPLATFORM docker.io/node:24.0.2-alpine3.21 AS builder # hadolint ignore=DL3018 -RUN apk add --no-cache python3 py3-pip make g++ jq curl git zip +RUN apk add --no-cache python3 py3-pip make g++ jq curl git zip bash RUN pip3 install yq --break-system-packages COPY .yarn/releases /dashboard/.yarn/releases/ @@ -32,6 +32,9 @@ COPY ${FRONTEND}/package.json /dashboard/${FRONTEND}/ ENV BACKEND=packages/dashboard-backend COPY ${BACKEND}/package.json /dashboard/${BACKEND}/ +ENV PLUGINS=packages/dashboard-plugins +COPY ${PLUGINS}/package.json /dashboard/${PLUGINS}/ + WORKDIR /dashboard ENV DISABLE_OPENCOLLECTIVE=true @@ -40,7 +43,22 @@ ENV ADBLOCK=true RUN yarn install --network-timeout 3600000 --frozen-lockfile --mode=skip-build || \ yarn install --network-timeout 3600000 --frozen-lockfile COPY packages/ /dashboard/packages -RUN yarn build +COPY scripts/prepare-plugins.sh /dashboard/scripts/prepare-plugins.sh +COPY scripts/fetch-plugins.sh /dashboard/scripts/fetch-plugins.sh +ARG PLUGINS_REPO=olexii4/che-dashboard-plugins +ARG PLUGINS_REF=latest +ARG PLUGINS_LIST +RUN bash scripts/fetch-plugins.sh \ + && find packages/dashboard-frontend/src/plugins packages/dashboard-backend/src/plugins -maxdepth 1 -type l -delete 2>/dev/null || true \ + && rm -f packages/dashboard-frontend/src/plugins/index.ts \ + && for d in plugins/*/; do \ + [ -f "$d/plugin.json" ] || continue; \ + name=$(basename "$d"); \ + [ -d "$d/frontend" ] && cp -r "$d/frontend" "packages/dashboard-frontend/src/plugins/$name"; \ + [ -d "$d/backend" ] && cp -r "$d/backend" "packages/dashboard-backend/src/plugins/$name"; \ + done \ + && bash scripts/prepare-plugins.sh +RUN yarn build:packages # Prepare air-gapped resources COPY scripts/airgap.sh /dashboard/airgap.sh diff --git a/build/dockerfiles/rhel.Dockerfile b/build/dockerfiles/rhel.Dockerfile index b392f7009b..d337ff9756 100644 --- a/build/dockerfiles/rhel.Dockerfile +++ b/build/dockerfiles/rhel.Dockerfile @@ -16,8 +16,9 @@ RUN dnf -y -q update --exclude=unbound-libs COPY . /dashboard/ WORKDIR /dashboard/ -RUN npm i -g yarn; yarn install -RUN yarn build +RUN npm i -g yarn \ + && yarn install +RUN yarn build:packages # https://registry.access.redhat.com/ubi9/nodejs-20 FROM registry.access.redhat.com/ubi9/nodejs-20 diff --git a/package.json b/package.json index 16a47f3128..6047ecc03f 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "workspaces": { "packages": [ "packages/common", + "packages/dashboard-plugins", "packages/dashboard-backend", "packages/dashboard-frontend" ] @@ -19,8 +20,11 @@ }, "license": "EPL-2.0", "scripts": { - "build": "yarn workspaces foreach -A run build", - "build:dev": "yarn workspaces foreach -A run build:dev", + "fetch:plugins": "bash scripts/fetch-plugins.sh", + "prepare:plugins": "bash scripts/fetch-plugins.sh && bash scripts/prepare-plugins.sh", + "build:packages": "yarn workspace @eclipse-che/common run build && yarn workspace @eclipse-che/dashboard-plugins run build && yarn workspace @eclipse-che/dashboard-backend run build && yarn workspace @eclipse-che/dashboard-frontend run build", + "build": "yarn prepare:plugins && yarn workspaces foreach -A --topological run build", + "build:dev": "yarn prepare:plugins && yarn workspaces foreach -A --topological run build:dev", "frontend:start": "yarn workspace @eclipse-che/dashboard-frontend start", "start": "$PWD/run/local-run.sh $@", "start:prepare": "$PWD/run/prepare-local-run.sh", @@ -29,7 +33,7 @@ "license:generate": "license-tool --harvest", "test": "yarn run pretest && yarn workspaces foreach -A run test --no-cache", "test:check": "yarn run pretest && yarn workspace @eclipse-che/dashboard-frontend test --config=jest.config.check.js", - "pretest": "yarn workspace @eclipse-che/common run build", + "pretest": "yarn prepare:plugins && yarn workspace @eclipse-che/common run build && yarn workspace @eclipse-che/dashboard-plugins run build", "test:coverage": "yarn run pretest && yarn workspaces foreach -A run test --coverage", "format:check": "yarn workspaces foreach -A run format:check", "format:fix": "yarn workspaces foreach -A run format:fix", @@ -37,6 +41,8 @@ "lint:fix": "yarn workspaces foreach -A run lint:fix", "deploy:patch": "$PWD/run/patch.sh", "local:patch": "$PWD/run/local-patch.sh", + "local:patch:fast": "PREBUILD=1 $PWD/run/local-patch.sh", + "local:patch:skip-build": "SKIP_BUILD=1 $PWD/run/local-patch.sh", "resolutions:check": "node scripts/check-resolutions.js", "prepare": "husky" }, @@ -79,7 +85,7 @@ "micromatch": "^4.0.8", "nanoid": "3.3.8", "pbkdf2": "^3.1.3", - "postcss": "^8.5.13", + "postcss": "^8.4.49", "serialize-javascript": "^6.0.2", "sha.js": "^2.4.12", "ip-address": "10.2.0", diff --git a/packages/common/src/dto/api/index.ts b/packages/common/src/dto/api/index.ts index 33ca0821b1..6afc729745 100644 --- a/packages/common/src/dto/api/index.ts +++ b/packages/common/src/dto/api/index.ts @@ -120,6 +120,9 @@ export interface AiToolDefinition { envVarName?: string; /** One-time setup command run in the editor container at postStart */ setupCommand?: string; + description?: string; + docsUrl?: string; + icon?: string; } export interface AiProviderDefinition { @@ -265,3 +268,29 @@ export interface IStreamedFile { stream: ReadStream; size: number; } + +export interface AiAgentEnvVar { + name: string; + value: string; +} + +export interface AiAgentDefinition { + id: string; + name: string; + publisher: string; + description?: string; + icon?: string; + docsUrl?: string; + image: string; + tag: string; + memoryLimit?: string; + cpuLimit?: string; + terminalPort: number; + env?: AiAgentEnvVar[]; + initCommand?: string; +} + +export interface IAiAgentRegistry { + agents: AiAgentDefinition[]; + defaultAgentId: string; +} diff --git a/packages/common/src/dto/api/webSocket.ts b/packages/common/src/dto/api/webSocket.ts index 3f336d75c4..484821a898 100644 --- a/packages/common/src/dto/api/webSocket.ts +++ b/packages/common/src/dto/api/webSocket.ts @@ -11,13 +11,19 @@ */ import { V1alpha2DevWorkspace } from '@devfile/api'; -import { CoreV1Event, V1Pod, V1Status } from '@kubernetes/client-node'; +import { + CoreV1Event, + V1ConfigMap, + V1Pod, + V1Status, +} from '@kubernetes/client-node'; export enum Channel { DEV_WORKSPACE = 'devWorkspace', EVENT = 'event', POD = 'pod', LOGS = 'logs', + CONFIGMAP = 'configMap', } export function isWebSocketChannel(channel: unknown): channel is Channel { @@ -26,7 +32,8 @@ export function isWebSocketChannel(channel: unknown): channel is Channel { ((channel as Channel) === Channel.DEV_WORKSPACE || (channel as Channel) === Channel.EVENT)) || (channel as Channel) === Channel.POD || - (channel as Channel) === Channel.LOGS + (channel as Channel) === Channel.LOGS || + (channel as Channel) === Channel.CONFIGMAP ); } @@ -44,7 +51,11 @@ export type SubscribeMessage = { method: 'SUBSCRIBE'; } & ( | { - channel: Channel.DEV_WORKSPACE | Channel.EVENT | Channel.POD; + channel: + | Channel.DEV_WORKSPACE + | Channel.EVENT + | Channel.POD + | Channel.CONFIGMAP; params: SubscribeParams; } | { @@ -149,12 +160,18 @@ export type StatusMessage = { status: V1Status; params: SubscribeParams | SubscribeLogsParams; }; +export type ConfigMapMessage = { + eventPhase: EventPhase.ADDED | EventPhase.MODIFIED | EventPhase.DELETED; + configMap: V1ConfigMap; +}; + export type NotificationMessage = | EventMessage | DevWorkspaceMessage | PodMessage | LogsMessage - | StatusMessage; + | StatusMessage + | ConfigMapMessage; export type EventData = { channel: Channel; message: NotificationMessage; @@ -229,3 +246,15 @@ export function isLogsMessage(message: unknown): message is LogsMessage { (message as LogsMessage).logs !== undefined ); } + +export function isConfigMapMessage( + message: unknown, +): message is ConfigMapMessage { + return ( + message !== undefined && + ((message as ConfigMapMessage).eventPhase === EventPhase.ADDED || + (message as ConfigMapMessage).eventPhase === EventPhase.MODIFIED || + (message as ConfigMapMessage).eventPhase === EventPhase.DELETED) && + (message as ConfigMapMessage).configMap !== undefined + ); +} diff --git a/packages/dashboard-backend/.eslintignore b/packages/dashboard-backend/.eslintignore new file mode 100644 index 0000000000..217ec2bcb5 --- /dev/null +++ b/packages/dashboard-backend/.eslintignore @@ -0,0 +1,4 @@ +# Plugin source files are managed in a separate repo (che-dashboard-plugins) +# and symlinked into src/plugins/ at build time. They follow their own lint +# rules and should not be linted as part of the dashboard-backend package. +src/plugins/ diff --git a/packages/dashboard-backend/package.json b/packages/dashboard-backend/package.json index e9d2961e24..5a63f703de 100644 --- a/packages/dashboard-backend/package.json +++ b/packages/dashboard-backend/package.json @@ -33,7 +33,7 @@ "@fastify/http-proxy": "^11.4.4", "@fastify/oauth2": "^8.2.0", "@fastify/rate-limit": "^10.3.0", - "@fastify/static": "^9.1.1", + "@fastify/static": "^9.0.0", "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.5", "@fastify/websocket": "^11.2.0", diff --git a/packages/dashboard-backend/src/app.ts b/packages/dashboard-backend/src/app.ts index 21bc82afce..461ce82e4d 100644 --- a/packages/dashboard-backend/src/app.ts +++ b/packages/dashboard-backend/src/app.ts @@ -19,6 +19,10 @@ import { FastifyInstance } from 'fastify'; import parseArgs from '@/helpers/parseArgs'; import { isLocalRun, registerLocalRun } from '@/localRun'; import { registerCors } from '@/plugins/cors'; +import { registerAgentsRoute } from '@/plugins/dashboard-ai-agent/routes/agents'; +import { registerAiAgentRegistryRoute } from '@/plugins/dashboard-ai-agent/routes/aiAgentRegistry'; +import { registerDevfilesRoute } from '@/plugins/dashboard-ai-agent/routes/devfiles'; +import { registerDevfileSchemaRoute } from '@/plugins/dashboard-ai-agent/routes/devfileSchema'; import { registerStaticServer } from '@/plugins/staticServer'; import { registerSwagger } from '@/plugins/swagger'; import { registerWebSocket } from '@/plugins/webSocket'; @@ -146,8 +150,12 @@ export default async function buildApp(server: FastifyInstance): Promise diff --git a/packages/dashboard-backend/tsconfig.json b/packages/dashboard-backend/tsconfig.json index 40be11ec45..2347a8acee 100644 --- a/packages/dashboard-backend/tsconfig.json +++ b/packages/dashboard-backend/tsconfig.json @@ -23,6 +23,7 @@ ], "include": [ "src", - "src/devfileSchemas/**/*.json" + "src/devfileSchemas/**/*.json", + "src/plugins/**/*.json" ], } \ No newline at end of file diff --git a/packages/dashboard-frontend/.eslintignore b/packages/dashboard-frontend/.eslintignore new file mode 100644 index 0000000000..a81a62d8a4 --- /dev/null +++ b/packages/dashboard-frontend/.eslintignore @@ -0,0 +1,4 @@ +# Plugin source files are managed in a separate repo (che-dashboard-plugins) +# and symlinked into src/plugins/ at build time. They follow their own lint +# rules and should not be linted as part of the dashboard-frontend package. +src/plugins/ diff --git a/packages/dashboard-frontend/__mocks__/pluginStub.tsx b/packages/dashboard-frontend/__mocks__/pluginStub.tsx new file mode 100644 index 0000000000..efb5f30642 --- /dev/null +++ b/packages/dashboard-frontend/__mocks__/pluginStub.tsx @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +// Universal stub for plugin modules that are only available via symlinks created by +// scripts/prepare-plugins.sh at build time. +// +// Uses a Proxy with a backing store so jest.spyOn() can replace properties: +// - get: returns stored value, or a stub function for unknown keys +// - set: stores the value (allows jest.spyOn to install its spy) +// - getOwnPropertyDescriptor: advertises every key as writable+configurable +// so Jest's internal Object.defineProperty calls succeed +// +// Special cases: +// - *Reducer named exports → identity reducer (required by Redux) +// - actionCreators / *ActionCreators → a spyable proxy-object +// - default → React component stub + +import React from 'react'; + +const stubThunk = () => () => Promise.resolve(); +const stubReducer = (state = {}) => state; + +function PluginStub(): React.ReactElement { + return <>; +} + +function makeSpyableProxy(fallback: () => unknown) { + const store: Record = {}; + return new Proxy(store, { + get(target, prop) { + if (prop in target) return target[prop]; + return fallback(); + }, + set(target, prop, value) { + target[prop] = value; + return true; + }, + getOwnPropertyDescriptor(target, prop) { + return { + configurable: true, + enumerable: true, + writable: true, + value: prop in target ? target[prop] : fallback(), + }; + }, + has() { + return true; + }, + }); +} + +const moduleStore: Record = {}; +const moduleProxy = new Proxy(moduleStore, { + get(target, prop) { + if (prop === '__esModule') return true; + if (prop === 'default') return PluginStub; + if (prop in target) return target[prop]; + if (prop === 'reducer' || (typeof prop === 'string' && /Reducer$/.test(prop))) { + return stubReducer; + } + if (prop === 'actionCreators' || (typeof prop === 'string' && /ActionCreators$/.test(prop))) { + const obj = makeSpyableProxy(stubThunk); + target[prop] = obj; + return obj; + } + return stubThunk; + }, + set(target, prop, value) { + target[prop] = value; + return true; + }, + getOwnPropertyDescriptor(target, prop) { + return { + configurable: true, + enumerable: true, + writable: true, + value: target[prop], + }; + }, + has() { + return true; + }, +}); + +module.exports = moduleProxy; diff --git a/packages/dashboard-frontend/__mocks__/sanitize-html.js b/packages/dashboard-frontend/__mocks__/sanitize-html.js new file mode 100644 index 0000000000..b403e6e80b --- /dev/null +++ b/packages/dashboard-frontend/__mocks__/sanitize-html.js @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +// CJS stub for sanitize-html (the package ships as ESM in newer versions). +// Implements minimal sanitization so tests that assert on sanitized output +// continue to pass without requiring the real ESM package. +function sanitizeHtml(html, options) { + if (typeof html !== 'string') return ''; + const allowedTags = (options && options.allowedTags) || []; + const allowedAttributes = (options && options.allowedAttributes) || {}; + + // Strip tags that are not in allowedTags, keeping their text content. + return html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, function (match, tag, attrs) { + const lowerTag = tag.toLowerCase(); + const isClosing = match.startsWith('`; + } + + // Filter attributes to only allowed ones + const permittedAttrs = allowedAttributes[lowerTag] || []; + let filteredAttrs = ''; + const attrRegex = /([a-zA-Z][a-zA-Z0-9-]*)=(?:"([^"]*)"|'([^']*)'|(\S+))/g; + let m; + while ((m = attrRegex.exec(attrs)) !== null) { + const attrName = m[1].toLowerCase(); + const attrValue = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4]; + if (permittedAttrs.includes(attrName)) { + filteredAttrs += ` ${attrName}="${attrValue}"`; + } + } + + return `<${lowerTag}${filteredAttrs}>`; + }); +} + +sanitizeHtml.defaults = { allowedTags: [], allowedAttributes: {} }; +module.exports = sanitizeHtml; diff --git a/packages/dashboard-frontend/jest.config.js b/packages/dashboard-frontend/jest.config.js index 42e40acf27..08ef97db3a 100644 --- a/packages/dashboard-frontend/jest.config.js +++ b/packages/dashboard-frontend/jest.config.js @@ -20,6 +20,12 @@ module.exports = { moduleNameMapper: { '\\.(css|less|sass|scss|styl)$': 'identity-obj-proxy', '\\.(gif|ttf|eot|svg)$': '/__mocks__/fileMock.js', + // sanitize-html ships as ESM in newer versions; use a CJS stub in tests + '^sanitize-html$': '/__mocks__/sanitize-html.js', + // Plugin modules are only available via symlinks created by prepare-plugins.sh. + // Map them to a stub so tests that jest.mock() a plugin module can resolve it locally. + // Must appear before the @/ mapper so it takes precedence. + '^@/plugins/(.*)$': '/__mocks__/pluginStub.tsx', // mapping for absolute imports (see tsconfig.json) '^@/(.*)$': '/src/$1', }, @@ -31,7 +37,11 @@ module.exports = { '^.*\\.tsx?$': [ 'ts-jest', { - diagnostics: true, + diagnostics: { + // Ignore TS2307 "Cannot find module" for plugin paths that are + // only available via symlinks created by prepare-plugins.sh at build time. + ignoreCodes: [2307], + }, }, ], }, diff --git a/packages/dashboard-frontend/package.json b/packages/dashboard-frontend/package.json index 3a96e3f54f..a9dbb15990 100644 --- a/packages/dashboard-frontend/package.json +++ b/packages/dashboard-frontend/package.json @@ -35,6 +35,7 @@ "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.10.8", "@codemirror/view": "^6.36.2", + "@eclipse-che/dashboard-plugins": "workspace:*", "@lezer/highlight": "^1.2.1", "@patternfly/react-core": "^6.4.0", "@patternfly/react-icons": "^6.4.0", diff --git a/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/index.module.css b/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/index.module.css index 49d25b536e..777aaa17c3 100644 --- a/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/index.module.css +++ b/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/index.module.css @@ -54,8 +54,3 @@ background-color: var(--pf-t--global--color--nonstatus--red--default); border-radius: 3px; } - -:global(.pf-v6-theme-dark) .errorMessage { - color: var(--pf-t--global--text--color--regular); - background-color: rgb(185 80 80); -} diff --git a/packages/dashboard-frontend/src/Layout/Navigation/MainList.tsx b/packages/dashboard-frontend/src/Layout/Navigation/MainList.tsx index a7f7ae2888..e524c8684d 100644 --- a/packages/dashboard-frontend/src/Layout/Navigation/MainList.tsx +++ b/packages/dashboard-frontend/src/Layout/Navigation/MainList.tsx @@ -15,6 +15,7 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; import NavigationMainItem from '@/Layout/Navigation/MainItem'; +import { getPluginNavigationItems } from '@/plugin-registry'; import { ROUTE } from '@/Routes'; import { RootState } from '@/store'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; @@ -27,21 +28,36 @@ type Props = MappedProps & { export class NavigationMainList extends React.PureComponent { private get items(): NavigationItemObject[] { - const { allWorkspaces } = this.props; - + const { allWorkspaces, state } = this.props; const allWorkspacesNumber = allWorkspaces.length; - return [ + const base: NavigationItemObject[] = [ { to: ROUTE.GET_STARTED, label: 'Create Workspace' }, { to: ROUTE.WORKSPACES, label: `Workspaces (${allWorkspacesNumber})` }, ]; + + const pluginItems = getPluginNavigationItems(); + for (const def of pluginItems) { + const label = def.labelSelector ? def.labelSelector(state) : def.label; + const item: NavigationItemObject = { to: def.to, label }; + if (def.insertAfter) { + const idx = base.findIndex(i => i.to === def.insertAfter); + if (idx !== -1) { + base.splice(idx + 1, 0, item); + continue; + } + } + base.push(item); + } + + return base; } public render(): React.ReactElement { const { activePath } = this.props; const navItems = this.items.map(item => { - return ; + return ; }); return {navItems}; @@ -50,6 +66,7 @@ export class NavigationMainList extends React.PureComponent { const mapStateToProps = (state: RootState) => ({ allWorkspaces: selectAllWorkspaces(state), + state, }); const connector = connect(mapStateToProps); diff --git a/packages/dashboard-frontend/src/Layout/Sidebar/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/Layout/Sidebar/__tests__/__snapshots__/index.spec.tsx.snap index 5183307abc..ed11e0a388 100644 --- a/packages/dashboard-frontend/src/Layout/Sidebar/__tests__/__snapshots__/index.spec.tsx.snap +++ b/packages/dashboard-frontend/src/Layout/Sidebar/__tests__/__snapshots__/index.spec.tsx.snap @@ -8,10 +8,25 @@ exports[`Sidebar snapshot - closed 1`] = ` inert="" >
-
- Mock Navigation component +
+
+ Mock Navigation component +
+
+
+
@@ -24,10 +39,25 @@ exports[`Sidebar snapshot - open 1`] = ` id="page-sidebar" >
-
- Mock Navigation component +
+
+ Mock Navigation component +
+
+
+
diff --git a/packages/dashboard-frontend/src/Layout/Sidebar/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/Layout/Sidebar/__tests__/index.spec.tsx index 324b3b1d55..07ea8377d6 100644 --- a/packages/dashboard-frontend/src/Layout/Sidebar/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/Sidebar/__tests__/index.spec.tsx @@ -19,6 +19,10 @@ import { Sidebar } from '@/Layout/Sidebar'; import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; jest.mock('@/Layout/Navigation'); +jest.mock('@/plugins/dashboard-ai-agent/navigation/AgentList', () => ({ + __esModule: true, + default: () => null, +})); const { createSnapshot } = getComponentRenderer(getComponent); diff --git a/packages/dashboard-frontend/src/Layout/Sidebar/index.module.css b/packages/dashboard-frontend/src/Layout/Sidebar/index.module.css new file mode 100644 index 0000000000..f15ab9930d --- /dev/null +++ b/packages/dashboard-frontend/src/Layout/Sidebar/index.module.css @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2018-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +/* Make the sidebar body a flex column so agent pods can be pushed to the bottom */ +.sidebarBody { + display: flex; + flex-direction: column; + height: 100%; + padding: 0; +} + +/* Main navigation fills all available vertical space */ +.mainNavWrapper { + overflow-y: auto; + flex: 1 1 auto; +} + +/* Agent pods section sticks to the bottom and grows upward */ +.agentNavWrapper { + flex: 0 0 auto; + margin-top: auto; +} diff --git a/packages/dashboard-frontend/src/Layout/Sidebar/index.tsx b/packages/dashboard-frontend/src/Layout/Sidebar/index.tsx index 440796dc95..ed18dfcfc2 100644 --- a/packages/dashboard-frontend/src/Layout/Sidebar/index.tsx +++ b/packages/dashboard-frontend/src/Layout/Sidebar/index.tsx @@ -10,12 +10,14 @@ * Red Hat, Inc. - initial API and implementation */ -import { PageContext, PageSidebar, PageSidebarBody } from '@patternfly/react-core'; +import { Nav, PageContext, PageSidebar, PageSidebarBody } from '@patternfly/react-core'; import { History } from 'history'; import React from 'react'; import { useAnnounceOnChange } from '@/components/WorkspaceProgress/StepTitle/useAnnounceOnChange'; import Navigation from '@/Layout/Navigation'; +import styles from '@/Layout/Sidebar/index.module.css'; +import NavigationAgentList from '@/plugins/dashboard-ai-agent/navigation/AgentList'; export type Props = { history: History; @@ -37,8 +39,15 @@ export function Sidebar({ history }: Props): React.ReactElement { return ( - - + +
+ +
+
+ +
); diff --git a/packages/dashboard-frontend/src/Routes/index.tsx b/packages/dashboard-frontend/src/Routes/index.tsx index 1f2a129fce..879d9d2210 100644 --- a/packages/dashboard-frontend/src/Routes/index.tsx +++ b/packages/dashboard-frontend/src/Routes/index.tsx @@ -20,6 +20,16 @@ import WorkspaceDetailsContainer from '@/containers/WorkspaceDetails'; import WorkspacesListContainer from '@/containers/WorkspacesList'; import { Redirects } from '@/Routes/Redirects'; +// Lazy-load plugin containers to avoid pulling the full plugin component tree +// into the main bundle (prevents circular dep crash) and to keep jest happy +// (the dynamic import is not resolved during module loading in tests). +const DevfilesListContainer = React.lazy( + () => import('@/plugins/dashboard-ai-agent/containers/DevfilesList'), +); +const DevfileDetailsContainer = React.lazy( + () => import('@/plugins/dashboard-ai-agent/containers/DevfileDetails'), +); + export enum ROUTE { HOME = '/', GET_STARTED = '/create-workspace', @@ -33,6 +43,8 @@ export enum ROUTE { FACTORY_LOADER_URL = '/load-factory?url=:url', USER_PREFERENCES = '/user-preferences', USER_PREFERENCES_TAB = '/user-preferences?tab=:tabId', + DEVFILES = '/devfiles', + DEVFILE_DETAILS = '/devfile/:namespace/:devfileId', } export type WorkspaceRouteParams = Params<'namespace' | 'workspaceName'>; @@ -61,6 +73,24 @@ export function AppRoutes(): React.ReactElement { path={ROUTE.USER_PREFERENCES} element={} /> + + + + } + /> + + + + } + /> } /> ); diff --git a/packages/dashboard-frontend/src/components/BackupStatusBadge/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/BackupStatusBadge/__tests__/__snapshots__/index.spec.tsx.snap index 70344e4d8f..2b1cabaa26 100644 --- a/packages/dashboard-frontend/src/components/BackupStatusBadge/__tests__/__snapshots__/index.spec.tsx.snap +++ b/packages/dashboard-frontend/src/components/BackupStatusBadge/__tests__/__snapshots__/index.spec.tsx.snap @@ -25,7 +25,7 @@ exports[`BackupStatusBadge icon rendering should render CheckCircleIcon for succ > div > div > div > div { + opacity: 0.8; +} diff --git a/packages/dashboard-frontend/src/components/DevfileViewer/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/DevfileViewer/__tests__/__snapshots__/index.spec.tsx.snap deleted file mode 100644 index 25b6eba8a0..0000000000 --- a/packages/dashboard-frontend/src/components/DevfileViewer/__tests__/__snapshots__/index.spec.tsx.snap +++ /dev/null @@ -1,12 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`DevfileViewer snapshot with light theme 1`] = ` -
-
-
-`; diff --git a/packages/dashboard-frontend/src/components/DevfileViewer/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/DevfileViewer/__tests__/index.spec.tsx deleted file mode 100644 index 09b9fbaa08..0000000000 --- a/packages/dashboard-frontend/src/components/DevfileViewer/__tests__/index.spec.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) 2018-2025 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import React from 'react'; - -import { DevfileViewer } from '@/components/DevfileViewer'; -import { useTheme } from '@/contexts/ThemeContext'; -import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; - -jest.mock('@/contexts/ThemeContext', () => ({ - useTheme: jest.fn(), -})); - -const mockUseTheme = useTheme as jest.Mock; - -const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); - -const sampleDevfile = 'schemaVersion: 2.2.0\nmetadata:\n name: test-workspace'; - -describe('DevfileViewer', () => { - beforeEach(() => { - mockUseTheme.mockReturnValue({ - themePreference: 'LIGHT', - isDarkTheme: false, - setThemePreference: jest.fn(), - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('snapshot with light theme', () => { - const snapshot = createSnapshot(sampleDevfile); - expect(snapshot.toJSON()).toMatchSnapshot(); - }); - - test('renders content in light theme', () => { - renderComponent(sampleDevfile); - - const textbox = screen.getByRole('textbox'); - expect(textbox).toHaveTextContent('schemaVersion'); - }); - - test('renders content in dark theme', () => { - mockUseTheme.mockReturnValue({ - themePreference: 'DARK', - isDarkTheme: true, - setThemePreference: jest.fn(), - }); - - renderComponent(sampleDevfile); - - const textbox = screen.getByRole('textbox'); - expect(textbox).toHaveTextContent('schemaVersion'); - }); - - test('handles content change', () => { - const { reRenderComponent } = renderComponent(sampleDevfile); - - const textbox = screen.getByRole('textbox'); - expect(textbox).toHaveTextContent('test-workspace'); - - const updatedDevfile = 'schemaVersion: 2.2.0\nmetadata:\n name: updated-workspace'; - reRenderComponent(updatedDevfile); - - expect(textbox).toHaveTextContent('updated-workspace'); - }); -}); - -function getComponent(value: string): React.ReactElement { - return ; -} diff --git a/packages/dashboard-frontend/src/components/DevfileViewer/index.module.css b/packages/dashboard-frontend/src/components/DevfileViewer/index.module.css index 7e0d903392..ca8540efd1 100644 --- a/packages/dashboard-frontend/src/components/DevfileViewer/index.module.css +++ b/packages/dashboard-frontend/src/components/DevfileViewer/index.module.css @@ -23,3 +23,7 @@ height: 100%; border: 1px solid var(--pf-t--global--border--color--default); } + +html:not(.pf-v6-theme-dark) .devfileViewer > div > div > div > div { + opacity: 0.8; +} diff --git a/packages/dashboard-frontend/src/components/DevfileViewer/index.tsx b/packages/dashboard-frontend/src/components/DevfileViewer/index.tsx index 52f2cc16e1..db386a6274 100644 --- a/packages/dashboard-frontend/src/components/DevfileViewer/index.tsx +++ b/packages/dashboard-frontend/src/components/DevfileViewer/index.tsx @@ -40,7 +40,7 @@ const createLightTheme = () => { }, '.cm-gutters': { backgroundColor: '#f7f7f7', - color: '#636363', + color: '#999', }, '.cm-activeLineGutter': { backgroundColor: '#f7f7f7', @@ -50,25 +50,10 @@ const createLightTheme = () => { ); }; -const createDarkGutterTheme = () => { - return EditorView.theme( - { - '.cm-gutters': { - backgroundColor: '#161b22', - color: '#8b949e', - }, - '.cm-activeLineGutter': { - backgroundColor: '#161b22', - }, - }, - { dark: true }, - ); -}; - const createLightHighlightStyle = () => { return HighlightStyle.define([ - { tag: t.keyword, color: '#4c7399' }, - { tag: [t.string], color: '#4c7399' }, + { tag: t.keyword, color: '#5e81ac' }, + { tag: [t.string], color: '#5e81ac' }, { tag: [t.variableName], color: '#008080' }, { tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], @@ -81,7 +66,6 @@ export const DevfileViewer: React.FC = ({ value, id }) => { const { isDarkTheme } = useTheme(); const lightTheme = useMemo(() => createLightTheme(), []); - const darkGutterTheme = useMemo(() => createDarkGutterTheme(), []); const lightHighlightStyle = useMemo(() => createLightHighlightStyle(), []); const extensions = useMemo(() => { @@ -90,9 +74,9 @@ export const DevfileViewer: React.FC = ({ value, id }) => { yaml(), ]; return isDarkTheme - ? [darkGutterTheme, ...baseExtensions] + ? baseExtensions : [lightTheme, syntaxHighlighting(lightHighlightStyle), ...baseExtensions]; - }, [isDarkTheme, darkGutterTheme, lightTheme, lightHighlightStyle]); + }, [isDarkTheme, lightTheme, lightHighlightStyle]); return (
diff --git a/packages/dashboard-frontend/src/components/ExpandableWarning/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/ExpandableWarning/__tests__/__snapshots__/index.spec.tsx.snap index 7bedfee4af..c27cf04439 100644 --- a/packages/dashboard-frontend/src/components/ExpandableWarning/__tests__/__snapshots__/index.spec.tsx.snap +++ b/packages/dashboard-frontend/src/components/ExpandableWarning/__tests__/__snapshots__/index.spec.tsx.snap @@ -80,7 +80,7 @@ exports[`Expandable warning items should correctly render the component 1`] = ` className="pf-v6-c-code-block__code" > { ); const expandableSectionTitle = isExpanded ? 'Show Less' : 'Show More'; - const messageClassName = isExpanded - ? styles.errorMessage - : `${styles.hideOverflow} ${styles.errorMessage}`; + const messageClassName = isExpanded ? undefined : styles.hideOverflow; return ( <> diff --git a/packages/dashboard-frontend/src/components/ResourceIcon/index.module.css b/packages/dashboard-frontend/src/components/ResourceIcon/index.module.css index e1be04f701..d51b802f90 100644 --- a/packages/dashboard-frontend/src/components/ResourceIcon/index.module.css +++ b/packages/dashboard-frontend/src/components/ResourceIcon/index.module.css @@ -12,6 +12,7 @@ .icon { display: inline-block; + flex-shrink: 0; min-width: 18px; margin-right: 4px; @@ -19,10 +20,10 @@ font-size: 14px; line-height: 16px; - color: var(--pf-t--global--text--color--nonstatus--on-blue--default); + color: var(--pf-t--global--color--nonstatus--gray--100); text-align: center; white-space: nowrap; - background-color: var(--pf-t--global--color--nonstatus--blue--default); + background-color: var(--pf-t--global--color--nonstatus--blue--300); border-radius: 20px; } diff --git a/packages/dashboard-frontend/src/components/Workspace/Status/index.module.css b/packages/dashboard-frontend/src/components/Workspace/Status/index.module.css index b1a89f3982..51164b4b4a 100644 --- a/packages/dashboard-frontend/src/components/Workspace/Status/index.module.css +++ b/packages/dashboard-frontend/src/components/Workspace/Status/index.module.css @@ -1,8 +1,19 @@ .statusLabel { - font-weight: bold; text-transform: capitalize; } +:global(html.pf-v6-theme-dark) .pf-m-green.statusLabel { + --pf-v6-c-label--BackgroundColor: var(--pf-t--color--green--10); +} + +:global(html.pf-v6-theme-dark) .pf-m-orange.statusLabel { + --pf-v6-c-label--BackgroundColor: var(--pf-t--color--orange--40); +} + +:global(html.pf-v6-theme-dark) .pf-m-info.statusLabel { + --pf-v6-c-label--BackgroundColor: var(--pf-t--color--blue--10); +} + .statusIndicator { margin-right: 5px; } diff --git a/packages/dashboard-frontend/src/index.tsx b/packages/dashboard-frontend/src/index.tsx index 682371a5c4..3049bd1286 100644 --- a/packages/dashboard-frontend/src/index.tsx +++ b/packages/dashboard-frontend/src/index.tsx @@ -21,6 +21,7 @@ import { Provider } from 'react-redux'; import App from '@/App'; import WorkspaceActionsProvider from '@/contexts/WorkspaceActions/Provider'; +import { registerAllPlugins } from '@/plugins'; import PreloadData from '@/services/bootstrap'; import { store } from '@/store'; @@ -47,6 +48,8 @@ if (typeof EventTarget !== 'undefined') { startApp(); async function startApp(): Promise { + registerAllPlugins(); + const history = createHashHistory(); const container = document.getElementById('ui-container')!; @@ -54,15 +57,16 @@ async function startApp(): Promise { try { // preload app data await new PreloadData(store).init(); - console.log('UD: preload data complete successfully.'); } catch (error) { console.error('UD: preload data failed.', error); } if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('./service-worker.js').catch(e => { - console.warn('Service worker registration failed:', e); - }); + try { + await navigator.serviceWorker.register('./service-worker.js'); + } catch (error) { + console.error('UD: service worker registration failed.', error); + } } root.render( diff --git a/packages/dashboard-frontend/src/pages/GetStarted/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/__snapshots__/index.spec.tsx.snap index 8af35337c8..de8568a41e 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/__tests__/__snapshots__/index.spec.tsx.snap +++ b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/__snapshots__/index.spec.tsx.snap @@ -58,6 +58,10 @@ exports[`GetStarted snapshot 1`] = ` className="spacer" data-testid="spacer" /> +
diff --git a/packages/dashboard-frontend/src/pages/GetStarted/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/index.tsx index e05a4e8d18..296c618fcf 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/index.tsx @@ -22,6 +22,7 @@ import Head from '@/components/Head'; import ImportFromGit from '@/components/ImportFromGit'; import { Spacer } from '@/components/Spacer'; import SamplesList from '@/pages/GetStarted/SamplesList'; +import { PluginSlot } from '@/plugin-registry/PluginSlot'; import { ROUTE } from '@/Routes'; import { RootState } from '@/store'; import { selectAiConfigEnabled } from '@/store/AiConfig/selectors'; @@ -126,6 +127,10 @@ export class GetStarted extends React.PureComponent { + + + + { isActive={activeTabKey === LoaderTab.Events} /> + + + diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/Form/SectionUser/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/Form/SectionUser/__tests__/index.spec.tsx index a0ed031228..9ee802392c 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/Form/SectionUser/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/Form/SectionUser/__tests__/index.spec.tsx @@ -63,7 +63,7 @@ describe('GitConfigSectionUser', () => { email: 'user@che', }, }, - false, + true, ); }); @@ -88,7 +88,7 @@ describe('GitConfigSectionUser', () => { email: 'new-user@che', }, }, - false, + true, ); }); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/Form/SectionUser/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/Form/SectionUser/index.tsx index ccae648841..0c752fc42d 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/Form/SectionUser/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/GitConfig/Form/SectionUser/index.tsx @@ -24,8 +24,8 @@ export type Props = { }; export class GitConfigSectionUser extends React.PureComponent { - private isNameValid = false; - private isEmailValid = false; + private isNameValid = true; + private isEmailValid = true; private handleChange( partialConfigUser: Partial, diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx index 143074b5b3..fb51348542 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx @@ -16,16 +16,15 @@ import { connect, ConnectedProps } from 'react-redux'; import { Location, NavigateFunction } from 'react-router-dom'; import Head from '@/components/Head'; -import AiProviderKeys from '@/pages/UserPreferences/AiProviderKeys'; import ContainerRegistries from '@/pages/UserPreferences/ContainerRegistriesTab'; import GitConfig from '@/pages/UserPreferences/GitConfig'; import GitServices from '@/pages/UserPreferences/GitServices'; import PersonalAccessTokens from '@/pages/UserPreferences/PersonalAccessTokens'; import SshKeys from '@/pages/UserPreferences/SshKeys'; +import { getPluginTabs } from '@/plugin-registry'; import { ROUTE } from '@/Routes'; import { UserPreferencesTab } from '@/services/helpers/types'; -import { RootState } from '@/store'; -import { selectAiConfigEnabled } from '@/store/AiConfig/selectors'; +import { RootState, store } from '@/store'; import { gitOauthConfigActionCreators } from '@/store/GitOauthConfig'; import { selectIsLoading } from '@/store/GitOauthConfig/selectors'; @@ -35,7 +34,7 @@ export type Props = { } & MappedProps; export type State = { - activeTabKey: UserPreferencesTab; + activeTabKey: UserPreferencesTab | string; }; class UserPreferences extends React.PureComponent { @@ -57,23 +56,18 @@ class UserPreferences extends React.PureComponent { }; } - private getActiveTabKey(): UserPreferencesTab { - const { aiEnabled, location } = this.props; - const { pathname, search } = location; + private getActiveTabKey(): UserPreferencesTab | string { + const { pathname, search } = this.props.location; if (search) { const searchParam = new URLSearchParams(search); const tab = searchParam.get('tab'); - if ( - pathname === ROUTE.USER_PREFERENCES && - ((tab === UserPreferencesTab.AI_PROVIDER_KEYS && aiEnabled) || - tab === UserPreferencesTab.CONTAINER_REGISTRIES || - tab === UserPreferencesTab.GITCONFIG || - tab === UserPreferencesTab.GIT_SERVICES || - tab === UserPreferencesTab.PERSONAL_ACCESS_TOKENS || - tab === UserPreferencesTab.SSH_KEYS) - ) { - return searchParam.get('tab') as UserPreferencesTab; + if (pathname === ROUTE.USER_PREFERENCES && tab) { + const knownTabs = Object.values(UserPreferencesTab) as string[]; + const pluginTabKeys = getPluginTabs().map(t => t.tabKey); + if (knownTabs.includes(tab) || pluginTabKeys.includes(tab)) { + return tab as UserPreferencesTab; + } } } @@ -99,7 +93,7 @@ class UserPreferences extends React.PureComponent { } const { activeTabKey } = this.state; - const currentIndex = this.tabOrder.indexOf(activeTabKey); + const currentIndex = (this.tabOrder as ReadonlyArray).indexOf(activeTabKey); let nextIndex = -1; if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { @@ -178,11 +172,23 @@ class UserPreferences extends React.PureComponent { > - {this.props.aiEnabled && ( - - - - )} + + {getPluginTabs() + .filter(tab => !tab.visible || tab.visible(store.getState())) + .map(tab => { + const SlotComponent = tab.component; + return ( + + + + ); + })} + @@ -191,7 +197,6 @@ class UserPreferences extends React.PureComponent { } const mapStateToProps = (state: RootState) => ({ - aiEnabled: selectAiConfigEnabled(state), isLoading: selectIsLoading(state), }); diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/Rows.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/Rows.tsx index fe55b4a315..4dcc9b84ab 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/Rows.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/Rows.tsx @@ -35,6 +35,7 @@ export type SortDirection = 'asc' | 'desc' | 'none'; export interface RowData { workspaceUID: string; + workspace: Workspace; cells: { details: React.ReactNode; editorIcon: React.ReactNode; @@ -240,6 +241,7 @@ export function buildRow( return { workspaceUID: workspace.uid, + workspace, cells: { details, editorIcon, diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/WorkspacesTable/index.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/WorkspacesTable/index.tsx new file mode 100644 index 0000000000..0028a15a4c --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/WorkspacesTable/index.tsx @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { Divider, PageSection, PageSectionVariants } from '@patternfly/react-core'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import React from 'react'; + +import { getSortParams, RowData, SortDirection } from '@/pages/WorkspacesList/Rows'; +import { getRegisteredFrontendPlugins } from '@/plugin-registry'; +import { selectAiProviders, selectAiTools } from '@/plugins/ai-selector/store/AiConfig'; +import { store } from '@/store'; + +type ColumnDef = { + title: string; + dataLabel: string; + sortable?: boolean; + screenReaderText?: string; +}; + +type Props = { + columns: ColumnDef[]; + rows: RowData[]; + sortBy: { + index: number; + direction: SortDirection; + }; + onSelect: (isSelected: boolean, rowIndex: number) => void; + onSort: (index: number, direction: SortDirection) => void; + toolbar: React.ReactNode; +}; + +export class WorkspacesTable extends React.PureComponent { + public render(): React.ReactElement { + const { columns, rows, sortBy, onSelect, onSort, toolbar } = this.props; + + return ( + + + {toolbar} + + + + + ), + )} + + + + {rows.map((row, rowIndex) => ( + + + + {getRegisteredFrontendPlugins() + .map(p => p.slots.workspacesListColumn) + .filter(col => { + if (!col) return false; + if (col.visible) return col.visible(store.getState()); + return true; + }) + .map((col, i) => { + const state = store.getState(); + const ColComponent = col!.component as React.ComponentType< + Record + >; + return ( + + ); + })} + + {row.cells.backupStatus !== undefined && ( + + )} + + + + + ))} + +
+ {columns.map((col, colIndex) => + col.screenReaderText ? ( + + ) : ( + + {col.title} +
onSelect(isSelected, rowIndex), + isSelected: row.isSelected, + isDisabled: row.isDisabled, + }} + /> + {row.cells.details}{row.cells.editorIcon} + + {row.cells.lastModifiedDate}{row.cells.backupStatus}{row.cells.projectsList}{row.cells.action}{row.cells.actionsDropdown}
+
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/index.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/index.tsx index 2ca4e4dbe7..f7ef0341d6 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/index.tsx @@ -20,10 +20,12 @@ import NothingFoundEmptyState from '@/pages/WorkspacesList/EmptyState/NothingFou import NoWorkspacesEmptyState from '@/pages/WorkspacesList/EmptyState/NoWorkspaces'; import { buildRows, getSortParams, RowData, SortDirection } from '@/pages/WorkspacesList/Rows'; import WorkspacesListToolbar from '@/pages/WorkspacesList/WorkspacesView/Toolbar'; +import { getRegisteredFrontendPlugins } from '@/plugin-registry'; import { BrandingData } from '@/services/bootstrap/branding.constant'; import devfileApi from '@/services/devfileApi'; import { buildGettingStartedLocation } from '@/services/helpers/location'; import { Workspace } from '@/services/workspace-adapter'; +import { store } from '@/store'; type Props = { backupConfig?: BackupConfig; @@ -48,6 +50,33 @@ type State = { }; export class WorkspacesView extends React.PureComponent { + private getPluginColumns() { + const state = store.getState(); + return getRegisteredFrontendPlugins() + .map(p => p.slots.workspacesListColumn) + .filter(col => col && (!col.visible || col.visible(state))); + } + + private getColumns() { + const showBackupStatus = !!this.props.backupConfig?.registry; + const pluginColumns = this.getPluginColumns().map(col => ({ + title: col!.name, + dataLabel: col!.name, + })); + const columns = [ + { title: 'Name', dataLabel: 'Name', sortable: true }, + { title: 'Editor', dataLabel: 'Editor', sortable: true }, + ...pluginColumns, + { title: 'Last Modified', dataLabel: 'Last Modified', sortable: true }, + ...(showBackupStatus ? [{ title: 'Backup Status', dataLabel: 'Backup Status' }] : []), + { title: 'Project(s)', dataLabel: 'Project(s)' }, + { title: '', dataLabel: ' ', screenReaderText: 'Open' }, + { title: '', dataLabel: ' ', screenReaderText: 'Open' }, + { title: '', dataLabel: ' ', screenReaderText: 'Actions' }, + ]; + return columns; + } + constructor(props: Props) { super(props); diff --git a/packages/dashboard-frontend/src/plugin-registry/PluginSlot.tsx b/packages/dashboard-frontend/src/plugin-registry/PluginSlot.tsx new file mode 100644 index 0000000000..1694206060 --- /dev/null +++ b/packages/dashboard-frontend/src/plugin-registry/PluginSlot.tsx @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; + +import { getRegisteredFrontendPlugins } from '@/plugin-registry'; + +interface PluginSlotProps { + name: 'workspaceCreation' | 'workspaceDetailsOverview'; + props?: Record; +} + +export const PluginSlot: React.FC = ({ name, props = {} }) => { + const plugins = getRegisteredFrontendPlugins(); + + return ( + <> + {plugins.map(plugin => { + const SlotComponent = plugin.slots[name]; + if (!SlotComponent) { + return null; + } + return ; + })} + + ); +}; diff --git a/packages/dashboard-frontend/src/plugin-registry/index.ts b/packages/dashboard-frontend/src/plugin-registry/index.ts new file mode 100644 index 0000000000..4fd282d68e --- /dev/null +++ b/packages/dashboard-frontend/src/plugin-registry/index.ts @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { + FrontendPlugin, + LoaderTabDefinition, + NavigationItemDefinition, +} from '@eclipse-che/dashboard-plugins'; +import { Store } from 'redux'; + +const frontendPlugins: FrontendPlugin[] = []; + +export function registerFrontendPlugin(plugin: FrontendPlugin): void { + if (!plugin.manifest.enabled) { + return; + } + frontendPlugins.push(plugin); +} + +export function getRegisteredFrontendPlugins(): FrontendPlugin[] { + return frontendPlugins; +} + +export async function bootstrapPlugins(store: Store): Promise { + const results = await Promise.allSettled( + frontendPlugins.filter(p => p.bootstrap !== undefined).map(p => p.bootstrap!(store)), + ); + for (const result of results) { + if (result.status === 'rejected') { + console.warn('Plugin bootstrap failed:', result.reason); + } + } +} + +export function getPluginTabs(): Array<{ + pluginId: string; + name: string; + tabKey: string; + component: React.ComponentType; + visible?: (state: unknown) => boolean; +}> { + const tabs: Array<{ + pluginId: string; + name: string; + tabKey: string; + component: React.ComponentType; + visible?: (state: unknown) => boolean; + }> = []; + + for (const plugin of frontendPlugins) { + const tab = plugin.slots.userPreferencesTab; + if (tab) { + tabs.push({ + pluginId: plugin.manifest.id, + name: tab.name, + tabKey: tab.key, + component: tab.component, + visible: tab.visible, + }); + } + } + return tabs; +} + +export function getPluginNavigationItems(): NavigationItemDefinition[] { + const items: NavigationItemDefinition[] = []; + for (const plugin of frontendPlugins) { + if (plugin.slots.navigationItems) { + items.push(...plugin.slots.navigationItems); + } + } + return items; +} + +export function getPluginLoaderTabs(): LoaderTabDefinition[] { + const tabs: LoaderTabDefinition[] = []; + for (const plugin of frontendPlugins) { + if (plugin.slots.loaderTabs) { + tabs.push(...plugin.slots.loaderTabs); + } + } + return tabs; +} diff --git a/packages/dashboard-frontend/src/services/backend-client/websocketClient/index.ts b/packages/dashboard-frontend/src/services/backend-client/websocketClient/index.ts index 930478e9e9..d1cf7ded0a 100644 --- a/packages/dashboard-frontend/src/services/backend-client/websocketClient/index.ts +++ b/packages/dashboard-frontend/src/services/backend-client/websocketClient/index.ts @@ -189,6 +189,13 @@ export class WebsocketClient { this.messageHandler.addListener(channel, listener); } + public removeChannelMessageListener( + channel: api.webSocket.Channel, + listener: ChannelListener, + ): void { + this.messageHandler.removeListener(channel, listener); + } + /** * Returns true if the given channel has a listener. */ diff --git a/packages/dashboard-frontend/src/services/backend-client/websocketClient/subscriptionsManager.ts b/packages/dashboard-frontend/src/services/backend-client/websocketClient/subscriptionsManager.ts index b5e334f902..7177da3211 100644 --- a/packages/dashboard-frontend/src/services/backend-client/websocketClient/subscriptionsManager.ts +++ b/packages/dashboard-frontend/src/services/backend-client/websocketClient/subscriptionsManager.ts @@ -20,7 +20,8 @@ export type SubscriptionArgs = channel: | api.webSocket.Channel.DEV_WORKSPACE | api.webSocket.Channel.EVENT - | api.webSocket.Channel.POD, + | api.webSocket.Channel.POD + | api.webSocket.Channel.CONFIGMAP, namespace: string, options: { getResourceVersion: ReturnResourceVersion }, ] diff --git a/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx index d7ed199d41..f0ca6d06ed 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx @@ -11,6 +11,8 @@ */ import { container } from '@/inversify.config'; +import { aiAgentRegistryActionCreators } from '@/plugins/dashboard-ai-agent/store/AiAgentRegistry'; +import { actionCreators as localDevfilesActionCreators } from '@/plugins/dashboard-ai-agent/store/LocalDevfiles'; import { provisionKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; import { WebsocketClient } from '@/services/backend-client/websocketClient'; import Bootstrap from '@/services/bootstrap'; @@ -96,6 +98,12 @@ jest .spyOn(workspacePreferencesActionCreators, 'requestPreferences') .mockImplementation(() => jest.fn()); jest.spyOn(bannerAlertActionCreators, 'addBanner').mockImplementation(() => jest.fn()); +jest + .spyOn(localDevfilesActionCreators, 'subscribeToAgentPodChanges') + .mockImplementation(() => jest.fn()); +jest + .spyOn(aiAgentRegistryActionCreators, 'requestAiAgentRegistry') + .mockImplementation(() => jest.fn().mockResolvedValue(undefined)); const mockWebsocketClient = { connect: jest.fn(), diff --git a/packages/dashboard-frontend/src/services/bootstrap/index.ts b/packages/dashboard-frontend/src/services/bootstrap/index.ts index 354ac86d13..00fc881ddc 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/index.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/index.ts @@ -14,6 +14,8 @@ import common, { api } from '@eclipse-che/common'; import { Store } from 'redux'; import { lazyInject } from '@/inversify.config'; +import { bootstrapPlugins } from '@/plugin-registry'; +import { actionCreators as localDevfilesActionCreators } from '@/plugins/dashboard-ai-agent/store/LocalDevfiles'; import { updateFavicon } from '@/preload/brandingLoader'; import { provisionKubernetesNamespace } from '@/services/backend-client/kubernetesNamespaceApi'; import { WebsocketClient } from '@/services/backend-client/websocketClient'; @@ -34,9 +36,7 @@ import { signIn } from '@/services/helpers/login'; import { ResourceFetcherService } from '@/services/resource-fetcher'; import { Workspace } from '@/services/workspace-adapter'; import { hasLoginPage, isForbidden, isUnauthorized } from '@/services/workspace-client/helpers'; -import { RootState } from '@/store'; -import { aiConfigActionCreators } from '@/store/AiConfig'; -import { selectAiConfigEnabled } from '@/store/AiConfig/selectors'; +import { AppThunk, RootState } from '@/store'; import { bannerAlertActionCreators } from '@/store/BannerAlert'; import { brandingActionCreators } from '@/store/Branding'; import { clusterConfigActionCreators, selectDashboardFavicon } from '@/store/ClusterConfig'; @@ -88,6 +88,10 @@ export default class Bootstrap { this.resourceFetcher = new ResourceFetcherService(); } + private dispatch(thunk: AppThunk): T { + return thunk(this.store.dispatch, this.store.getState, undefined); + } + async init(): Promise { await this.fetchServerConfig() .then(() => this.doBackendsSanityCheck()) @@ -122,10 +126,11 @@ export default class Bootstrap { }), this.fetchPods().then(() => { this.watchWebSocketPods(); + this.dispatch(localDevfilesActionCreators.subscribeToAgentPodChanges()); }), this.fetchSshKeys(), this.fetchWorkspacePreferences(), - this.fetchAiRegistry().then(() => this.fetchAiProviderKeyStatus()), + bootstrapPlugins(this.store), ]); const errors = results @@ -151,7 +156,7 @@ export default class Bootstrap { lastFetched: Date.now(), }), ); - } catch (e) { + } catch (e: unknown) { if (isUnauthorized(e) || (isForbidden(e) && hasLoginPage(e))) { signIn(); } @@ -169,7 +174,7 @@ export default class Bootstrap { const { requestClusterConfig } = clusterConfigActionCreators; try { await requestClusterConfig()(this.store.dispatch, this.store.getState, undefined); - } catch (e) { + } catch (e: unknown) { console.warn( 'Unable to fetch cluster configuration. This is expected behavior unless backend is configured to provide this information.', ); @@ -180,7 +185,7 @@ export default class Bootstrap { const { requestClusterInfo } = clusterInfoActionCreators; try { await requestClusterInfo()(this.store.dispatch, this.store.getState, undefined); - } catch (e) { + } catch (e: unknown) { console.warn( 'Unable to fetch cluster info. This is expected behavior unless backend is configured to provide this information.', ); @@ -191,7 +196,7 @@ export default class Bootstrap { const { requestBranding } = brandingActionCreators; try { await requestBranding()(this.store.dispatch, this.store.getState, undefined); - } catch (e) { + } catch (e: unknown) { const errorMessage = common.helpers.errors.getMessage(e); this.issuesReporterService.registerIssue('unknown', new Error(errorMessage)); } @@ -302,7 +307,7 @@ export default class Bootstrap { const { requestDwDefaultEditor } = devWorkspacePluginsActionCreators; try { await requestDwDefaultEditor()(this.store.dispatch, this.store.getState, undefined); - } catch (e) { + } catch (e: unknown) { const message = `Required sources failed when trying to create the workspace: ${e}`; const { addBanner } = bannerAlertActionCreators; addBanner(message)(this.store.dispatch, this.store.getState, undefined); @@ -315,7 +320,7 @@ export default class Bootstrap { const { requestDwDefaultPlugins } = devWorkspacePluginsActionCreators; try { await requestDwDefaultPlugins()(this.store.dispatch, this.store.getState, undefined); - } catch (e) { + } catch (e: unknown) { console.error('Failed to retrieve default plug-ins.', e); } } @@ -324,7 +329,7 @@ export default class Bootstrap { const { requestNamespaces } = infrastructureNamespacesActionCreators; try { await requestNamespaces()(this.store.dispatch, this.store.getState, undefined); - } catch (e) { + } catch (e: unknown) { console.error(e); } } @@ -333,7 +338,7 @@ export default class Bootstrap { const { requestServerConfig } = serverConfigActionCreators; try { await requestServerConfig()(this.store.dispatch, this.store.getState, undefined); - } catch (e) { + } catch (e: unknown) { console.error(e); } } @@ -420,7 +425,7 @@ export default class Bootstrap { const error = this.workspaceStoppedDetector.getWorkspaceStoppedError(stoppedWorkspace, type); this.issuesReporterService.registerIssue(type, error, workspaceData); - } catch (e) { + } catch (e: unknown) { if (e instanceof WorkspaceRunningError) { if (e.workspace.ideUrl) { const ideUrl = e.workspace.ideUrl; @@ -457,29 +462,6 @@ export default class Bootstrap { await requestSshKeys()(this.store.dispatch, this.store.getState, undefined); } - private async fetchAiRegistry(): Promise { - const { requestAiRegistry } = aiConfigActionCreators; - try { - await requestAiRegistry()(this.store.dispatch, this.store.getState, undefined); - } catch (e) { - console.warn('Unable to fetch AI registry.', e); - } - } - - private async fetchAiProviderKeyStatus(): Promise { - const state = this.store.getState(); - const enabled = selectAiConfigEnabled(state); - if (!enabled) { - return; - } - const { requestAiProviderKeyStatus } = aiConfigActionCreators; - try { - await requestAiProviderKeyStatus()(this.store.dispatch, this.store.getState, undefined); - } catch (e) { - console.warn('Unable to fetch AI provider key status.', e); - } - } - private async fetchWorkspacePreferences(): Promise { await workspacePreferencesActionCreators.requestPreferences()( this.store.dispatch, diff --git a/packages/dashboard-frontend/src/services/helpers/location.ts b/packages/dashboard-frontend/src/services/helpers/location.ts index 6c1c064b7a..e2531b3c6e 100644 --- a/packages/dashboard-frontend/src/services/helpers/location.ts +++ b/packages/dashboard-frontend/src/services/helpers/location.ts @@ -51,7 +51,7 @@ export function buildWorkspaceDetailsLocation(workspace: Workspace): Location { return _buildLocationObject(path); } -export function buildUserPreferencesLocation(tab?: UserPreferencesTab): Location { +export function buildUserPreferencesLocation(tab?: UserPreferencesTab | string): Location { let pathAndQuery: string; if (!tab) { pathAndQuery = ROUTE.USER_PREFERENCES; diff --git a/packages/dashboard-frontend/src/store/__mocks__/mockStore.ts b/packages/dashboard-frontend/src/store/__mocks__/mockStore.ts index fd59188d80..b207c8dafb 100644 --- a/packages/dashboard-frontend/src/store/__mocks__/mockStore.ts +++ b/packages/dashboard-frontend/src/store/__mocks__/mockStore.ts @@ -30,7 +30,25 @@ export class MockStoreBuilder { private state: Partial; constructor(state: Partial = {}) { - this.state = { ...state }; + // Provide sensible defaults for plugin-sourced reducers that the stub returns as {}. + // Without this, selectors like selectAiTools would return undefined when the + // ai-selector plugin is not symlinked (local development without prepare-plugins.sh). + this.state = { + aiConfig: { + providers: [], + tools: [], + defaultAiProviders: [], + providerKeyExists: {}, + isLoading: false, + error: undefined, + }, + aiAgentRegistry: { + agents: [], + isLoading: false, + error: undefined, + } as unknown as RootState['aiAgentRegistry'], + ...state, + }; } public withDwServerConfig(config: Partial): MockStoreBuilder { diff --git a/packages/dashboard-frontend/src/store/rootReducer.ts b/packages/dashboard-frontend/src/store/rootReducer.ts index 11df18a5ea..7a7804482f 100644 --- a/packages/dashboard-frontend/src/store/rootReducer.ts +++ b/packages/dashboard-frontend/src/store/rootReducer.ts @@ -10,7 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ -import { aiConfigReducer } from '@/store/AiConfig'; +import { reducer as aiConfigReducer } from '@/plugins/ai-selector/store/AiConfig/reducer'; +import { aiAgentRegistryReducer } from '@/plugins/dashboard-ai-agent/store/AiAgentRegistry'; +import { devfileSchemaReducer } from '@/plugins/dashboard-ai-agent/store/DevfileSchema'; +import { devWorkspaceSchemaReducer } from '@/plugins/dashboard-ai-agent/store/DevWorkspaceSchema'; +import { localDevfilesReducer } from '@/plugins/dashboard-ai-agent/store/LocalDevfiles'; import { backupsReducer } from '@/store/Backups'; import { bannerAlertReducer } from '@/store/BannerAlert'; import { brandingReducer } from '@/store/Branding'; @@ -39,7 +43,11 @@ import { devWorkspacesReducer } from '@/store/Workspaces/devWorkspaces'; import { workspacePreferencesReducer } from '@/store/Workspaces/Preferences'; export const rootReducer = { + aiAgentRegistry: aiAgentRegistryReducer, aiConfig: aiConfigReducer, + devfileSchema: devfileSchemaReducer, + devWorkspaceSchema: devWorkspaceSchemaReducer, + localDevfiles: localDevfilesReducer, backups: backupsReducer, bannerAlert: bannerAlertReducer, branding: brandingReducer, diff --git a/packages/dashboard-frontend/tsconfig.json b/packages/dashboard-frontend/tsconfig.json index b4abbfcba7..52fe68e406 100644 --- a/packages/dashboard-frontend/tsconfig.json +++ b/packages/dashboard-frontend/tsconfig.json @@ -18,9 +18,21 @@ "paths": { "@/*": [ "./src/*" + ], + "@/plugins/*": [ + "./src/plugins/*" + ], + "@/components/*": [ + "./src/components/*", + "./src/plugins/dashboard-ai-agent/components/*" + ], + "@/pages/*": [ + "./src/pages/*", + "./src/plugins/dashboard-ai-agent/pages/*" ] }, "preserveConstEnums": true, + "preserveSymlinks": true, "removeComments": true, "resolveJsonModule": true, "rootDir": "src", @@ -38,5 +50,10 @@ "exclude": [ "node_modules", "lib", + "src/plugins/**/__tests__", + "src/plugins/**/*.spec.ts", + "src/plugins/**/*.spec.tsx", + "src/plugins/**/*.test.ts", + "src/plugins/**/*.test.tsx", ], } \ No newline at end of file diff --git a/packages/dashboard-frontend/webpack.config.common.js b/packages/dashboard-frontend/webpack.config.common.js index f24a23d252..fb19fd0439 100644 --- a/packages/dashboard-frontend/webpack.config.common.js +++ b/packages/dashboard-frontend/webpack.config.common.js @@ -11,6 +11,7 @@ */ const CopyPlugin = require('copy-webpack-plugin'); +const fs = require('fs'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const path = require('path'); const webpack = require('webpack'); @@ -73,7 +74,16 @@ const config = { { test: /\.tsx?$/, include: path.join(__dirname, 'src'), - use: ['ts-loader'], + use: [{ + loader: 'ts-loader', + options: { + // TS6059: file is not under 'rootDir'. Plugin files are mounted via + // symlinks outside the dashboard src tree, so TypeScript raises this + // error for them. Suppressing it here lets the build succeed while + // symlink-based plugins are compiled alongside core dashboard code. + ignoreDiagnostics: [6059], + }, + }], exclude: /node_modules/, }, { @@ -88,10 +98,49 @@ const config = { }, resolve: { extensions: ['.js', '.ts', '.tsx'], - alias: { - // alias for absolute imports (see tsconfig.json) - '@': path.resolve(__dirname, 'src/'), - }, + symlinks: false, + alias: (() => { + // IMPORTANT: webpack alias resolution uses first-match-wins (forEachBail). + // Specific plugin aliases MUST be listed BEFORE the general '@' alias, + // otherwise '@' intercepts all '@/...' imports and plugin aliases never fire. + const pluginsDir = path.join(__dirname, 'src/plugins'); + const pluginAliases = {}; + + if (fs.existsSync(pluginsDir)) { + const pluginDirs = fs.readdirSync(pluginsDir).filter(n => { + const full = path.join(pluginsDir, n); + try { + return (fs.lstatSync(full).isSymbolicLink() || fs.statSync(full).isDirectory()) + && !n.endsWith('.ts') && !n.endsWith('.tsx'); + } catch { return false; } + }); + + for (const pluginName of pluginDirs) { + const pluginDir = path.resolve(pluginsDir, pluginName); + pluginAliases[`@/plugins/${pluginName}`] = pluginDir; + + // For components and pages the plugin provides, alias the @/components/ + // and @/pages/ paths to the plugin dir when not present in dashboard src. + for (const subdir of ['components', 'pages']) { + const pluginSubdir = path.join(pluginDir, subdir); + if (!fs.existsSync(pluginSubdir)) continue; + for (const entry of fs.readdirSync(pluginSubdir)) { + const dashboardPath = path.join(__dirname, 'src', subdir, entry); + if (!fs.existsSync(dashboardPath)) { + pluginAliases[`@/${subdir}/${entry}`] = path.resolve(pluginSubdir, entry); + } + } + } + } + } + + return { + // Specific plugin aliases come FIRST so they win over the general '@' alias + ...pluginAliases, + // General '@' alias for absolute imports (see tsconfig.json) — must be LAST + '@': path.resolve(__dirname, 'src/'), + }; + })(), fallback: { "fs": false, "net": false, diff --git a/packages/dashboard-plugins/.eslintrc.js b/packages/dashboard-plugins/.eslintrc.js new file mode 100644 index 0000000000..d33d814852 --- /dev/null +++ b/packages/dashboard-plugins/.eslintrc.js @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +module.exports = { + env: { + node: true, + }, + extends: [ + '../../.eslintrc.js', + ], + rules: { + 'notice/notice': [ + 'error', + { + templateFile: '../../.config/copyright.js', + onNonMatchingHeader: 'report', + messages: { + reportAndSkip: 'Missing license header', + }, + }, + ], + }, +}; diff --git a/packages/dashboard-plugins/.prettierrc b/packages/dashboard-plugins/.prettierrc new file mode 100644 index 0000000000..7eb3c265ea --- /dev/null +++ b/packages/dashboard-plugins/.prettierrc @@ -0,0 +1,5 @@ +{ + "arrowParens": "avoid", + "trailingComma": "all", + "singleQuote": true +} diff --git a/packages/dashboard-plugins/jest.config.js b/packages/dashboard-plugins/jest.config.js new file mode 100644 index 0000000000..29a58a07d5 --- /dev/null +++ b/packages/dashboard-plugins/jest.config.js @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +const base = require('../../jest.config.base'); + +module.exports = { + ...base, + moduleNameMapper: { + '^@plugins/(.*)$': '/src/$1', + }, +}; diff --git a/packages/dashboard-plugins/package.json b/packages/dashboard-plugins/package.json new file mode 100644 index 0000000000..30bfc29bce --- /dev/null +++ b/packages/dashboard-plugins/package.json @@ -0,0 +1,47 @@ +{ + "name": "@eclipse-che/dashboard-plugins", + "version": "7.121.0-next", + "repository": "https://github.com/eclipse-che/che-dashboard", + "license": "EPL-2.0", + "private": true, + "main": "lib/index.js", + "scripts": { + "build": "yarn clean && tsc -b", + "build:dev": "yarn build", + "watch": "yarn build && tsc -w", + "clean": "rimraf lib tsconfig.tsbuildinfo", + "format:check": "yarn prettier --config .prettierrc --check src/", + "format:fix": "yarn prettier --config .prettierrc --write src/", + "lint:check": "tsc --noEmit && eslint '*/**/*.{js,ts,tsx}'", + "lint:fix": "tsc --noEmit && eslint '*/**/*.{js,ts,tsx}' --fix", + "test": "jest --passWithNoTests", + "test:watch": "yarn test --watch" + }, + "dependencies": { + "@devfile/api": "2.3.0-1757407014", + "@eclipse-che/common": "7.121.0-next", + "@kubernetes/client-node": "^1.3.0", + "@reduxjs/toolkit": "^2.2.7", + "axios": "^1.15.2", + "fastify": "^5.8.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-redux": "^9.1.2" + }, + "devDependencies": { + "@types/jest": "^29.5.3", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^6.3.0", + "@typescript-eslint/parser": "^6.3.0", + "eslint": "^8.57.1", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-notice": "^0.9.10", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^30.2.0", + "prettier": "^3.2.5", + "rimraf": "^5.0.1", + "ts-jest": "^29.4.6", + "typescript": "^5.1.6" + } +} diff --git a/packages/dashboard-plugins/src/backend/index.ts b/packages/dashboard-plugins/src/backend/index.ts new file mode 100644 index 0000000000..98926bfa09 --- /dev/null +++ b/packages/dashboard-plugins/src/backend/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +export { + getRegisteredBackendPlugins, + registerBackendPlugin, +} from '../registry'; diff --git a/packages/dashboard-plugins/src/frontend/PluginSlot.tsx b/packages/dashboard-plugins/src/frontend/PluginSlot.tsx new file mode 100644 index 0000000000..304390b091 --- /dev/null +++ b/packages/dashboard-plugins/src/frontend/PluginSlot.tsx @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; + +import { getRegisteredFrontendPlugins } from '../registry'; +import { + FrontendPlugin, + LoaderTabDefinition, + NavigationItemDefinition, + PluginSlotProps, + PluginSlots, +} from '../types'; + +function collectFromPlugins( + extractor: (slots: PluginSlots) => T | undefined, + mapper: (plugin: FrontendPlugin, value: T) => R, +): R[] { + const plugins = getRegisteredFrontendPlugins(); + const results: R[] = []; + for (const plugin of plugins) { + const value = extractor(plugin.slots); + if (value !== undefined) { + results.push(mapper(plugin, value)); + } + } + return results; +} + +export const PluginSlot: React.FC = ({ name, props = {} }) => { + const plugins = getRegisteredFrontendPlugins(); + + return ( + <> + {plugins.map(plugin => { + const slotValue = plugin.slots[name]; + if (!slotValue) { + return null; + } + + if ( + name === 'workspaceCreation' || + name === 'workspaceDetailsOverview' + ) { + const SlotComponent = slotValue as React.ComponentType< + Record + >; + return ; + } + + return null; + })} + + ); +}; + +export function getPluginTabs(): Array<{ + pluginId: string; + name: string; + tabKey: string; + component: React.ComponentType; + visible?: (state: unknown) => boolean; +}> { + return collectFromPlugins( + slots => slots.userPreferencesTab, + (plugin, tab) => ({ + pluginId: plugin.manifest.id, + name: tab.name, + tabKey: tab.key, + component: tab.component, + visible: tab.visible, + }), + ); +} + +export function getPluginColumns(): Array<{ + pluginId: string; + name: string; + component: React.ComponentType>; + visible?: (state: unknown) => boolean; +}> { + return collectFromPlugins( + slots => slots.workspacesListColumn, + (plugin, col) => ({ + pluginId: plugin.manifest.id, + name: col.name, + component: col.component, + visible: col.visible, + }), + ); +} + +export function getPluginNavigationItems(): NavigationItemDefinition[] { + const plugins = getRegisteredFrontendPlugins(); + const items: NavigationItemDefinition[] = []; + for (const plugin of plugins) { + if (plugin.slots.navigationItems) { + items.push(...plugin.slots.navigationItems); + } + } + return items; +} + +export function getPluginLoaderTabs(): LoaderTabDefinition[] { + const plugins = getRegisteredFrontendPlugins(); + const tabs: LoaderTabDefinition[] = []; + for (const plugin of plugins) { + if (plugin.slots.loaderTabs) { + tabs.push(...plugin.slots.loaderTabs); + } + } + return tabs; +} diff --git a/packages/dashboard-plugins/src/frontend/index.ts b/packages/dashboard-plugins/src/frontend/index.ts new file mode 100644 index 0000000000..d4fa81d0fa --- /dev/null +++ b/packages/dashboard-plugins/src/frontend/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +export { getPluginColumns, getPluginTabs, PluginSlot } from './PluginSlot'; +export { + bootstrapPlugins, + getPluginFactoryParams, + getPluginReducers, + registerFrontendPlugin, + runPluginCreateHooks, + runPluginStartHooks, +} from '../registry'; diff --git a/packages/dashboard-plugins/src/index.ts b/packages/dashboard-plugins/src/index.ts new file mode 100644 index 0000000000..f654c55f30 --- /dev/null +++ b/packages/dashboard-plugins/src/index.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +export type { + BackendPlugin, + ColumnDefinition, + FactoryParamExtension, + FrontendPlugin, + LoaderTabDefinition, + NavigationItemDefinition, + PluginManifest, + PluginSlotProps, + PluginSlots, + TabDefinition, + WorkspaceHooks, +} from './types'; + +export { + bootstrapPlugins, + getPluginFactoryParams, + getPluginReducers, + getRegisteredBackendPlugins, + getRegisteredFrontendPlugins, + registerBackendPlugin, + registerFrontendPlugin, + runPluginCreateHooks, + runPluginStartHooks, +} from './registry'; + +export { + getPluginColumns, + getPluginLoaderTabs, + getPluginNavigationItems, + getPluginTabs, + PluginSlot, +} from './frontend/PluginSlot'; diff --git a/packages/dashboard-plugins/src/registry.ts b/packages/dashboard-plugins/src/registry.ts new file mode 100644 index 0000000000..bc1def100d --- /dev/null +++ b/packages/dashboard-plugins/src/registry.ts @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { Reducer, Store } from 'redux'; + +import { BackendPlugin, FrontendPlugin } from './types'; + +const backendPlugins: BackendPlugin[] = []; +const frontendPlugins: FrontendPlugin[] = []; + +export function registerBackendPlugin(plugin: BackendPlugin): void { + if (!plugin.manifest.enabled) { + return; + } + backendPlugins.push(plugin); +} + +export function registerFrontendPlugin(plugin: FrontendPlugin): void { + if (!plugin.manifest.enabled) { + return; + } + if (frontendPlugins.some(p => p.manifest.id === plugin.manifest.id)) { + console.warn( + `[PluginSystem] Plugin "${plugin.manifest.id}" is already registered. Skipping duplicate registration.`, + ); + return; + } + frontendPlugins.push(plugin); +} + +export function getRegisteredBackendPlugins(): BackendPlugin[] { + return [...backendPlugins]; +} + +export function getRegisteredFrontendPlugins(): FrontendPlugin[] { + return [...frontendPlugins]; +} + +export function getPluginReducers(): Record { + const reducers: Record = {}; + for (const plugin of frontendPlugins) { + reducers[plugin.reducerKey] = plugin.reducer; + } + return reducers; +} + +export async function bootstrapPlugins(store: Store): Promise { + const results = await Promise.allSettled( + frontendPlugins + .filter(p => p.bootstrap !== undefined) + .map(p => p.bootstrap!(store)), + ); + for (const result of results) { + if (result.status === 'rejected') { + console.warn('Plugin bootstrap failed:', result.reason); + } + } +} + +export function runPluginCreateHooks( + workspace: Record, + factoryParams: Record, +): Record { + let result = workspace; + for (const plugin of frontendPlugins) { + if (plugin.workspaceHooks?.onWorkspaceCreate) { + result = plugin.workspaceHooks.onWorkspaceCreate(result, factoryParams); + } + } + return result; +} + +export function runPluginStartHooks( + workspace: Record, +): Record | null { + let result: Record | null = workspace; + for (const plugin of frontendPlugins) { + if (plugin.workspaceHooks?.onWorkspaceStart && result !== null) { + result = plugin.workspaceHooks.onWorkspaceStart(result); + } + } + return result; +} + +export function getPluginFactoryParams(): Array<{ + paramName: string; + parse: (value: string) => string[]; +}> { + return frontendPlugins + .filter(p => p.slots.factoryParams !== undefined) + .map(p => p.slots.factoryParams!); +} diff --git a/packages/dashboard-plugins/src/types.ts b/packages/dashboard-plugins/src/types.ts new file mode 100644 index 0000000000..66ca81dbff --- /dev/null +++ b/packages/dashboard-plugins/src/types.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { FastifyInstance } from 'fastify'; +import { ComponentType, ReactNode } from 'react'; +import { Reducer, Store } from 'redux'; + +export interface PluginManifest { + id: string; + name: string; + version: string; + description: string; + enabled: boolean; +} + +export interface BackendPlugin { + manifest: PluginManifest; + registerRoutes: (server: FastifyInstance) => Promise; +} + +export interface FrontendPlugin { + manifest: PluginManifest; + reducerKey: string; + reducer: Reducer; + bootstrap?: (store: Store) => Promise; + slots: PluginSlots; + workspaceHooks?: WorkspaceHooks; +} + +export interface TabDefinition { + name: string; + key: string; + component: ComponentType; + visible?: (state: unknown) => boolean; +} + +export interface ColumnDefinition { + name: string; + component: ComponentType>; + visible?: (state: unknown) => boolean; +} + +export interface FactoryParamExtension { + paramName: string; + parse: (value: string) => string[]; +} + +export interface NavigationItemDefinition { + to: string; + label: string; + labelSelector?: (state: unknown) => string; + insertAfter?: string; +} + +export interface LoaderTabDefinition { + key: string; + title: string; + component: ComponentType<{ workspace: unknown; isActive: boolean }>; + insertAfter?: string; +} + +export interface PluginSlots { + workspaceCreation?: ComponentType>; + workspaceDetailsOverview?: ComponentType>; + workspacesListColumn?: ColumnDefinition; + userPreferencesTab?: TabDefinition; + factoryParams?: FactoryParamExtension; + navigationItems?: NavigationItemDefinition[]; + loaderTabs?: LoaderTabDefinition[]; +} + +export interface WorkspaceHooks { + onWorkspaceCreate?: ( + workspace: Record, + factoryParams: Record, + ) => Record; + onWorkspaceStart?: ( + workspace: Record, + ) => Record | null; +} + +export interface PluginSlotProps { + name: keyof PluginSlots; + props?: Record; + children?: ReactNode; +} diff --git a/packages/dashboard-plugins/tsconfig.json b/packages/dashboard-plugins/tsconfig.json new file mode 100644 index 0000000000..f2eacef560 --- /dev/null +++ b/packages/dashboard-plugins/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2021", + "lib": [ + "ES2021", + "dom" + ], + "module": "commonjs", + "baseUrl": "./", + "moduleResolution": "node", + "paths": { + "@plugins/*": [ + "./src/*" + ] + }, + "jsx": "react", + "resolveJsonModule": true, + "outDir": "lib", + "rootDir": "src" + }, + "exclude": [ + "node_modules", + "lib" + ], + "include": [ + "src" + ] +} diff --git a/scripts/fetch-plugins.sh b/scripts/fetch-plugins.sh new file mode 100755 index 0000000000..68530b9312 --- /dev/null +++ b/scripts/fetch-plugins.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2018-2026 Red Hat, Inc. +# This program and the accompanying materials are made +# available under the terms of the Eclipse Public License 2.0 +# which is available at https://www.eclipse.org/legal/epl-2.0/ +# +# SPDX-License-Identifier: EPL-2.0 +# +# Fetches Eclipse Che Dashboard plugins and unpacks them into plugins/. +# +# Resolution order: +# 1. LOCAL_PLUGINS set → copy from local directory (dev mode) +# 2. PLUGINS_REF is a release tag and ZIPs exist → download from GitHub Releases +# 3. Fallback → git clone the repo at PLUGINS_REF (or main) +# +# Usage: +# bash scripts/fetch-plugins.sh +# bash scripts/fetch-plugins.sh --ref v1.2.0 +# +# Environment Variables: +# PLUGINS_REPO - GitHub repository (default: olexii4/che-dashboard-plugins) +# PLUGINS_REF - Release tag or git ref (default: latest → main branch) +# PLUGINS_DIR - Target directory (default: $PWD/plugins) +# LOCAL_PLUGINS - Path to a local clone; skips all network steps when set +# PLUGINS_LIST - JSON array of plugin names to install, e.g. '["ai-selector"]' +# When unset, all plugins from the release/clone are installed. +# + +set -euo pipefail + +PLUGINS_REPO="${PLUGINS_REPO:-olexii4/che-dashboard-plugins}" +PLUGINS_REF="${PLUGINS_REF:-latest}" +PLUGINS_DIR="${PLUGINS_DIR:-${PWD}/plugins}" +LOCAL_PLUGINS="${LOCAL_PLUGINS:-}" + +# --ref flag overrides PLUGINS_REF +while [[ $# -gt 0 ]]; do + case "$1" in + --ref) PLUGINS_REF="$2"; shift 2 ;; + *) echo "[ERROR] Unknown argument: $1"; exit 1 ;; + esac +done + +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT +mkdir -p "$PLUGINS_DIR" + +# ── Helper: copy all plugin dirs from a source path ────────────────────────── +copy_plugins_from_dir() { + local src="$1" + for d in "$src"/*/; do + [ -f "${d}plugin.json" ] || continue + local name + name=$(basename "$d") + echo "[INFO] Installing plugin: $name" + rm -rf "${PLUGINS_DIR}/${name}" + cp -r "$d" "${PLUGINS_DIR}/${name}" + done +} + +# ── Helper: print installed plugin versions ─────────────────────────────────── +print_versions() { + for d in "${PLUGINS_DIR}"/*/; do + [ -f "${d}plugin.json" ] || continue + local name version + name=$(basename "$d") + version=$(python3 -c "import json; print(json.load(open('${d}plugin.json')).get('version','?'))" 2>/dev/null || echo "?") + echo " ${name} v${version}" + done +} + +# ── Mode 1: local clone ─────────────────────────────────────────────────────── +if [[ -n "$LOCAL_PLUGINS" ]]; then + echo "[INFO] LOCAL_PLUGINS set — copying from: $LOCAL_PLUGINS" + [[ -d "$LOCAL_PLUGINS" ]] || { echo "[ERROR] LOCAL_PLUGINS not found: $LOCAL_PLUGINS"; exit 1; } + copy_plugins_from_dir "$LOCAL_PLUGINS" + echo "[INFO] Done."; print_versions; exit 0 +fi + +# ── Mode 2: GitHub Releases ─────────────────────────────────────────────────── +PLUGINS=() +if [[ -z "${PLUGINS_LIST:-}" ]]; then + if [[ "$PLUGINS_REF" == "latest" ]]; then + API_URL="https://api.github.com/repos/${PLUGINS_REPO}/releases/latest" + else + API_URL="https://api.github.com/repos/${PLUGINS_REPO}/releases/tags/${PLUGINS_REF}" + fi + echo "[INFO] Checking for GitHub Release: ${PLUGINS_REF}" + while IFS= read -r line; do [[ -n "$line" ]] && PLUGINS+=("$line"); done \ + < <(curl -sSL "$API_URL" | jq -r '.assets[]?.name | select(endswith(".zip")) | sub("\\.zip$"; "")' 2>/dev/null || true) +else + while IFS= read -r line; do [[ -n "$line" ]] && PLUGINS+=("$line"); done \ + < <(echo "$PLUGINS_LIST" | jq -r '.[]') +fi + +if [[ ${#PLUGINS[@]} -gt 0 ]]; then + BASE_URL="https://github.com/${PLUGINS_REPO}/releases/download/${PLUGINS_REF}" + echo "[INFO] Downloading from release: ${BASE_URL}" + for plugin in "${PLUGINS[@]}"; do + ZIP_FILE="${TMP_DIR}/${plugin}.zip" + echo "[INFO] Downloading ${plugin}.zip ..." + if ! curl -fsSL "${BASE_URL}/${plugin}.zip" -o "$ZIP_FILE"; then + echo "[ERROR] Failed to download ${BASE_URL}/${plugin}.zip"; exit 1 + fi + echo "[INFO] Unpacking ${plugin}.zip ..." + rm -rf "${PLUGINS_DIR}/${plugin}" + unzip -q "$ZIP_FILE" -d "$PLUGINS_DIR" + done + echo "[INFO] Done."; print_versions; exit 0 +fi + +# ── Mode 3: git clone fallback ──────────────────────────────────────────────── +GIT_REF="$PLUGINS_REF" +[[ "$GIT_REF" == "latest" ]] && GIT_REF="main" +echo "[INFO] No GitHub Release found — cloning ${PLUGINS_REPO}@${GIT_REF}" +git clone --depth 1 --branch "$GIT_REF" \ + "https://github.com/${PLUGINS_REPO}.git" "${TMP_DIR}/plugins-src" +copy_plugins_from_dir "${TMP_DIR}/plugins-src" +echo "[INFO] Done."; print_versions diff --git a/scripts/prepare-plugins.sh b/scripts/prepare-plugins.sh new file mode 100755 index 0000000000..82873a582b --- /dev/null +++ b/scripts/prepare-plugins.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Copyright (c) 2018-2025 Red Hat, Inc. +# This program and the accompanying materials are made +# available under the terms of the Eclipse Public License 2.0 +# which is available at https://www.eclipse.org/legal/epl-2.0/ +# +# SPDX-License-Identifier: EPL-2.0 +# +# Prepares plugins for build: +# 1. Unpacks any *.zip files in plugins/ +# 2. Creates symlinks from packages/{frontend,backend}/src/plugins/ to the plugin source +# 3. Auto-generates packages/dashboard-frontend/src/plugins/index.ts + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PLUGINS_DIR="$ROOT_DIR/plugins" +FRONTEND_PLUGINS="$ROOT_DIR/packages/dashboard-frontend/src/plugins" +BACKEND_PLUGINS="$ROOT_DIR/packages/dashboard-backend/src/plugins" + +# --- Step 1: Unpack ZIP files --- +for zipfile in "$PLUGINS_DIR"/*.zip; do + [ -f "$zipfile" ] || continue + dirname="$PLUGINS_DIR/$(basename "$zipfile" .zip)" + if [ ! -d "$dirname" ] || [ "$zipfile" -nt "$dirname" ]; then + echo "Unpacking plugin: $(basename "$zipfile")" + rm -rf "$dirname" + unzip -q -o "$zipfile" -d "$dirname" + fi +done + +# --- Step 2: Create symlinks --- +mkdir -p "$FRONTEND_PLUGINS" + +for plugindir in "$PLUGINS_DIR"/*/; do + [ -f "$plugindir/plugin.json" ] || continue + pluginname=$(basename "$plugindir") + + # Frontend symlink (skip if already a real directory, e.g. Docker copy) + if [ -d "$plugindir/frontend" ]; then + target="$FRONTEND_PLUGINS/$pluginname" + if [ ! -L "$target" ] && [ ! -d "$target" ]; then + ln -sfn "../../../../plugins/$pluginname/frontend" "$target" + echo "Linked frontend: $pluginname" + fi + fi + + # Backend symlink (skip if already a real directory, e.g. Docker copy) + if [ -d "$plugindir/backend" ]; then + target="$BACKEND_PLUGINS/$pluginname" + if [ ! -L "$target" ] && [ ! -d "$target" ]; then + ln -sfn "../../../../plugins/$pluginname/backend" "$target" + echo "Linked backend: $pluginname" + fi + fi +done + +# --- Step 3: Auto-generate frontend plugin registration --- +REGISTRY_FILE="$FRONTEND_PLUGINS/index.ts" +{ + cat <<'HEADER' +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +// AUTO-GENERATED by scripts/prepare-plugins.sh — do not edit manually + +import { registerFrontendPlugin } from '@/plugin-registry'; + +HEADER + + # Collect enabled plugins + imports="" + registrations="" + for plugindir in "$PLUGINS_DIR"/*/; do + [ -f "$plugindir/plugin.json" ] || continue + pluginname=$(basename "$plugindir") + + enabled=$(python3 -c "import json; print(json.load(open('$plugindir/plugin.json')).get('enabled', False))" 2>/dev/null || echo "false") + [ "$enabled" = "True" ] || continue + + has_frontend=$([ -f "$plugindir/frontend/plugin.ts" ] || [ -f "$plugindir/frontend/plugin.tsx" ] && echo "yes" || echo "no") + [ "$has_frontend" = "yes" ] || continue + + # Convert plugin-name to camelCase variable name (e.g., ai-selector -> aiSelectorPlugin) + varname=$(python3 -c " +import re +name = '$pluginname' +parts = name.split('-') +print(parts[0] + ''.join(w.capitalize() for w in parts[1:]) + 'Plugin') +") + + imports="${imports}import { ${varname} } from '@/plugins/${pluginname}/plugin';\n" + registrations="${registrations} registerFrontendPlugin(${varname});\n" + done + + echo -e "$imports" + echo "export function registerAllPlugins(): void {" + echo -e "$registrations}" +} > "$REGISTRY_FILE" + +echo "Generated: $REGISTRY_FILE" +echo "Plugin preparation complete." diff --git a/yarn.lock b/yarn.lock index d6b8cd6721..efb3083837 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1055,7 +1055,7 @@ __metadata: languageName: node linkType: hard -"@eclipse-che/common@workspace:packages/common": +"@eclipse-che/common@npm:7.121.0-next, @eclipse-che/common@workspace:packages/common": version: 0.0.0-use.local resolution: "@eclipse-che/common@workspace:packages/common" dependencies: @@ -1089,7 +1089,7 @@ __metadata: "@fastify/http-proxy": "npm:^11.4.4" "@fastify/oauth2": "npm:^8.2.0" "@fastify/rate-limit": "npm:^10.3.0" - "@fastify/static": "npm:^9.1.1" + "@fastify/static": "npm:^9.0.0" "@fastify/swagger": "npm:^9.7.0" "@fastify/swagger-ui": "npm:^5.2.5" "@fastify/websocket": "npm:^11.2.0" @@ -1149,6 +1149,7 @@ __metadata: "@codemirror/language": "npm:^6.10.8" "@codemirror/view": "npm:^6.36.2" "@devfile/api": "npm:2.3.0-1747843475" + "@eclipse-che/dashboard-plugins": "workspace:*" "@kubernetes/client-node": "npm:^0.22.1" "@lezer/highlight": "npm:^1.2.1" "@patternfly/react-core": "npm:^6.4.0" @@ -1248,6 +1249,36 @@ __metadata: languageName: unknown linkType: soft +"@eclipse-che/dashboard-plugins@workspace:*, @eclipse-che/dashboard-plugins@workspace:packages/dashboard-plugins": + version: 0.0.0-use.local + resolution: "@eclipse-che/dashboard-plugins@workspace:packages/dashboard-plugins" + dependencies: + "@devfile/api": "npm:2.3.0-1757407014" + "@eclipse-che/common": "npm:7.121.0-next" + "@kubernetes/client-node": "npm:^1.3.0" + "@reduxjs/toolkit": "npm:^2.2.7" + "@types/jest": "npm:^29.5.3" + "@types/react": "npm:^18.3.3" + "@types/react-dom": "npm:^18.3.0" + "@typescript-eslint/eslint-plugin": "npm:^6.3.0" + "@typescript-eslint/parser": "npm:^6.3.0" + axios: "npm:^1.15.2" + eslint: "npm:^8.57.1" + eslint-config-prettier: "npm:^9.0.0" + eslint-plugin-notice: "npm:^0.9.10" + eslint-plugin-prettier: "npm:^5.0.0" + fastify: "npm:^5.8.3" + jest: "npm:^30.2.0" + prettier: "npm:^3.2.5" + react: "npm:^18.3.1" + react-dom: "npm:^18.3.1" + react-redux: "npm:^9.1.2" + rimraf: "npm:^5.0.1" + ts-jest: "npm:^29.4.6" + typescript: "npm:^5.1.6" + languageName: unknown + linkType: soft + "@eclipse-che/dashboard@workspace:.": version: 0.0.0-use.local resolution: "@eclipse-che/dashboard@workspace:." @@ -2991,6 +3022,16 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:^18.3.3": + version: 18.3.31 + resolution: "@types/react@npm:18.3.31" + dependencies: + "@types/prop-types": "npm:*" + csstype: "npm:^3.2.2" + checksum: 10/0a5d73b1ba3ce73273b28cc50da78d520b1a8d4253075820dc5cf6c9e286ff6cb339b56bd613d05885fec214fbecd8137bddcbdb475612fdcc46e061d88f00fc + languageName: node + linkType: hard + "@types/redux-logger@npm:^3.0.13": version: 3.0.13 resolution: "@types/redux-logger@npm:3.0.13" @@ -5811,6 +5852,13 @@ __metadata: languageName: node linkType: hard +"csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10/ad41baf7e2ffac65ab544d79107bf7cd1a4bb9bab9ac3302f59ab4ba655d5e30942a8ae46e10ba160c6f4ecea464cc95b975ca2fefbdeeacd6ac63f12f99fe1f + languageName: node + linkType: hard + "dashdash@npm:^1.12.0": version: 1.14.1 resolution: "dashdash@npm:1.14.1" @@ -11858,14 +11906,14 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.5.13": - version: 8.5.13 - resolution: "postcss@npm:8.5.13" +"postcss@npm:^8.4.49": + version: 8.5.20 + resolution: "postcss@npm:8.5.20" dependencies: - nanoid: "npm:^3.3.11" + nanoid: "npm:^3.3.16" picocolors: "npm:^1.1.1" source-map-js: "npm:^1.2.1" - checksum: 10/4763873c0a39a6f71fc552d0046fedb80dca889ed0b85e36217285f754a3bd003a906f6088feb266b1f53b0ad20483a475dce4cc1c756f05e27940c51e40ebd8 + checksum: 10/bcae7818094f88030cb410f17efc312f24ee8f9bbc46483ac6536df5a9270f693c5fc41dcf0ccc254ab3fa6853d48468ee8707999ad1cc434a6084ed0a0e7a47 languageName: node linkType: hard