From 2ad367b185877e9739f317305233e907a04780e0 Mon Sep 17 00:00:00 2001 From: carlosthe19916 <2582866+carlosthe19916@users.noreply.github.com> Date: Wed, 17 Apr 2024 12:56:00 +0200 Subject: [PATCH] Move UI to trustify --- .gitignore | 2 + Cargo.lock | 265 +- Cargo.toml | 3 + common/ui/Cargo.toml | 10 + common/ui/src/lib.rs | 60 + server/Cargo.toml | 2 + server/src/lib.rs | 32 +- trustd/build.rs | 59 + ui/.containerignore | 2 + ui/.editorconfig | 13 + ui/.eslintrc.cjs | 94 + ui/.gitignore | 30 + ui/.npmrc | 2 + ui/.prettierignore | 12 + ui/.prettierrc.mjs | 14 + ui/BRANDING.md | 151 + ui/Containerfile | 38 + ui/README.md | 65 + ui/branding/favicon.ico | Bin 0 -> 15406 bytes ui/branding/images/logo.png | Bin 0 -> 6378 bytes ui/branding/images/logo192.png | Bin 0 -> 6378 bytes ui/branding/images/logo512.png | Bin 0 -> 24557 bytes ui/branding/images/masthead-logo.svg | 40 + ui/branding/manifest.json | 15 + ui/branding/strings.json | 21 + ui/client/config/jest.config.ts | 50 + ui/client/config/monacoConstants.ts | 22 + ui/client/config/stylePaths.js | 17 + ui/client/config/webpack.common.ts | 221 + ui/client/config/webpack.dev.ts | 121 + ui/client/config/webpack.prod.ts | 72 + ui/client/package.json | 112 + ui/client/public/index.html.ejs | 21 + ui/client/public/manifest.json | 15 + ui/client/public/mockServiceWorker.js | 303 + ui/client/public/robots.txt | 3 + ui/client/src/app/App.css | 10 + ui/client/src/app/App.tsx | 27 + ui/client/src/app/Constants.ts | 29 + ui/client/src/app/Routes.tsx | 70 + ui/client/src/app/analytics.ts | 6 + ui/client/src/app/api/model-utils.ts | 67 + ui/client/src/app/api/models.ts | 147 + ui/client/src/app/api/rest.ts | 182 + ui/client/src/app/axios-config/apiInit.ts | 30 + ui/client/src/app/axios-config/index.ts | 1 + ui/client/src/app/common/types.ts | 9 + .../src/app/components/AnalyticsProvider.tsx | 56 + .../src/app/components/AppPlaceholder.tsx | 17 + .../src/app/components/ConfirmDialog.tsx | 86 + ui/client/src/app/components/CveGallery.tsx | 57 + .../FilterToolbar/FilterControl.tsx | 62 + .../FilterToolbar/FilterToolbar.tsx | 239 + .../MultiselectFilterControl.tsx | 363 + .../FilterToolbar/SearchFilterControl.tsx | 78 + .../FilterToolbar/SelectFilterControl.tsx | 129 + .../src/app/components/FilterToolbar/index.ts | 1 + .../FilterToolbar/select-overrides.css | 4 + .../HookFormPFGroupController.tsx | 135 + .../HookFormPFFields/HookFormPFSelect.tsx | 61 + .../HookFormPFFields/HookFormPFTextArea.tsx | 54 + .../HookFormPFFields/HookFormPFTextInput.tsx | 66 + .../app/components/HookFormPFFields/index.ts | 4 + ui/client/src/app/components/IconedStatus.tsx | 119 + .../src/app/components/LoadingWrapper.tsx | 21 + .../src/app/components/NoDataEmptyState.tsx | 29 + .../src/app/components/Notifications.tsx | 36 + .../app/components/NotificationsContext.tsx | 66 + ui/client/src/app/components/OidcProvider.tsx | 54 + .../src/app/components/PageDrawerContext.tsx | 199 + .../app/components/SeverityShieldAndText.tsx | 35 + .../SimplePagination/SimplePagination.tsx | 42 + .../app/components/SimplePagination/index.ts | 1 + ui/client/src/app/components/StateError.tsx | 27 + ui/client/src/app/components/StateNoData.tsx | 11 + .../src/app/components/StateNoResults.tsx | 25 + .../TableControls/ConditionalTableBody.tsx | 57 + .../TableHeaderContentWithControls.tsx | 26 + .../TableRowContentWithControls.tsx | 51 + .../src/app/components/TableControls/index.ts | 3 + .../app/components/ToolbarBulkSelector.tsx | 144 + .../app/components/markdownPFComponents.tsx | 22 + .../src/app/components/notes-markdown.tsx | 29 + ui/client/src/app/env.ts | 5 + .../active-item/getActiveItemDerivedState.ts | 72 + .../hooks/table-controls/active-item/index.ts | 4 + .../active-item/useActiveItemEffects.ts | 41 + .../active-item/useActiveItemPropHelpers.ts | 69 + .../active-item/useActiveItemState.ts | 82 + .../table-controls/column/useColumnState.ts | 28 + .../expansion/getExpansionDerivedState.ts | 86 + .../hooks/table-controls/expansion/index.ts | 3 + .../expansion/useExpansionPropHelpers.ts | 147 + .../expansion/useExpansionState.ts | 117 + .../filtering/getFilterHubRequestParams.ts | 191 + .../filtering/getLocalFilterDerivedState.ts | 69 + .../hooks/table-controls/filtering/helpers.ts | 43 + .../hooks/table-controls/filtering/index.ts | 5 + .../filtering/useFilterPropHelpers.ts | 64 + .../filtering/useFilterState.ts | 110 + .../table-controls/getHubRequestParams.ts | 59 + .../getLocalTableControlDerivedState.ts | 54 + .../src/app/hooks/table-controls/index.ts | 12 + .../getLocalPaginationDerivedState.ts | 36 + .../getPaginationHubRequestParams.ts | 44 + .../hooks/table-controls/pagination/index.ts | 5 + .../pagination/usePaginationEffects.ts | 35 + .../pagination/usePaginationPropHelpers.ts | 70 + .../pagination/usePaginationState.ts | 133 + .../sorting/getLocalSortDerivedState.ts | 72 + .../sorting/getSortHubRequestParams.ts | 56 + .../app/hooks/table-controls/sorting/index.ts | 4 + .../sorting/useSortPropHelpers.ts | 84 + .../table-controls/sorting/useSortState.ts | 117 + .../src/app/hooks/table-controls/types.ts | 485 + .../table-controls/useLocalTableControls.ts | 49 + .../table-controls/useTableControlProps.ts | 199 + .../table-controls/useTableControlState.ts | 92 + .../src/app/hooks/table-controls/utils.ts | 36 + ui/client/src/app/hooks/useBranding.ts | 12 + .../src/app/hooks/useCreateEditModalState.ts | 18 + ui/client/src/app/hooks/useDownload.ts | 28 + ui/client/src/app/hooks/usePersistentState.ts | 98 + ui/client/src/app/hooks/useSelectionState.ts | 102 + ui/client/src/app/hooks/useStorage.ts | 113 + ui/client/src/app/hooks/useUpload.ts | 256 + ui/client/src/app/hooks/useUrlParams.ts | 151 + ui/client/src/app/images/avatar.svg | 18 + ui/client/src/app/images/pfbg-icon.svg | 1 + ui/client/src/app/layout/about.tsx | 72 + ui/client/src/app/layout/default-layout.tsx | 23 + ui/client/src/app/layout/header.tsx | 189 + ui/client/src/app/layout/index.ts | 1 + ui/client/src/app/layout/layout-constants.ts | 2 + ui/client/src/app/layout/sidebar.tsx | 83 + ui/client/src/app/oidc.ts | 12 + .../advisory-details/advisory-details.tsx | 146 + .../src/app/pages/advisory-details/cves.tsx | 171 + .../src/app/pages/advisory-details/index.ts | 1 + .../app/pages/advisory-details/overview.tsx | 168 + .../src/app/pages/advisory-details/source.tsx | 30 + .../app/pages/advisory-list/advisory-list.tsx | 237 + .../components/CVEsGaleryCount.tsx | 24 + .../components/UploadFilesDrawer.tsx | 191 + .../src/app/pages/advisory-list/index.ts | 1 + .../src/app/pages/cve-details/cve-details.tsx | 151 + ui/client/src/app/pages/cve-details/index.ts | 1 + .../pages/cve-details/related-advisories.tsx | 135 + .../app/pages/cve-details/related-sboms.tsx | 180 + .../src/app/pages/cve-details/source.tsx | 30 + ui/client/src/app/pages/cve-list/cve-list.tsx | 203 + ui/client/src/app/pages/cve-list/index.ts | 1 + ui/client/src/app/pages/home/home.tsx | 5 + ui/client/src/app/pages/home/index.ts | 1 + .../components/importer-form.tsx | 191 + .../components/importer-status-icon.tsx | 35 + .../app/pages/importer-list/importer-list.tsx | 306 + .../src/app/pages/importer-list/index.ts | 1 + .../src/app/pages/package-details/index.ts | 1 + .../pages/package-details/package-details.tsx | 93 + .../pages/package-details/related-cves.tsx | 142 + .../pages/package-details/related-sboms.tsx | 138 + ui/client/src/app/pages/package-list/index.ts | 1 + .../app/pages/package-list/package-list.tsx | 224 + ui/client/src/app/pages/sbom-details/cves.tsx | 178 + .../sbom-details/dependency-analytics.tsx | 9 + ui/client/src/app/pages/sbom-details/index.ts | 1 + .../src/app/pages/sbom-details/overview.tsx | 135 + .../src/app/pages/sbom-details/packages.tsx | 197 + .../app/pages/sbom-details/sbom-details.tsx | 127 + .../src/app/pages/sbom-details/source.tsx | 30 + ui/client/src/app/pages/sbom-list/index.ts | 1 + .../src/app/pages/sbom-list/sbom-list.tsx | 207 + ui/client/src/app/queries/advisories.ts | 65 + ui/client/src/app/queries/cves.ts | 54 + ui/client/src/app/queries/importers.ts | 99 + ui/client/src/app/queries/packages.ts | 39 + ui/client/src/app/queries/sboms.ts | 94 + ui/client/src/app/react-app-env.d.ts | 1 + ui/client/src/app/reportWebVitals.ts | 15 + ui/client/src/app/test-config/setupTests.ts | 5 + ui/client/src/app/utils/query-utils.ts | 33 + ui/client/src/app/utils/type-utils.ts | 12 + ui/client/src/app/utils/utils.test.ts | 15 + ui/client/src/app/utils/utils.ts | 103 + ui/client/src/index.tsx | 59 + ui/client/src/mocks/browser.ts | 32 + ui/client/src/mocks/config.test.ts | 114 + ui/client/src/mocks/config.ts | 78 + ui/client/src/mocks/server.ts | 0 .../src/mocks/stub-new-work/advisories.ts | 79 + ui/client/src/mocks/stub-new-work/cves.ts | 41 + ui/client/src/mocks/stub-new-work/index.ts | 22 + ui/client/src/mocks/stub-new-work/packages.ts | 23 + ui/client/src/mocks/stub-new-work/sboms.ts | 74 + ui/client/tsconfig.json | 39 + .../types/@hookform_resolvers_2.9.11.d.ts | 35 + ui/client/types/array-filter-Boolean.ts | 28 + ui/client/types/globals.d.ts | 10 + ui/client/types/typings.d.ts | 56 + ui/common/package.json | 35 + ui/common/rollup.config.js | 66 + ui/common/src/branding-strings-stub.json | 21 + ui/common/src/branding.ts | 48 + ui/common/src/environment.ts | 103 + ui/common/src/index.ts | 24 + ui/common/src/proxies.ts | 33 + ui/common/tsconfig.json | 23 + ui/entrypoint.sh | 28 + ui/package-lock.json | 20236 ++++++++++++++++ ui/package.json | 70 + ui/scripts/verify_lock.mjs | 66 + ui/server/package.json | 26 + ui/server/rollup.config.js | 32 + ui/server/src/index.js | 76 + 215 files changed, 34634 insertions(+), 2 deletions(-) create mode 100644 common/ui/Cargo.toml create mode 100644 common/ui/src/lib.rs create mode 100644 trustd/build.rs create mode 100644 ui/.containerignore create mode 100644 ui/.editorconfig create mode 100644 ui/.eslintrc.cjs create mode 100644 ui/.gitignore create mode 100644 ui/.npmrc create mode 100644 ui/.prettierignore create mode 100644 ui/.prettierrc.mjs create mode 100644 ui/BRANDING.md create mode 100644 ui/Containerfile create mode 100644 ui/README.md create mode 100644 ui/branding/favicon.ico create mode 100644 ui/branding/images/logo.png create mode 100644 ui/branding/images/logo192.png create mode 100644 ui/branding/images/logo512.png create mode 100644 ui/branding/images/masthead-logo.svg create mode 100644 ui/branding/manifest.json create mode 100644 ui/branding/strings.json create mode 100644 ui/client/config/jest.config.ts create mode 100644 ui/client/config/monacoConstants.ts create mode 100644 ui/client/config/stylePaths.js create mode 100644 ui/client/config/webpack.common.ts create mode 100644 ui/client/config/webpack.dev.ts create mode 100644 ui/client/config/webpack.prod.ts create mode 100644 ui/client/package.json create mode 100644 ui/client/public/index.html.ejs create mode 100644 ui/client/public/manifest.json create mode 100644 ui/client/public/mockServiceWorker.js create mode 100644 ui/client/public/robots.txt create mode 100644 ui/client/src/app/App.css create mode 100644 ui/client/src/app/App.tsx create mode 100644 ui/client/src/app/Constants.ts create mode 100644 ui/client/src/app/Routes.tsx create mode 100644 ui/client/src/app/analytics.ts create mode 100644 ui/client/src/app/api/model-utils.ts create mode 100644 ui/client/src/app/api/models.ts create mode 100644 ui/client/src/app/api/rest.ts create mode 100644 ui/client/src/app/axios-config/apiInit.ts create mode 100644 ui/client/src/app/axios-config/index.ts create mode 100644 ui/client/src/app/common/types.ts create mode 100644 ui/client/src/app/components/AnalyticsProvider.tsx create mode 100644 ui/client/src/app/components/AppPlaceholder.tsx create mode 100644 ui/client/src/app/components/ConfirmDialog.tsx create mode 100644 ui/client/src/app/components/CveGallery.tsx create mode 100644 ui/client/src/app/components/FilterToolbar/FilterControl.tsx create mode 100644 ui/client/src/app/components/FilterToolbar/FilterToolbar.tsx create mode 100644 ui/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx create mode 100644 ui/client/src/app/components/FilterToolbar/SearchFilterControl.tsx create mode 100644 ui/client/src/app/components/FilterToolbar/SelectFilterControl.tsx create mode 100644 ui/client/src/app/components/FilterToolbar/index.ts create mode 100644 ui/client/src/app/components/FilterToolbar/select-overrides.css create mode 100644 ui/client/src/app/components/HookFormPFFields/HookFormPFGroupController.tsx create mode 100644 ui/client/src/app/components/HookFormPFFields/HookFormPFSelect.tsx create mode 100644 ui/client/src/app/components/HookFormPFFields/HookFormPFTextArea.tsx create mode 100644 ui/client/src/app/components/HookFormPFFields/HookFormPFTextInput.tsx create mode 100644 ui/client/src/app/components/HookFormPFFields/index.ts create mode 100644 ui/client/src/app/components/IconedStatus.tsx create mode 100644 ui/client/src/app/components/LoadingWrapper.tsx create mode 100644 ui/client/src/app/components/NoDataEmptyState.tsx create mode 100644 ui/client/src/app/components/Notifications.tsx create mode 100644 ui/client/src/app/components/NotificationsContext.tsx create mode 100644 ui/client/src/app/components/OidcProvider.tsx create mode 100644 ui/client/src/app/components/PageDrawerContext.tsx create mode 100644 ui/client/src/app/components/SeverityShieldAndText.tsx create mode 100644 ui/client/src/app/components/SimplePagination/SimplePagination.tsx create mode 100644 ui/client/src/app/components/SimplePagination/index.ts create mode 100644 ui/client/src/app/components/StateError.tsx create mode 100644 ui/client/src/app/components/StateNoData.tsx create mode 100644 ui/client/src/app/components/StateNoResults.tsx create mode 100644 ui/client/src/app/components/TableControls/ConditionalTableBody.tsx create mode 100644 ui/client/src/app/components/TableControls/TableHeaderContentWithControls.tsx create mode 100644 ui/client/src/app/components/TableControls/TableRowContentWithControls.tsx create mode 100644 ui/client/src/app/components/TableControls/index.ts create mode 100644 ui/client/src/app/components/ToolbarBulkSelector.tsx create mode 100644 ui/client/src/app/components/markdownPFComponents.tsx create mode 100644 ui/client/src/app/components/notes-markdown.tsx create mode 100644 ui/client/src/app/env.ts create mode 100644 ui/client/src/app/hooks/table-controls/active-item/getActiveItemDerivedState.ts create mode 100644 ui/client/src/app/hooks/table-controls/active-item/index.ts create mode 100644 ui/client/src/app/hooks/table-controls/active-item/useActiveItemEffects.ts create mode 100644 ui/client/src/app/hooks/table-controls/active-item/useActiveItemPropHelpers.ts create mode 100644 ui/client/src/app/hooks/table-controls/active-item/useActiveItemState.ts create mode 100644 ui/client/src/app/hooks/table-controls/column/useColumnState.ts create mode 100644 ui/client/src/app/hooks/table-controls/expansion/getExpansionDerivedState.ts create mode 100644 ui/client/src/app/hooks/table-controls/expansion/index.ts create mode 100644 ui/client/src/app/hooks/table-controls/expansion/useExpansionPropHelpers.ts create mode 100644 ui/client/src/app/hooks/table-controls/expansion/useExpansionState.ts create mode 100644 ui/client/src/app/hooks/table-controls/filtering/getFilterHubRequestParams.ts create mode 100644 ui/client/src/app/hooks/table-controls/filtering/getLocalFilterDerivedState.ts create mode 100644 ui/client/src/app/hooks/table-controls/filtering/helpers.ts create mode 100644 ui/client/src/app/hooks/table-controls/filtering/index.ts create mode 100644 ui/client/src/app/hooks/table-controls/filtering/useFilterPropHelpers.ts create mode 100644 ui/client/src/app/hooks/table-controls/filtering/useFilterState.ts create mode 100644 ui/client/src/app/hooks/table-controls/getHubRequestParams.ts create mode 100644 ui/client/src/app/hooks/table-controls/getLocalTableControlDerivedState.ts create mode 100644 ui/client/src/app/hooks/table-controls/index.ts create mode 100644 ui/client/src/app/hooks/table-controls/pagination/getLocalPaginationDerivedState.ts create mode 100644 ui/client/src/app/hooks/table-controls/pagination/getPaginationHubRequestParams.ts create mode 100644 ui/client/src/app/hooks/table-controls/pagination/index.ts create mode 100644 ui/client/src/app/hooks/table-controls/pagination/usePaginationEffects.ts create mode 100644 ui/client/src/app/hooks/table-controls/pagination/usePaginationPropHelpers.ts create mode 100644 ui/client/src/app/hooks/table-controls/pagination/usePaginationState.ts create mode 100644 ui/client/src/app/hooks/table-controls/sorting/getLocalSortDerivedState.ts create mode 100644 ui/client/src/app/hooks/table-controls/sorting/getSortHubRequestParams.ts create mode 100644 ui/client/src/app/hooks/table-controls/sorting/index.ts create mode 100644 ui/client/src/app/hooks/table-controls/sorting/useSortPropHelpers.ts create mode 100644 ui/client/src/app/hooks/table-controls/sorting/useSortState.ts create mode 100644 ui/client/src/app/hooks/table-controls/types.ts create mode 100644 ui/client/src/app/hooks/table-controls/useLocalTableControls.ts create mode 100644 ui/client/src/app/hooks/table-controls/useTableControlProps.ts create mode 100644 ui/client/src/app/hooks/table-controls/useTableControlState.ts create mode 100644 ui/client/src/app/hooks/table-controls/utils.ts create mode 100644 ui/client/src/app/hooks/useBranding.ts create mode 100644 ui/client/src/app/hooks/useCreateEditModalState.ts create mode 100644 ui/client/src/app/hooks/useDownload.ts create mode 100644 ui/client/src/app/hooks/usePersistentState.ts create mode 100644 ui/client/src/app/hooks/useSelectionState.ts create mode 100644 ui/client/src/app/hooks/useStorage.ts create mode 100644 ui/client/src/app/hooks/useUpload.ts create mode 100644 ui/client/src/app/hooks/useUrlParams.ts create mode 100644 ui/client/src/app/images/avatar.svg create mode 100644 ui/client/src/app/images/pfbg-icon.svg create mode 100644 ui/client/src/app/layout/about.tsx create mode 100644 ui/client/src/app/layout/default-layout.tsx create mode 100644 ui/client/src/app/layout/header.tsx create mode 100644 ui/client/src/app/layout/index.ts create mode 100644 ui/client/src/app/layout/layout-constants.ts create mode 100644 ui/client/src/app/layout/sidebar.tsx create mode 100644 ui/client/src/app/oidc.ts create mode 100644 ui/client/src/app/pages/advisory-details/advisory-details.tsx create mode 100644 ui/client/src/app/pages/advisory-details/cves.tsx create mode 100644 ui/client/src/app/pages/advisory-details/index.ts create mode 100644 ui/client/src/app/pages/advisory-details/overview.tsx create mode 100644 ui/client/src/app/pages/advisory-details/source.tsx create mode 100644 ui/client/src/app/pages/advisory-list/advisory-list.tsx create mode 100644 ui/client/src/app/pages/advisory-list/components/CVEsGaleryCount.tsx create mode 100644 ui/client/src/app/pages/advisory-list/components/UploadFilesDrawer.tsx create mode 100644 ui/client/src/app/pages/advisory-list/index.ts create mode 100644 ui/client/src/app/pages/cve-details/cve-details.tsx create mode 100644 ui/client/src/app/pages/cve-details/index.ts create mode 100644 ui/client/src/app/pages/cve-details/related-advisories.tsx create mode 100644 ui/client/src/app/pages/cve-details/related-sboms.tsx create mode 100644 ui/client/src/app/pages/cve-details/source.tsx create mode 100644 ui/client/src/app/pages/cve-list/cve-list.tsx create mode 100644 ui/client/src/app/pages/cve-list/index.ts create mode 100644 ui/client/src/app/pages/home/home.tsx create mode 100644 ui/client/src/app/pages/home/index.ts create mode 100644 ui/client/src/app/pages/importer-list/components/importer-form.tsx create mode 100644 ui/client/src/app/pages/importer-list/components/importer-status-icon.tsx create mode 100644 ui/client/src/app/pages/importer-list/importer-list.tsx create mode 100644 ui/client/src/app/pages/importer-list/index.ts create mode 100644 ui/client/src/app/pages/package-details/index.ts create mode 100644 ui/client/src/app/pages/package-details/package-details.tsx create mode 100644 ui/client/src/app/pages/package-details/related-cves.tsx create mode 100644 ui/client/src/app/pages/package-details/related-sboms.tsx create mode 100644 ui/client/src/app/pages/package-list/index.ts create mode 100644 ui/client/src/app/pages/package-list/package-list.tsx create mode 100644 ui/client/src/app/pages/sbom-details/cves.tsx create mode 100644 ui/client/src/app/pages/sbom-details/dependency-analytics.tsx create mode 100644 ui/client/src/app/pages/sbom-details/index.ts create mode 100644 ui/client/src/app/pages/sbom-details/overview.tsx create mode 100644 ui/client/src/app/pages/sbom-details/packages.tsx create mode 100644 ui/client/src/app/pages/sbom-details/sbom-details.tsx create mode 100644 ui/client/src/app/pages/sbom-details/source.tsx create mode 100644 ui/client/src/app/pages/sbom-list/index.ts create mode 100644 ui/client/src/app/pages/sbom-list/sbom-list.tsx create mode 100644 ui/client/src/app/queries/advisories.ts create mode 100644 ui/client/src/app/queries/cves.ts create mode 100644 ui/client/src/app/queries/importers.ts create mode 100644 ui/client/src/app/queries/packages.ts create mode 100644 ui/client/src/app/queries/sboms.ts create mode 100644 ui/client/src/app/react-app-env.d.ts create mode 100644 ui/client/src/app/reportWebVitals.ts create mode 100644 ui/client/src/app/test-config/setupTests.ts create mode 100644 ui/client/src/app/utils/query-utils.ts create mode 100644 ui/client/src/app/utils/type-utils.ts create mode 100644 ui/client/src/app/utils/utils.test.ts create mode 100644 ui/client/src/app/utils/utils.ts create mode 100644 ui/client/src/index.tsx create mode 100644 ui/client/src/mocks/browser.ts create mode 100644 ui/client/src/mocks/config.test.ts create mode 100644 ui/client/src/mocks/config.ts create mode 100644 ui/client/src/mocks/server.ts create mode 100644 ui/client/src/mocks/stub-new-work/advisories.ts create mode 100644 ui/client/src/mocks/stub-new-work/cves.ts create mode 100644 ui/client/src/mocks/stub-new-work/index.ts create mode 100644 ui/client/src/mocks/stub-new-work/packages.ts create mode 100644 ui/client/src/mocks/stub-new-work/sboms.ts create mode 100644 ui/client/tsconfig.json create mode 100644 ui/client/types/@hookform_resolvers_2.9.11.d.ts create mode 100644 ui/client/types/array-filter-Boolean.ts create mode 100644 ui/client/types/globals.d.ts create mode 100644 ui/client/types/typings.d.ts create mode 100644 ui/common/package.json create mode 100644 ui/common/rollup.config.js create mode 100644 ui/common/src/branding-strings-stub.json create mode 100644 ui/common/src/branding.ts create mode 100644 ui/common/src/environment.ts create mode 100644 ui/common/src/index.ts create mode 100644 ui/common/src/proxies.ts create mode 100644 ui/common/tsconfig.json create mode 100755 ui/entrypoint.sh create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100755 ui/scripts/verify_lock.mjs create mode 100644 ui/server/package.json create mode 100644 ui/server/rollup.config.js create mode 100644 ui/server/src/index.js diff --git a/.gitignore b/.gitignore index f4c03c999..92f85112c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ target /flamegraph*.svg /perf.data* + +static/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index f9d1e96d8..62b13a339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,29 @@ dependencies = [ "smallvec", ] +[[package]] +name = "actix-files" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0bdd6ff79de7c9a021f5d9ea79ce23e108d8bfc9b49b5b4a2cf6fad5a35212" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags 2.4.2", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + [[package]] name = "actix-http" version = "3.6.0" @@ -859,6 +882,16 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "buffered-reader" version = "1.3.0" @@ -994,6 +1027,28 @@ dependencies = [ "windows-targets 0.52.4", ] +[[package]] +name = "chrono-tz" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + [[package]] name = "clap" version = "4.5.2" @@ -1475,6 +1530,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "deunicode" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6e854126756c496b8c81dec88f9a706b15b875c5849d4097a3854476b9fdf94" + [[package]] name = "digest" version = "0.10.7" @@ -1962,6 +2023,30 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "globset" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.6", + "regex-syntax 0.8.2", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "gloo-timers" version = "0.2.6" @@ -2114,6 +2199,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.8.0" @@ -2144,6 +2235,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91f255a4535024abf7640cb288260811fc14794f62b063652ed349f9a6c2348e" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "humantime" version = "2.1.0" @@ -2278,6 +2378,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" +[[package]] +name = "ignore" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.6", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "impl-more" version = "0.1.6" @@ -3092,6 +3208,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" +dependencies = [ + "regex", +] + [[package]] name = "paste" version = "1.0.14" @@ -3178,6 +3303,35 @@ dependencies = [ "indexmap 2.2.5", ] +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.2", + "rand", +] + [[package]] name = "phf_shared" version = "0.10.0" @@ -3187,6 +3341,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.5" @@ -4496,6 +4659,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "slug" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd94acec9c8da640005f8e135a39fc0372e74535e6b368b7a04b875f784c8c4" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + [[package]] name = "smallvec" version = "1.13.1" @@ -4842,7 +5015,7 @@ dependencies = [ "new_debug_unreachable", "once_cell", "parking_lot 0.12.1", - "phf_shared", + "phf_shared 0.10.0", "precomputed-hash", ] @@ -5011,6 +5184,28 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "tera" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand", + "regex", + "serde", + "serde_json", + "slug", + "unic-segment", +] + [[package]] name = "term" version = "0.7.0" @@ -5751,6 +5946,7 @@ dependencies = [ name = "trustify-server" version = "0.1.0" dependencies = [ + "actix-files", "actix-web", "anyhow", "clap", @@ -5772,6 +5968,7 @@ dependencies = [ "trustify-module-ingestor", "trustify-module-search", "trustify-module-storage", + "trustify-ui", "url", "url-escape", "utoipa", @@ -5797,6 +5994,16 @@ dependencies = [ "trustify-server", ] +[[package]] +name = "trustify-ui" +version = "0.1.0" +dependencies = [ + "base64 0.22.0", + "serde", + "serde_json", + "tera", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5815,6 +6022,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23" +dependencies = [ + "unic-ucd-segment", +] + +[[package]] +name = "unic-ucd-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + [[package]] name = "unicase" version = "2.7.0" @@ -5978,6 +6235,12 @@ dependencies = [ "serde", ] +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + [[package]] name = "validator" version = "0.16.1" diff --git a/Cargo.toml b/Cargo.toml index f759be274..92762a3ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "common", "common/auth", "common/infrastructure", + "common/ui", "cvss", "modules/graph", "modules/importer", @@ -15,6 +16,7 @@ members = [ "migration", "server", ] +default-members = ["trustd"] [workspace.dependencies] actix-cors = "0.7" @@ -109,6 +111,7 @@ trustify-module-importer = { path = "modules/importer" } trustify-module-search = { path = "modules/search" } trustify-module-storage = { path = "modules/storage" } trustify-infrastructure = { path = "common/infrastructure" } +trustify-ui = { path = "common/ui" } [patch.crates-io] csaf-walker = { git = "https://github.com/ctron/csaf-walker", rev = "7b6e64dd56e4be79e184b053ef754a42e1496fe0" } diff --git a/common/ui/Cargo.toml b/common/ui/Cargo.toml new file mode 100644 index 000000000..e2a8d73bb --- /dev/null +++ b/common/ui/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "trustify-ui" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["raw_value"] } +tera = "1.19.1" +base64 = { workspace = true } \ No newline at end of file diff --git a/common/ui/src/lib.rs b/common/ui/src/lib.rs new file mode 100644 index 000000000..79b1f4bf3 --- /dev/null +++ b/common/ui/src/lib.rs @@ -0,0 +1,60 @@ +use std::fs; + +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use serde::Serialize; +use serde_json::Value; + +static STATIC_DIR: &str = "./static"; + +#[derive(Serialize, Clone, Default)] +pub struct UI { + #[serde(rename(serialize = "VERSION"))] + pub version: String, + + #[serde(rename(serialize = "AUTH_REQUIRED"))] + pub auth_required: String, + + #[serde(rename(serialize = "OIDC_SERVER_URL"))] + pub oidc_server_url: String, + + #[serde(rename(serialize = "OIDC_CLIENT_ID"))] + pub oidc_client_id: String, + + #[serde(rename(serialize = "OIDC_SCOPE"))] + pub oidc_scope: String, + + #[serde(rename(serialize = "ANALYTICS_ENABLED"))] + pub analytics_enabled: String, + + #[serde(rename(serialize = "ANALYTICS_WRITE_KEY"))] + pub analytics_write_key: String, +} + +pub fn generate_index_html(ui: &UI) -> tera::Result { + let template_file = fs::read_to_string(format!("{STATIC_DIR}/{}", "index.html.ejs"))?; + let template = template_file + .replace("<%=", "{{") + .replace("%>", "}}") + .replace( + "?? branding.application.title", + "| default(value=branding.application.title)", + ) + .replace( + "?? branding.application.title", + "| default(value=branding.application.title)", + ); + + let env_json = serde_json::to_string(&ui).expect("Could not create JSON from ENV"); + let env_base64 = BASE64_STANDARD.encode(env_json.as_bytes()); + + let branding_file_content = + fs::read_to_string(format!("{STATIC_DIR}/{}", "branding/strings.json")).unwrap(); + let branding: Value = serde_json::from_str(&branding_file_content).unwrap(); + + let mut context = tera::Context::new(); + context.insert("_env", &env_base64); + context.insert("branding", &branding); + + tera::Tera::one_off(&template, &context, true) +} diff --git a/server/Cargo.toml b/server/Cargo.toml index b122a2a62..e8af1b136 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -8,11 +8,13 @@ trustify-auth = { workspace = true } trustify-common = { workspace = true } trustify-infrastructure = { workspace = true } trustify-entity = { workspace = true } +trustify-ui = { workspace = true } trustify-module-graph = { workspace = true } trustify-module-ingestor = { workspace = true } trustify-module-importer = { workspace = true } trustify-module-search = { workspace = true } trustify-module-storage = { workspace = true } +actix-files = "0.6.5" actix-web = { workspace = true } anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } diff --git a/server/src/lib.rs b/server/src/lib.rs index 0338f2e18..1f075f60d 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -2,6 +2,7 @@ mod openapi; +use actix_web::dev::{ServiceRequest, ServiceResponse}; use actix_web::{ body::MessageBody, dev::{ConnectionInfo, Url}, @@ -39,6 +40,7 @@ use trustify_module_graph::graph::Graph; use trustify_module_importer::server::importer; use trustify_module_storage::service::dispatch::DispatchBackend; use trustify_module_storage::service::fs::FileSystemBackend; +use trustify_ui::{generate_index_html, UI}; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; @@ -169,7 +171,6 @@ impl InitData { .default_authenticator(self.authenticator) .authorizer(self.authorizer.clone()) .configure(move |svc| { - svc.service(index); svc.service(swagger_ui_with_auth( openapi::openapi(), swagger_oidc.clone(), @@ -185,6 +186,35 @@ impl InitData { ); trustify_module_search::endpoints::configure(svc, db.clone()); }); + + svc.service( + actix_files::Files::new("/", "./static") + .index_file("index.html") + .default_handler(|req: ServiceRequest| { + let (http_req, _payload) = req.into_parts(); + async { + // TODO Set these values with ENV or Config values + let ui = UI { + version: String::from("99.0.0"), + auth_required: String::from("false"), + oidc_server_url: String::from( + "http://localhost:8180/realms/trustify", + ), + oidc_client_id: String::from("trustify-ui"), + oidc_scope: String::from("email"), + analytics_enabled: String::from("false"), + analytics_write_key: String::from(""), + }; + + let index_html = generate_index_html(&ui).unwrap(); + let http_response = HttpResponse::Ok() + .content_type(actix_web::http::header::ContentType::html()) + .body(index_html); + + Ok(ServiceResponse::new(http_req, http_response)) + } + }), + ); }) }; diff --git a/trustd/build.rs b/trustd/build.rs new file mode 100644 index 000000000..34a31cd01 --- /dev/null +++ b/trustd/build.rs @@ -0,0 +1,59 @@ +use std::path::Path; +use std::process::Command; +use std::{fs, io}; + +static UI_DIR: &str = "../ui"; +static UI_DIR_SRC: &str = "../ui/src"; +static UI_DIST_DIR: &str = "../ui/client/dist"; +static STATIC_DIR: &str = "../static"; + +#[cfg(windows)] +static NPM_CMD: &str = "npm.cmd"; +#[cfg(not(windows))] +static NPM_CMD: &str = "npm"; + +fn main() { + println!("Build Trustify - build.rs!"); + + println!("cargo:rerun-if-changed={}", UI_DIR_SRC); + + install_ui_deps(); + build_ui(); + copy_dir_all(UI_DIST_DIR, STATIC_DIR).unwrap(); +} + +fn install_ui_deps() { + if !Path::new("./ui/node_modules").exists() { + println!("Installing node dependencies..."); + Command::new(NPM_CMD) + .args(["clean-install", "--ignore-scripts"]) + .current_dir(UI_DIR) + .status() + .unwrap(); + } +} + +fn build_ui() { + if !Path::new(STATIC_DIR).exists() { + println!("Building UI..."); + Command::new(NPM_CMD) + .args(["run", "build"]) + .current_dir(UI_DIR) + .status() + .unwrap(); + } +} + +fn copy_dir_all(src: impl AsRef, dst: impl AsRef) -> io::Result<()> { + fs::create_dir_all(&dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?; + } else { + fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?; + } + } + Ok(()) +} diff --git a/ui/.containerignore b/ui/.containerignore new file mode 100644 index 000000000..a6f610eee --- /dev/null +++ b/ui/.containerignore @@ -0,0 +1,2 @@ +node_modules +*/dist/ diff --git a/ui/.editorconfig b/ui/.editorconfig new file mode 100644 index 000000000..8bb29e3b2 --- /dev/null +++ b/ui/.editorconfig @@ -0,0 +1,13 @@ +root=true + +[*] +# standard prettier behaviors +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +# Configurable prettier behaviors +end_of_line = lf +indent_style = space +indent_size = 2 +max_line_length = 80 diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs new file mode 100644 index 000000000..c3a69ba4a --- /dev/null +++ b/ui/.eslintrc.cjs @@ -0,0 +1,94 @@ +/* eslint-env node */ + +module.exports = { + root: true, + + env: { + browser: true, + es2020: true, + jest: true, + }, + + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2020, // keep in sync with tsconfig.json + sourceType: "module", + }, + + // eslint-disable-next-line prettier/prettier + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "prettier", + ], + + // eslint-disable-next-line prettier/prettier + plugins: [ + "prettier", + "unused-imports", // or eslint-plugin-import? + "@typescript-eslint", + "react", + "react-hooks", + "@tanstack/query", + ], + + // NOTE: Tweak the rules as needed when bulk fixes get merged + rules: { + // TODO: set to "error" when prettier v2 to v3 style changes are fixed + "prettier/prettier": ["warn"], + + // TODO: set to "error" when all resolved, but keep the `argsIgnorePattern` + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + + // TODO: each one of these can be removed or set to "error" when they're all resolved + "unused-imports/no-unused-imports": ["warn"], + "@typescript-eslint/ban-types": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "react/jsx-key": "warn", + "react-hooks/rules-of-hooks": "warn", + "react-hooks/exhaustive-deps": "warn", + "no-extra-boolean-cast": "warn", + "prefer-const": "warn", + + // Allow the "cy-data" property for trustification-ui-test (but should really be "data-cy" w/o this rule) + "react/no-unknown-property": ["error", { ignore: ["cy-data"] }], + + "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/prefer-query-object-syntax": "error", + }, + + settings: { + react: { version: "detect" }, + }, + + ignorePatterns: [ + // don't ignore dot files so config files get linted + "!.*.js", + "!.*.cjs", + "!.*.mjs", + + // take the place of `.eslintignore` + "dist/", + "generated/", + "node_modules/", + ], + + // this is a hack to make sure eslint will look at all of the file extensions we + // care about without having to put it on the command line + overrides: [ + { + files: [ + "**/*.js", + "**/*.jsx", + "**/*.cjs", + "**/*.mjs", + "**/*.ts", + "**/*.tsx", + ], + }, + ], +}; diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 000000000..5089c820f --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,30 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules/ +/.pnp +.pnp.js + +# testing +coverage/ + +# production +dist/ +/qa/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* + +.eslintcache + +# VSCode +.vscode/* + +# Intellij IDEA +.idea/ diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 000000000..d8c5ebffa --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1,2 @@ +engine-strict=true +fetch-timeout=60000 diff --git a/ui/.prettierignore b/ui/.prettierignore new file mode 100644 index 000000000..ccd6c3041 --- /dev/null +++ b/ui/.prettierignore @@ -0,0 +1,12 @@ +# Library, IDE and build locations +**/node_modules/ +**/coverage/ +**/dist/ +.vscode/ +.idea/ +.eslintcache/ + +# +# NOTE: Could ignore anything that eslint will look at since eslint also applies +# prettier. +# diff --git a/ui/.prettierrc.mjs b/ui/.prettierrc.mjs new file mode 100644 index 000000000..c4d9a638a --- /dev/null +++ b/ui/.prettierrc.mjs @@ -0,0 +1,14 @@ +/** @type {import("prettier").Config} */ +const config = { + trailingComma: "es5", // es5 was the default in prettier v2 + semi: true, + singleQuote: false, + + // Values used from .editorconfig: + // - printWidth == max_line_length + // - tabWidth == indent_size + // - useTabs == indent_style + // - endOfLine == end_of_line +}; + +export default config; diff --git a/ui/BRANDING.md b/ui/BRANDING.md new file mode 100644 index 000000000..39fb1e869 --- /dev/null +++ b/ui/BRANDING.md @@ -0,0 +1,151 @@ +# Branding + +The UI supports static branding at build time. Dynamically switching brands is not +possible with the current implementation. + +## Summary + +Each of the project modules need to do some branding enablement. + +- `@trustify-ui/common` pulls in the branding assets and packages the configuration, + strings and assets within the common package. The other modules pull branding + from the common module. + +- `@trustify-ui/client` uses branding from the common package: + + - The location of `favicon.ico`, `manifest.json` and any other branding + assets that may be referenced in the `brandingStrings` are sourced from the + common package. + + - The `brandingStrings` are used by the dev-server runtime, to fill out the + `index.html` template. + + - The about modal and application masthead components use the branding strings + provided by the common module to display brand appropriate logos, titles and + about information. Since the common module provides all the information, it + is packaged directly into the app at build time. + +- `@trustify-ui/server` uses the `brandingStrings` from the common package to fill + out the `index.html` template. + +## Providing alternate branding + +To provide an alternate branding to the build, specify the path to the branding assets +with the `BRANDING` environment variable. Relative paths in `BRANDING` are computed +from the project source root. + +Each brand requires the presence of at least the following files: + +- `strings.json` +- `favicon.ico` +- `manifest.json` + +With a file path of `/alt/custom-branding`, a build that uses an alternate branding +is run as: + +```sh +> BRANDING=/alt/custom-branding npm run build +``` + +The dev server can also be run this way. Since file watching of the branding assets +is not implemented in the common module's build watch mode, it may be necessary to +manually build the common module before running the dev server. When working on a +brand, it is useful to run the dev server like this: + +```sh +> export BRANDING=/alt/custom-branding +> npm run build -w common +> npm run start:dev +> unset BRANDING # when you don't want to use the custom branding path anymore +``` + +### File details + +#### strings.json + +The expected shape of `strings.json` is defined in [branding.ts](./common/src/branding.ts). + +The default version of the file is [branding/strings.json](./branding/strings.json). + +A minimal viable example of the file is: + +```json +{ + "application": { + "title": "Konveyor" + }, + "about": { + "displayName": "Konveyor" + }, + "masthead": {} +} +``` + +At build time, the json file is processed as an [ejs](https://ejs.co/) template. The +variable `brandingRoot` is provided as the relative root of the branding +assets within the build of the common module. Consider the location of `strings.json` +in your branding directory as the base `brandingRoot` when creating a new brand. + +For example, to properly reference a logo within this branding structure: + +``` + special-brand/ + images/ + masthead-logo.svg + about-logo.svg + strings.json +``` + +Use a url string like this: + +```json +{ + "about": { + "imageSrc": "<%= brandingRoot %>/images/about-logo.svg" + } +} +``` + +and in the output of `BRANDING=special-brand npm run build -w common`, the `imageSrc` +will be `branding/images/about-logo.svg` with all of the files in `special-branding/*` +copied to and available to the client and server modules from +`@trustify-ui/common/branding/*`. + +#### favicon.ico + +A standard favorite icon file `favicon.ico` is required to be in the same directory +as `strings.json` + +#### manifest.json + +A standard [web app manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) +file `manifest.json` is required to be in the same directory as `strings.json`. + +## Technical details + +All branding strings and assets are pulled in to the common module. The client and +server modules access the branding from the common module build. + +The `common` module relies on rollup packaging to embed all of the brand for easy +use. The use of branding strings in `client` and `server` modules is straight forward. +Pulling in `strings.json` and providing the base path to the brand assets is a +more complicated. + +The `common` module provides the `brandingAssetPath()` function to let the build time +code find the root path to all brand assets. Webpack configuration files use this +function to source the favicon.ico, manifest.json and other brand assets to be copied +to the application bundle. + +The `brandingStrings` is typed and sourced from a json file. To pass typescript builds, +a stub json file needs to be available at transpile time. By using a typescript paths +of `@branding/strings.json`, the stub json is found at transpile time. The generated +javascript will still import the path alias. The +[virtual rollup plugin](https://github.com/rollup/plugins/tree/master/packages/virtual) +further transform the javascript output by replacing the `@branding/strings.json` import +with a dynamically built module containing the contents of the brand's `strings.json`. +The brand json becomes a virtual module embedded in the common module. + +A build for a custom brand will fail (1) if the expected files cannot be read, or (2) +if `strings.json` is not a valid JSON file. **Note:** The context of `stings.json` is +not currently validated. If something is missing or a url is malformed, it will only +be visible as a runtime error. diff --git a/ui/Containerfile b/ui/Containerfile new file mode 100644 index 000000000..253d0f3ac --- /dev/null +++ b/ui/Containerfile @@ -0,0 +1,38 @@ +# Builder image +FROM registry.access.redhat.com/ubi9/nodejs-20:latest as builder + +USER 1001 +COPY --chown=1001 . . +RUN npm clean-install --ignore-scripts && npm run build && npm run dist + +# Runner image +FROM registry.access.redhat.com/ubi9/nodejs-20-minimal:latest + +# Add ps package to allow liveness probe for k8s cluster +# Add tar package to allow copying files with kubectl scp +USER 0 +RUN microdnf -y install tar procps-ng && microdnf clean all + +USER 1001 + +LABEL name="trustify/trustify-ui" \ + description="Trustify - User Interface" \ + help="For more information visit https://trustification.github.io/" \ + license="Apache License 2.0" \ + maintainer="carlosthe19916@gmail.com" \ + summary="Trustify - User Interface" \ + url="https://ghcr.io/trustification/trustify-ui" \ + usage="podman run -p 80 -v trustification/trustify-ui:latest" \ + io.k8s.display-name="trustify-ui" \ + io.k8s.description="Trustify - User Interface" \ + io.openshift.expose-services="80:http" \ + io.openshift.tags="operator,trustification,trustify,ui,nodejs20" \ + io.openshift.min-cpu="100m" \ + io.openshift.min-memory="350Mi" + +COPY --from=builder /opt/app-root/src/dist /opt/app-root/dist/ + +ENV DEBUG=1 + +WORKDIR /opt/app-root/dist +ENTRYPOINT ["./entrypoint.sh"] diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 000000000..7a3737997 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,65 @@ +# frontend + +## dev-env + +### Install node and npm + +Use [nvm](https://github.com/nvm-sh/nvm?tab=readme-ov-file#install--update-script) +that installs both node and npm. + +Then + +```shell +nvm install node +node --version +npm --version +``` + +### Install dependencies: + +```shell +npm clean-install --ignore-scripts +``` + +### Init the dev server: + +```shell +npm run start:dev +``` + +> Known issue: after installing the dependencies for the first time and then executing `npm run start:dev` you will see +> an error +> `config/webpack.dev.ts(18,8): error TS2307: Cannot find module '@trustify-ui/common' or its corresponding type declarations` +> Stop the comand with Ctrl+C and run the command `npm run start:dev` again and the error should be gone. This only +> happens the very first time we install dependencies in a clean environment, subsequent commands `npm run start:dev` +> should not give that error. (bug under investigation) + +Open browser at + +## Environment variables + +| ENV VAR | Description | Defaul value | +| ---------------------- | ----------------------------- | ------------------------------------ | +| TRUSTIFICATION_API_URL | Set Trustification API URL | http://localhost:8080 | +| AUTH_REQUIRED | Enable/Disable authentication | false | +| OIDC_CLIENT_ID | Set Oidc Client | frontend | +| OIDC_SERVER_URL | Set Oidc Server URL | http://localhost:8090/realms/chicken | +| OIDC_SCOPE | Set Oidc Scope | openid | +| ANALYTICS_ENABLED | Enable/Disable analytics | false | +| ANALYTICS_WRITE_KEY | Set Segment Write key | null | + +## Mock data + +Enable mocks: + +```shell +export MOCK=stub +``` + +Start app: + +```shell +npm run start:dev +``` + +Mock data is defined at `client/src/mocks` diff --git a/ui/branding/favicon.ico b/ui/branding/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..130b38c8d137f57f8c2fa06ec326cc161219d392 GIT binary patch literal 15406 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 aBN=$GXi_nRb&dIX0jMcyDOSj%0{;UZ$cefD literal 0 HcmV?d00001 diff --git a/ui/branding/images/logo192.png b/ui/branding/images/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..9a640c9402599580c83f83500650e172a055a2e2 GIT binary patch literal 6378 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 aBN=$GXi_nRb&dIX0jMcyDOSj%0{;UZ$cefD literal 0 HcmV?d00001 diff --git a/ui/branding/images/logo512.png b/ui/branding/images/logo512.png new file mode 100644 index 0000000000000000000000000000000000000000..9f72223b7cb9cc99fb26f9d14d82eb5fa7d6ab10 GIT binary patch literal 24557 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 + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/branding/manifest.json b/ui/branding/manifest.json new file mode 100644 index 000000000..b0013e63b --- /dev/null +++ b/ui/branding/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "trustification-ui", + "name": "Trustification UI", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/ui/branding/strings.json b/ui/branding/strings.json new file mode 100644 index 000000000..0d2e2f2cc --- /dev/null +++ b/ui/branding/strings.json @@ -0,0 +1,21 @@ +{ + "application": { + "title": "Trustification", + "name": "Trustification UI", + "description": "Trustification UI" + }, + "about": { + "displayName": "Trustification", + "imageSrc": "<%= brandingRoot %>/images/masthead-logo.svg", + "documentationUrl": "https://trustification.io/" + }, + "masthead": { + "leftBrand": { + "src": "<%= brandingRoot %>/images/masthead-logo.svg", + "alt": "brand", + "height": "40px" + }, + "leftTitle": null, + "rightBrand": null + } +} diff --git a/ui/client/config/jest.config.ts b/ui/client/config/jest.config.ts new file mode 100644 index 000000000..23d6246e6 --- /dev/null +++ b/ui/client/config/jest.config.ts @@ -0,0 +1,50 @@ +import type { JestConfigWithTsJest } from "ts-jest"; + +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +const config: JestConfigWithTsJest = { + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: false, + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // Stub out resources and provide handling for tsconfig.json paths + moduleNameMapper: { + // stub out files that don't matter for tests + "\\.(css|less)$": "/__mocks__/styleMock.js", + "\\.(xsd)$": "/__mocks__/styleMock.js", + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": + "/__mocks__/fileMock.js", + "@patternfly/react-icons/dist/esm/icons/": + "/__mocks__/fileMock.js", + + // match the paths in tsconfig.json + "@app/(.*)": "/src/app/$1", + "@assets/(.*)": + "../node_modules/@patternfly/react-core/dist/styles/assets/$1", + }, + + // A list of paths to directories that Jest should use to search for files + roots: ["/src"], + + // The test environment that will be used for testing + testEnvironment: "jest-environment-jsdom", + + // The pattern or patterns Jest uses to find test files + testMatch: ["/src/**/*.{test,spec}.{js,jsx,ts,tsx}"], + + // Process js/jsx/mjs/mjsx/ts/tsx/mts/mtsx with `ts-jest` + transform: { + "^.+\\.(js|mjs|ts|mts)x?$": "ts-jest", + }, + + // Code to set up the testing framework before each test file in the suite is executed + setupFilesAfterEnv: ["/src/app/test-config/setupTests.ts"], +}; + +export default config; diff --git a/ui/client/config/monacoConstants.ts b/ui/client/config/monacoConstants.ts new file mode 100644 index 000000000..35c16dc6f --- /dev/null +++ b/ui/client/config/monacoConstants.ts @@ -0,0 +1,22 @@ +import { EditorLanguage } from "monaco-editor/esm/metadata"; + +export const LANGUAGES_BY_FILE_EXTENSION = { + java: "java", + go: "go", + xml: "xml", + js: "javascript", + ts: "typescript", + html: "html", + htm: "html", + css: "css", + yaml: "yaml", + yml: "yaml", + json: "json", + md: "markdown", + php: "php", + py: "python", + pl: "perl", + rb: "ruby", + sh: "shell", + bash: "shell", +} as const satisfies Record; diff --git a/ui/client/config/stylePaths.js b/ui/client/config/stylePaths.js new file mode 100644 index 000000000..a7f76aada --- /dev/null +++ b/ui/client/config/stylePaths.js @@ -0,0 +1,17 @@ +/* eslint-env node */ + +import * as path from "path"; + +export const stylePaths = [ + // Include our sources + path.resolve(__dirname, "../src"), + + // Include =PF4 paths, even if nested under another package because npm cannot hoist + // a single package to the root node_modules/ + /node_modules\/@patternfly\/patternfly/, + /node_modules\/@patternfly\/react-core\/.*\.css/, + /node_modules\/@patternfly\/react-styles/, +]; diff --git a/ui/client/config/webpack.common.ts b/ui/client/config/webpack.common.ts new file mode 100644 index 000000000..36b28eb13 --- /dev/null +++ b/ui/client/config/webpack.common.ts @@ -0,0 +1,221 @@ +import path from "path"; +import { Configuration } from "webpack"; +import CopyPlugin from "copy-webpack-plugin"; +import Dotenv from "dotenv-webpack"; +import { TsconfigPathsPlugin } from "tsconfig-paths-webpack-plugin"; +import MonacoWebpackPlugin from "monaco-editor-webpack-plugin"; + +import { brandingAssetPath } from "@trustify-ui/common"; +import { LANGUAGES_BY_FILE_EXTENSION } from "./monacoConstants"; + +const pathTo = (relativePath: string) => path.resolve(__dirname, relativePath); + +const brandingPath = brandingAssetPath(); +const manifestPath = path.resolve(brandingPath, "manifest.json"); + +const BG_IMAGES_DIRNAME = "images"; + +const config: Configuration = { + entry: { + app: [pathTo("../src/index.tsx")], + }, + + output: { + path: pathTo("../dist"), + publicPath: "auto", + clean: true, + }, + + module: { + rules: [ + { + test: /\.[jt]sx?$/, + exclude: /node_modules/, + use: { + loader: "ts-loader", + options: { + transpileOnly: true, + }, + }, + }, + { + test: /\.(svg|ttf|eot|woff|woff2)$/, + // only process modules with this loader + // if they live under a 'fonts' or 'pficon' directory + include: [ + pathTo("../../node_modules/patternfly/dist/fonts"), + pathTo( + "../../node_modules/@patternfly/react-core/dist/styles/assets/fonts" + ), + pathTo( + "../../node_modules/@patternfly/react-core/dist/styles/assets/pficon" + ), + pathTo("../../node_modules/@patternfly/patternfly/assets/fonts"), + pathTo("../../node_modules/@patternfly/patternfly/assets/pficon"), + ], + use: { + loader: "file-loader", + options: { + // Limit at 50k. larger files emited into separate files + limit: 5000, + outputPath: "fonts", + name: "[name].[ext]", + }, + }, + }, + { + test: /\.(xsd)$/, + include: [pathTo("../src")], + use: { + loader: "raw-loader", + options: { + esModule: true, + }, + }, + }, + { + test: /\.svg$/, + include: (input) => input.indexOf("background-filter.svg") > 1, + use: [ + { + loader: "url-loader", + options: { + limit: 5000, + outputPath: "svgs", + name: "[name].[ext]", + }, + }, + ], + type: "javascript/auto", + }, + { + test: /\.svg$/, + // only process SVG modules with this loader if they live under a 'bgimages' directory + // this is primarily useful when applying a CSS background using an SVG + include: (input) => input.indexOf(BG_IMAGES_DIRNAME) > -1, + use: { + loader: "svg-url-loader", + options: {}, + }, + }, + { + test: /\.svg$/, + // only process SVG modules with this loader when they don't live under a 'bgimages', + // 'fonts', or 'pficon' directory, those are handled with other loaders + include: (input) => + input.indexOf(BG_IMAGES_DIRNAME) === -1 && + input.indexOf("fonts") === -1 && + input.indexOf("background-filter") === -1 && + input.indexOf("pficon") === -1, + use: { + loader: "raw-loader", + options: {}, + }, + type: "javascript/auto", + }, + { + test: /\.(jpg|jpeg|png|gif)$/i, + include: [ + pathTo("../src"), + pathTo("../../node_modules/patternfly"), + pathTo("../../node_modules/@patternfly/patternfly/assets/images"), + pathTo( + "../../node_modules/@patternfly/react-styles/css/assets/images" + ), + pathTo( + "../../node_modules/@patternfly/react-core/dist/styles/assets/images" + ), + pathTo( + "../../node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles/css/assets/images" + ), + pathTo( + "../../node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles/css/assets/images" + ), + pathTo( + "../../node_modules/@patternfly/react-inline-edit-extension/node_modules/@patternfly/react-styles/css/assets/images" + ), + ], + use: [ + { + loader: "url-loader", + options: { + limit: 5000, + outputPath: "images", + name: "[name].[ext]", + }, + }, + ], + type: "javascript/auto", + }, + { + test: pathTo("../../node_modules/xmllint/xmllint.js"), + loader: "exports-loader", + options: { + exports: "xmllint", + }, + }, + { + test: /\.yaml$/, + use: "raw-loader", + }, + + // For monaco-editor-webpack-plugin + { + test: /\.css$/, + include: [pathTo("../../node_modules/monaco-editor")], + use: ["style-loader", "css-loader"], + }, + { + test: /\.ttf$/, + type: "asset/resource", + }, + // <--- For monaco-editor-webpack-plugin + ], + }, + + plugins: [ + // new CaseSensitivePathsWebpackPlugin(), + new Dotenv({ + systemvars: true, + silent: true, + }), + new CopyPlugin({ + patterns: [ + { + from: manifestPath, + to: ".", + }, + { + from: brandingPath, + to: "./branding/", + }, + ], + }), + new MonacoWebpackPlugin({ + filename: "monaco/[name].worker.js", + languages: Object.values(LANGUAGES_BY_FILE_EXTENSION), + }), + ], + + resolve: { + // alias: { + // "react-dom": "@hot-loader/react-dom", + // }, + extensions: [".js", ".ts", ".tsx", ".jsx"], + plugins: [ + new TsconfigPathsPlugin({ + configFile: pathTo("../tsconfig.json"), + }), + ], + symlinks: false, + cacheWithContext: false, + fallback: { crypto: false, fs: false, path: false }, + }, + + externals: { + // required by xmllint (but not really used in the browser) + ws: "{}", + }, +}; + +export default config; diff --git a/ui/client/config/webpack.dev.ts b/ui/client/config/webpack.dev.ts new file mode 100644 index 000000000..941bca965 --- /dev/null +++ b/ui/client/config/webpack.dev.ts @@ -0,0 +1,121 @@ +import path from "path"; +import { mergeWithRules } from "webpack-merge"; +import type { Configuration as WebpackConfiguration } from "webpack"; +import type { Configuration as DevServerConfiguration } from "webpack-dev-server"; +import CopyPlugin from "copy-webpack-plugin"; +import HtmlWebpackPlugin from "html-webpack-plugin"; +import ReactRefreshTypeScript from "react-refresh-typescript"; +import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin"; +import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; + +import { + encodeEnv, + TRUSTIFICATION_ENV, + SERVER_ENV_KEYS, + proxyMap, + brandingStrings, + brandingAssetPath, +} from "@trustify-ui/common"; +import { stylePaths } from "./stylePaths"; +import commonWebpackConfiguration from "./webpack.common"; + +const pathTo = (relativePath: string) => path.resolve(__dirname, relativePath); +const faviconPath = path.resolve(brandingAssetPath(), "favicon.ico"); + +interface Configuration extends WebpackConfiguration { + devServer?: DevServerConfiguration; +} + +const devServer: DevServerConfiguration = { + port: 3000, + historyApiFallback: { + disableDotRule: true, + }, + hot: true, + proxy: proxyMap, +}; + +const config: Configuration = mergeWithRules({ + module: { + rules: { + test: "match", + use: { + loader: "match", + options: "replace", + }, + }, + }, +})(commonWebpackConfiguration, { + mode: "development", + devtool: "eval-source-map", + output: { + filename: "[name].js", + chunkFilename: "js/[name].js", + assetModuleFilename: "assets/[name][ext]", + }, + + devServer, + + module: { + rules: [ + { + test: /\.[jt]sx?$/, + exclude: /node_modules/, + use: { + loader: "ts-loader", + options: { + transpileOnly: true, // HMR in webpack-dev-server requires transpileOnly + getCustomTransformers: () => ({ + before: [ReactRefreshTypeScript()], + }), + }, + }, + }, + { + test: /\.css$/, + include: [...stylePaths], + use: ["style-loader", "css-loader"], + }, + ], + }, + + plugins: [ + new ReactRefreshWebpackPlugin(), + new ForkTsCheckerWebpackPlugin({ + typescript: { + mode: "readonly", + }, + }), + new CopyPlugin({ + patterns: [ + { + from: pathTo("../public/mockServiceWorker.js"), + }, + ], + }), + + // index.html generated at compile time to inject `_env` + new HtmlWebpackPlugin({ + filename: "index.html", + template: pathTo("../public/index.html.ejs"), + templateParameters: { + _env: encodeEnv(TRUSTIFICATION_ENV, SERVER_ENV_KEYS), + branding: brandingStrings, + }, + favicon: faviconPath, + minify: { + collapseWhitespace: false, + keepClosingSlash: true, + minifyJS: true, + removeEmptyAttributes: true, + removeRedundantAttributes: true, + }, + }), + ], + + watchOptions: { + // ignore watching everything except @trustify-ui packages + ignored: /node_modules\/(?!@trustify-ui\/)/, + }, +} as Configuration); +export default config; diff --git a/ui/client/config/webpack.prod.ts b/ui/client/config/webpack.prod.ts new file mode 100644 index 000000000..93bbf8127 --- /dev/null +++ b/ui/client/config/webpack.prod.ts @@ -0,0 +1,72 @@ +import path from "path"; +import merge from "webpack-merge"; +import webpack, { Configuration } from "webpack"; +import MiniCssExtractPlugin from "mini-css-extract-plugin"; +import CssMinimizerPlugin from "css-minimizer-webpack-plugin"; +import HtmlWebpackPlugin from "html-webpack-plugin"; + +import { brandingAssetPath } from "@trustify-ui/common"; +import { stylePaths } from "./stylePaths"; +import commonWebpackConfiguration from "./webpack.common"; + +const pathTo = (relativePath: string) => path.resolve(__dirname, relativePath); +const faviconPath = path.resolve(brandingAssetPath(), "favicon.ico"); + +const config = merge(commonWebpackConfiguration, { + mode: "production", + devtool: "nosources-source-map", // used to map stack traces on the client without exposing all of the source code + output: { + filename: "[name].[contenthash:8].min.js", + chunkFilename: "js/[name].[chunkhash:8].min.js", + assetModuleFilename: "assets/[name].[contenthash:8][ext]", + }, + + optimization: { + minimize: true, + minimizer: [ + "...", // The '...' string represents the webpack default TerserPlugin instance + new CssMinimizerPlugin(), + ], + }, + + module: { + rules: [ + { + test: /\.css$/, + include: [...stylePaths], + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + + plugins: [ + new MiniCssExtractPlugin({ + filename: "[name].[contenthash:8].css", + chunkFilename: "css/[name].[chunkhash:8].min.css", + }), + new CssMinimizerPlugin({ + minimizerOptions: { + preset: ["default", { mergeLonghand: false }], + }, + }), + new webpack.EnvironmentPlugin({ + NODE_ENV: "production", + }), + + // index.html generated at runtime via the express server to inject `_env` + new HtmlWebpackPlugin({ + filename: "index.html.ejs", + template: `!!raw-loader!${pathTo("../public/index.html.ejs")}`, + favicon: faviconPath, + minify: { + collapseWhitespace: false, + keepClosingSlash: true, + minifyJS: true, + removeEmptyAttributes: true, + removeRedundantAttributes: true, + }, + }), + ], +}); + +export default config; diff --git a/ui/client/package.json b/ui/client/package.json new file mode 100644 index 000000000..c6fa2ffdb --- /dev/null +++ b/ui/client/package.json @@ -0,0 +1,112 @@ +{ + "name": "@trustify-ui/client", + "version": "0.1.0", + "license": "Apache-2.0", + "private": true, + "scripts": { + "analyze": "source-map-explorer 'dist/static/js/*.js'", + "clean": "rimraf ./dist", + "prebuild": "npm run clean && npm run tsc -- --noEmit", + "build": "NODE_ENV=production webpack --config ./config/webpack.prod.ts", + "build:dev": "NODE_ENV=development webpack --config ./config/webpack.dev.ts", + "start:dev": "NODE_ENV=development webpack serve --config ./config/webpack.dev.ts", + "test": "NODE_ENV=test jest --rootDir=. --config=./config/jest.config.ts", + "lint": "eslint .", + "tsc": "tsc -p ./tsconfig.json" + }, + "lint-staged": { + "*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}": "eslint --fix", + "*.{css,json,md,yaml,yml}": "prettier --write" + }, + "dependencies": { + "@carlosthe19916-latest/react-table-batteries": "^0.0.3", + "@hookform/resolvers": "^2.9.11", + "@patternfly/patternfly": "^5.2.1", + "@patternfly/react-charts": "^7.2.1", + "@patternfly/react-code-editor": "^5.2.1", + "@patternfly/react-component-groups": "^5.0.0", + "@patternfly/react-core": "^5.2.1", + "@patternfly/react-table": "^5.2.1", + "@patternfly/react-tokens": "^5.2.1", + "@segment/analytics-next": "^1.64.0", + "@tanstack/react-query": "^5.22.2", + "@tanstack/react-query-devtools": "^5.24.0", + "axios": "^0.21.2", + "dayjs": "^1.11.7", + "ejs": "^3.1.7", + "file-saver": "^2.0.5", + "monaco-editor": "0.34.1", + "oidc-client-ts": "^2.4.0", + "packageurl-js": "^1.2.1", + "pretty-bytes": "^6.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.43.1", + "react-markdown": "^8.0.7", + "react-monaco-editor": "0.51.0", + "react-oidc-context": "^2.3.1", + "react-router-dom": "^6.21.1", + "usehooks-ts": "^2.14.0", + "web-vitals": "^0.2.4", + "xmllint": "^0.1.1", + "yaml": "^1.10.2", + "yup": "^0.32.11" + }, + "devDependencies": { + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^14.4.3", + "@types/dotenv-webpack": "^7.0.3", + "@types/ejs": "^3.1.0", + "@types/file-saver": "^2.0.2", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "browserslist": "^4.19.1", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "copy-webpack-plugin": "^11.0.0", + "css-loader": "^5.2.7", + "css-minimizer-webpack-plugin": "^3.4.1", + "dotenv-webpack": "^7.0.3", + "exports-loader": "^3.1.0", + "file-loader": "^6.2.0", + "fork-ts-checker-webpack-plugin": "^8.0.0", + "html-webpack-plugin": "^5.5.0", + "mini-css-extract-plugin": "^2.5.2", + "monaco-editor-webpack-plugin": "^7.0.1", + "msw": "^1.2.3", + "raw-loader": "^4.0.2", + "react-refresh": "^0.14.0", + "react-refresh-typescript": "^2.0.9", + "sass-loader": "^12.4.0", + "source-map-explorer": "^2.5.2", + "style-loader": "^3.3.1", + "svg-url-loader": "^7.1.1", + "terser-webpack-plugin": "^5.3.0", + "ts-loader": "^9.4.1", + "tsconfig-paths-webpack-plugin": "^4.0.0", + "url-loader": "^4.1.1", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^4.15.1", + "webpack-merge": "^5.9.0" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "nyc": { + "exclude": "client/src/reportWebVitals.ts" + }, + "msw": { + "workerDirectory": "public" + } +} diff --git a/ui/client/public/index.html.ejs b/ui/client/public/index.html.ejs new file mode 100644 index 000000000..4fc0deb38 --- /dev/null +++ b/ui/client/public/index.html.ejs @@ -0,0 +1,21 @@ + + + + <%= branding.application.title %> + + + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/ui/client/public/manifest.json b/ui/client/public/manifest.json new file mode 100644 index 000000000..b0013e63b --- /dev/null +++ b/ui/client/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "trustification-ui", + "name": "Trustification UI", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/ui/client/public/mockServiceWorker.js b/ui/client/public/mockServiceWorker.js new file mode 100644 index 000000000..e5aa935a6 --- /dev/null +++ b/ui/client/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.2.3). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = "3d6b9f06410d179a7f7404d4bf4c3c70"; +const activeClientIds = new Set(); + +self.addEventListener("install", function () { + self.skipWaiting(); +}); + +self.addEventListener("activate", function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("message", async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + switch (event.data) { + case "KEEPALIVE_REQUEST": { + sendToClient(client, { + type: "KEEPALIVE_RESPONSE", + }); + break; + } + + case "INTEGRITY_CHECK_REQUEST": { + sendToClient(client, { + type: "INTEGRITY_CHECK_RESPONSE", + payload: INTEGRITY_CHECKSUM, + }); + break; + } + + case "MOCK_ACTIVATE": { + activeClientIds.add(clientId); + + sendToClient(client, { + type: "MOCKING_ENABLED", + payload: true, + }); + break; + } + + case "MOCK_DEACTIVATE": { + activeClientIds.delete(clientId); + break; + } + + case "CLIENT_CLOSED": { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener("fetch", function (event) { + const { request } = event; + const accept = request.headers.get("accept") || ""; + + // Bypass server-sent events. + if (accept.includes("text/event-stream")) { + return; + } + + // Bypass navigation requests. + if (request.mode === "navigate") { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === "only-if-cached" && request.mode !== "same-origin") { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2); + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === "NetworkError") { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url + ); + return; + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}` + ); + }) + ); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const clonedResponse = response.clone(); + sendToClient(client, { + type: "RESPONSE", + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (client?.frameType === "top-level") { + return client; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === "visible"; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + const clonedRequest = request.clone(); + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()); + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers["x-msw-bypass"]; + + return fetch(clonedRequest, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get("x-msw-bypass") === "true") { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: "REQUEST", + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }); + + switch (clientMessage.type) { + case "MOCK_RESPONSE": { + return respondWithMock(clientMessage.data); + } + + case "MOCK_NOT_FOUND": { + return passthrough(); + } + + case "NETWORK_ERROR": { + const { name, message } = clientMessage.data; + const networkError = new Error(message); + networkError.name = name; + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError; + } + } + + return passthrough(); +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2]); + }); +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs); + }); +} + +async function respondWithMock(response) { + await sleep(response.delay); + return new Response(response.body, response); +} diff --git a/ui/client/public/robots.txt b/ui/client/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/ui/client/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/ui/client/src/app/App.css b/ui/client/src/app/App.css new file mode 100644 index 000000000..50707665f --- /dev/null +++ b/ui/client/src/app/App.css @@ -0,0 +1,10 @@ +.pf-c-select__toggle:before { + border-top: var(--pf-c-select__toggle--before--BorderTopWidth) solid + var(--pf-c-select__toggle--before--BorderTopColor) !important; + border-right: var(--pf-c-select__toggle--before--BorderRightWidth) solid + var(--pf-c-select__toggle--before--BorderRightColor) !important; + border-bottom: var(--pf-c-select__toggle--before--BorderBottomWidth) solid + var(--pf-c-select__toggle--before--BorderBottomColor) !important; + border-left: var(--pf-c-select__toggle--before--BorderLeftWidth) solid + var(--pf-c-select__toggle--before--BorderLeftColor) !important; +} diff --git a/ui/client/src/app/App.tsx b/ui/client/src/app/App.tsx new file mode 100644 index 000000000..5b7088d55 --- /dev/null +++ b/ui/client/src/app/App.tsx @@ -0,0 +1,27 @@ +import "./App.css"; +import React from "react"; +import { BrowserRouter as Router } from "react-router-dom"; + +import { DefaultLayout } from "./layout"; +import { AppRoutes } from "./Routes"; +import { NotificationsProvider } from "./components/NotificationsContext"; +import { AnalyticsProvider } from "./components/AnalyticsProvider"; + +import "@patternfly/patternfly/patternfly.css"; +import "@patternfly/patternfly/patternfly-addons.css"; + +const App: React.FC = () => { + return ( + + + + + + + + + + ); +}; + +export default App; diff --git a/ui/client/src/app/Constants.ts b/ui/client/src/app/Constants.ts new file mode 100644 index 000000000..61beb83dd --- /dev/null +++ b/ui/client/src/app/Constants.ts @@ -0,0 +1,29 @@ +import ENV from "./env"; + +export const RENDER_DATE_FORMAT = "MMM DD, YYYY"; +export const FILTER_DATE_FORMAT = "YYYY-MM-DD"; + +export const TablePersistenceKeyPrefixes = { + advisories: "ad", + cves: "cv", + sboms: "sb", + packages: "pk", +}; + +// URL param prefixes: should be short, must be unique for each table that uses one +export enum TableURLParamKeyPrefix { + repositories = "r", + tags = "t", +} + +export const isAuthRequired = ENV.AUTH_REQUIRED !== "false"; +export const isAnalyticsEnabled = ENV.ANALYTICS_ENABLED !== "false"; +export const uploadLimit = "500m"; + +/** + * The name of the client generated id field inserted in a object marked with mixin type + * `WithUiId`. + */ +export const UI_UNIQUE_ID = "_ui_unique_id"; + +export const FORM_DATA_FILE_KEY = "file"; diff --git a/ui/client/src/app/Routes.tsx b/ui/client/src/app/Routes.tsx new file mode 100644 index 000000000..4eaeda0f2 --- /dev/null +++ b/ui/client/src/app/Routes.tsx @@ -0,0 +1,70 @@ +import React, { Suspense, lazy } from "react"; +import { useParams, useRoutes } from "react-router-dom"; + +import { Bullseye, Spinner } from "@patternfly/react-core"; + +const Home = lazy(() => import("./pages/home")); +const AdvisoryList = lazy(() => import("./pages/advisory-list")); +const AdvisoryDetails = lazy(() => import("./pages/advisory-details")); +const CVEList = lazy(() => import("./pages/cve-list")); +const CVEDetails = lazy(() => import("./pages/cve-details")); +const PackageList = lazy(() => import("./pages/package-list")); +const PackageDetails = lazy(() => import("./pages/package-details")); +const SBOMList = lazy(() => import("./pages/sbom-list")); +const SBOMDetails = lazy(() => import("./pages/sbom-details")); +const ImporterList = lazy(() => import("./pages/importer-list")); + +export enum PathParam { + ADVISORY_ID = "advisoryId", + CVE_ID = "cveId", + SBOM_ID = "sbomId", + PACKAGE_ID = "packageId", + IMPORTER_ID = "importerId", +} + +export const AppRoutes = () => { + const allRoutes = useRoutes([ + { path: "/", element: }, + { path: "/advisories", element: }, + { + path: `/advisories/:${PathParam.ADVISORY_ID}`, + element: , + }, + { path: "/cves", element: }, + { + path: `/cves/:${PathParam.CVE_ID}`, + element: , + }, + { path: "/packages", element: }, + { + path: `/packages/:${PathParam.PACKAGE_ID}`, + element: , + }, + { path: "/sboms", element: }, + { + path: `/sboms/:${PathParam.SBOM_ID}`, + element: , + }, + { + path: `/importers`, + element: , + }, + ]); + + return ( + + + + } + > + {allRoutes} + + ); +}; + +export const useRouteParams = (pathParam: PathParam) => { + const params = useParams(); + return params[pathParam]; +}; diff --git a/ui/client/src/app/analytics.ts b/ui/client/src/app/analytics.ts new file mode 100644 index 000000000..1d3d7015c --- /dev/null +++ b/ui/client/src/app/analytics.ts @@ -0,0 +1,6 @@ +import { AnalyticsBrowserSettings } from "@segment/analytics-next"; +import { ENV } from "./env"; + +export const analyticsSettings: AnalyticsBrowserSettings = { + writeKey: ENV.ANALYTICS_WRITE_KEY || "", +}; diff --git a/ui/client/src/app/api/model-utils.ts b/ui/client/src/app/api/model-utils.ts new file mode 100644 index 000000000..190309d72 --- /dev/null +++ b/ui/client/src/app/api/model-utils.ts @@ -0,0 +1,67 @@ +import { + global_palette_purple_400 as criticalColor, + global_danger_color_100 as importantColor, + global_info_color_100 as lowColor, + global_warning_color_100 as moderateColor, +} from "@patternfly/react-tokens"; + +import { ProgressProps } from "@patternfly/react-core"; + +import { Severity } from "./models"; + +type ListType = { + [key in Severity]: { + name: string; + shieldIconColor: { name: string; value: string; var: string }; + progressProps: Pick; + }; +}; + +export const severityList: ListType = { + low: { + name: "Low", + shieldIconColor: lowColor, + progressProps: { variant: undefined }, + }, + moderate: { + name: "Moderate", + shieldIconColor: moderateColor, + progressProps: { variant: "warning" }, + }, + important: { + name: "Important", + shieldIconColor: importantColor, + progressProps: { variant: "danger" }, + }, + critical: { + name: "Critical", + shieldIconColor: criticalColor, + progressProps: { variant: "danger" }, + }, +}; + +const getSeverityPriority = (val: Severity) => { + switch (val) { + case "low": + return 1; + case "moderate": + return 2; + case "important": + return 3; + case "critical": + return 4; + default: + return 0; + } +}; + +export function compareBySeverityFn( + severityExtractor: (elem: T) => Severity +) { + return (a: T, b: T) => { + return ( + getSeverityPriority(severityExtractor(a)) - + getSeverityPriority(severityExtractor(b)) + ); + }; +} diff --git a/ui/client/src/app/api/models.ts b/ui/client/src/app/api/models.ts new file mode 100644 index 000000000..32ba5eb44 --- /dev/null +++ b/ui/client/src/app/api/models.ts @@ -0,0 +1,147 @@ +export type WithUiId = T & { _ui_unique_id: string }; + +/** Mark an object as "New" therefore does not have an `id` field. */ +export type New = Omit; + +export interface HubFilter { + field: string; + operator?: "=" | "!=" | "~" | ">" | ">=" | "<" | "<="; + value: + | string + | number + | { + list: (string | number)[]; + operator?: "AND" | "OR"; + }; +} + +export interface HubRequestParams { + filters?: HubFilter[]; + sort?: { + field: string; + direction: "asc" | "desc"; + }; + page?: { + pageNumber: number; // 1-indexed + itemsPerPage: number; + }; +} + +export interface HubPaginatedResult { + data: T[]; + total: number; + params: HubRequestParams; +} + +// Base + +export interface CVEBase { + id: string; + title: string; + description: string; + severity: Severity; + cwe: string; + date_discovered: string; + date_released: string; + date_reserved: string; + date_updated: string; +} + +export interface PackageBase { + id: string; + namespace: string; + name: string; + version: string; + type: string; + path?: string; + qualifiers: { [key: string]: string }; +} + +export interface SBOMBase { + id: string; + type: "CycloneDX" | "SPDX"; + name: string; + version: string; + supplier: string; + created_on: string; +} + +export interface AdvisoryBase { + id: string; + severity: Severity; + modified: string; + title: string; + metadata: { + category: string; + publisher: { + name: string; + namespace: string; + contact_details: string; + issuing_authority: string; + }; + tracking: { + status: string; + initial_release_date: string; + current_release_date: string; + }; + references: { + url: string; + label?: string; + }[]; + notes: string[]; + }; +} + +// Advisories + +export type Severity = "low" | "moderate" | "important" | "critical"; + +export interface Advisory extends AdvisoryBase { + cves: CVEBase[]; +} + +// CVE + +export interface CVE extends CVEBase { + related_sboms: SBOMBase[]; + related_advisories: AdvisoryBase[]; +} + +// Package + +export interface Package extends PackageBase { + related_cves: CVEBase[]; + related_sboms: SBOMBase[]; +} + +// SBOM + +export interface SBOM extends SBOMBase { + related_packages: { + count: number; + }; + related_cves: { [key in Severity]: number }; +} + +// Importer + +export type ImporterStatus = "waiting" | "running"; + +export interface Importer { + name: string; + configuration: ImporterConfiguration; + state?: ImporterStatus; +} + +export interface ImporterConfiguration { + sbom?: ImporterConfigurationValues; + csaf?: ImporterConfigurationValues; +} + +export interface ImporterConfigurationValues { + period: string; + source: string; + disabled: boolean; + v3Signatures: boolean; + onlyPatterns: string[]; +} diff --git a/ui/client/src/app/api/rest.ts b/ui/client/src/app/api/rest.ts new file mode 100644 index 000000000..e937589ba --- /dev/null +++ b/ui/client/src/app/api/rest.ts @@ -0,0 +1,182 @@ +import axios, { AxiosRequestConfig } from "axios"; + +import { FORM_DATA_FILE_KEY } from "@app/Constants"; +import { serializeRequestParamsForHub } from "@app/hooks/table-controls/getHubRequestParams"; +import { + Advisory, + CVE, + HubPaginatedResult, + HubRequestParams, + Importer, + ImporterConfiguration, + Package, + SBOM, +} from "./models"; + +const API = "/api"; + +export const ADVISORIES = API + "/advisories"; +export const ADVISORIES_SEARCH = API + "/v1/search/advisory"; +export const CVES = API + "/cves"; +export const SBOMS = API + "/sboms"; +export const PACKAGES = API + "/packages"; +export const IMPORTERS = API + "/v1/importer"; + +export interface PaginatedResponse { + items: T[]; + total: number; +} + +export const getHubPaginatedResult = ( + url: string, + params: HubRequestParams = {} +): Promise> => + axios + .get>(url, { + params: serializeRequestParamsForHub(params), + }) + .then(({ data }) => ({ + data: data.items, + total: data.total, + params, + })); + +// + +export const getAdvisories = (params: HubRequestParams = {}) => { + return getHubPaginatedResult(ADVISORIES_SEARCH, params); +}; + +export const getAdvisoryById = (id: number | string) => { + return axios + .get(`${ADVISORIES}/${id}`) + .then((response) => response.data); +}; + +export const getAdvisorySourceById = (id: number | string) => { + return axios + .get(`${ADVISORIES}/${id}/source`) + .then((response) => response.data); +}; + +export const downloadAdvisoryById = (id: number | string) => { + return axios.get(`${ADVISORIES}/${id}/source`, { + responseType: "arraybuffer", + headers: { Accept: "text/plain", responseType: "blob" }, + }); +}; + +export const uploadAdvisory = ( + formData: FormData, + config?: AxiosRequestConfig +) => { + const file = formData.get(FORM_DATA_FILE_KEY) as File; + return file.text().then((text) => { + const json = JSON.parse(text); + return axios.post(`${ADVISORIES}`, json, config); + }); +}; + +// + +export const getCVEs = (params: HubRequestParams = {}) => { + return getHubPaginatedResult(CVES, params); +}; + +export const getCVEById = (id: number | string) => { + return axios.get(`${CVES}/${id}`).then((response) => response.data); +}; + +export const getCVESourceById = (id: number | string) => { + return axios + .get(`${CVES}/${id}/source`) + .then((response) => response.data); +}; + +export const downloadCVEById = (id: number | string) => { + return axios.get(`${CVES}/${id}/source`, { + responseType: "arraybuffer", + headers: { Accept: "text/plain", responseType: "blob" }, + }); +}; + +// + +export const getPackages = (params: HubRequestParams = {}) => { + return getHubPaginatedResult(PACKAGES, params); +}; + +export const getPackageById = (id: number | string) => { + return axios + .get(`${PACKAGES}/${id}`) + .then((response) => response.data); +}; + +// + +export const getSBOMs = (params: HubRequestParams = {}) => { + return getHubPaginatedResult(SBOMS, params); +}; + +export const getSBOMById = (id: number | string) => { + return axios.get(`${SBOMS}/${id}`).then((response) => response.data); +}; + +export const getSBOMSourceById = (id: number | string) => { + return axios + .get(`${SBOMS}/${id}/source`) + .then((response) => response.data); +}; + +export const downloadSBOMById = (id: number | string) => { + return axios.get(`${SBOMS}/${id}/source`, { + responseType: "arraybuffer", + headers: { Accept: "text/plain", responseType: "blob" }, + }); +}; + +export const getPackagesBySbomId = (id: string | number) => { + return axios + .get(`${SBOMS}/${id}/packages`) + .then((response) => response.data); +}; + +export const getCVEsBySbomId = (id: string | number) => { + return axios + .get(`${SBOMS}/${id}/cves`) + .then((response) => response.data); +}; + +// + +export const getImporters = () => { + return axios.get(IMPORTERS).then((response) => response.data); +}; + +export const getImporterById = (id: number | string) => { + return axios + .get(`${IMPORTERS}/${id}`) + .then((response) => response.data); +}; + +export const createImporter = ( + id: number | string, + body: ImporterConfiguration +) => { + return axios.post(`${IMPORTERS}/${id}`, body); +}; + +export const updateImporter = ( + id: number | string, + body: ImporterConfiguration +) => { + return axios + .put(`${IMPORTERS}/${id}`, body) + .then((response) => response.data); +}; + +export const deleteImporter = (id: number | string) => { + return axios + .delete(`${IMPORTERS}/${id}`) + .then((response) => response.data); +}; diff --git a/ui/client/src/app/axios-config/apiInit.ts b/ui/client/src/app/axios-config/apiInit.ts new file mode 100644 index 000000000..8c16f7dac --- /dev/null +++ b/ui/client/src/app/axios-config/apiInit.ts @@ -0,0 +1,30 @@ +import ENV from "@app/env"; +import axios from "axios"; +import { User } from "oidc-client-ts"; + +function getUser() { + const oidcStorage = sessionStorage.getItem( + `oidc.user:${ENV.OIDC_SERVER_URL}:${ENV.OIDC_CLIENT_ID}` + ); + if (!oidcStorage) { + return null; + } + + return User.fromStorageString(oidcStorage); +} + +export const initInterceptors = () => { + axios.interceptors.request.use( + (config) => { + const user = getUser(); + const token = user?.access_token; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } + ); +}; diff --git a/ui/client/src/app/axios-config/index.ts b/ui/client/src/app/axios-config/index.ts new file mode 100644 index 000000000..98b6062d1 --- /dev/null +++ b/ui/client/src/app/axios-config/index.ts @@ -0,0 +1 @@ +export { initInterceptors } from "./apiInit"; diff --git a/ui/client/src/app/common/types.ts b/ui/client/src/app/common/types.ts new file mode 100644 index 000000000..2f6e5bcde --- /dev/null +++ b/ui/client/src/app/common/types.ts @@ -0,0 +1,9 @@ +export interface Page { + page: number; + perPage: number; +} + +export interface SortBy { + index: number; + direction: 'asc' | 'desc'; +} diff --git a/ui/client/src/app/components/AnalyticsProvider.tsx b/ui/client/src/app/components/AnalyticsProvider.tsx new file mode 100644 index 000000000..cdfe401c8 --- /dev/null +++ b/ui/client/src/app/components/AnalyticsProvider.tsx @@ -0,0 +1,56 @@ +import React, { useEffect } from "react"; +import { useLocation } from "react-router"; +import { useAuth } from "react-oidc-context"; +import { AnalyticsBrowser } from "@segment/analytics-next"; +import ENV from "@app/env"; +import { isAuthRequired } from "@app/Constants"; +import { analyticsSettings } from "@app/analytics"; + +const AnalyticsContext = React.createContext(undefined!); + +interface IAnalyticsProviderProps { + children: React.ReactNode; +} + +export const AnalyticsProvider: React.FC = ({ + children, +}) => { + return ENV.ANALYTICS_ENABLED !== "true" ? ( + <>{children} + ) : ( + {children} + ); +}; + +export const AnalyticsContextProvider: React.FC = ({ + children, +}) => { + const auth = (isAuthRequired && useAuth()) || undefined; + const analytics = React.useMemo(() => { + return AnalyticsBrowser.load(analyticsSettings); + }, []); + + // Identify + useEffect(() => { + if (auth) { + const claims = auth.user?.profile; + analytics.identify(claims?.sub, { + /* eslint-disable @typescript-eslint/no-explicit-any */ + organization_id: ((claims as any)?.organization as any)?.id, + domain: claims?.email?.split("@")[1], + }); + } + }, [auth, analytics]); + + // Watch navigation + const location = useLocation(); + useEffect(() => { + analytics.page(); + }, [analytics, location]); + + return ( + + {children} + + ); +}; diff --git a/ui/client/src/app/components/AppPlaceholder.tsx b/ui/client/src/app/components/AppPlaceholder.tsx new file mode 100644 index 000000000..a7850977c --- /dev/null +++ b/ui/client/src/app/components/AppPlaceholder.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { Bullseye, Spinner } from "@patternfly/react-core"; + +export const AppPlaceholder: React.FC = () => { + return ( + +
+
+ +
+
+

Loading...

+
+
+
+ ); +}; diff --git a/ui/client/src/app/components/ConfirmDialog.tsx b/ui/client/src/app/components/ConfirmDialog.tsx new file mode 100644 index 000000000..74c001c69 --- /dev/null +++ b/ui/client/src/app/components/ConfirmDialog.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { + Button, + Modal, + ButtonVariant, + ModalVariant, +} from "@patternfly/react-core"; + +export interface ConfirmDialogProps { + isOpen: boolean; + + title: string; + titleIconVariant?: + | "success" + | "danger" + | "warning" + | "info" + /* eslint-disable @typescript-eslint/no-explicit-any */ + | React.ComponentType; + message: string | React.ReactNode; + + confirmBtnLabel: string; + cancelBtnLabel: string; + + inProgress?: boolean; + confirmBtnVariant: ButtonVariant; + + onClose: () => void; + onConfirm: () => void; + onCancel?: () => void; +} + +export const ConfirmDialog: React.FC = ({ + isOpen, + title, + titleIconVariant, + message, + confirmBtnLabel, + cancelBtnLabel, + inProgress, + confirmBtnVariant, + onClose, + onConfirm, + onCancel, +}) => { + const confirmBtn = ( + + ); + + const cancelBtn = onCancel ? ( + + ) : undefined; + + return ( + + {message} + + ); +}; diff --git a/ui/client/src/app/components/CveGallery.tsx b/ui/client/src/app/components/CveGallery.tsx new file mode 100644 index 000000000..7bd5d3859 --- /dev/null +++ b/ui/client/src/app/components/CveGallery.tsx @@ -0,0 +1,57 @@ +import React from "react"; + +import { Divider, Flex, FlexItem } from "@patternfly/react-core"; + +import { compareBySeverityFn } from "@app/api/model-utils"; +import { Severity } from "@app/api/models"; +import { SeverityShieldAndText } from "@app/components/SeverityShieldAndText"; + +interface CVEGalleryProps { + severities: { [key in Severity]: number }; +} + +export const CveGallery: React.FC = ({ severities }) => { + const severityCount = Object.values(severities).reduce((prev, acc) => { + return prev + acc; + }, 0); + + return ( + + {severityCount} + + + + {Object.entries(severities) + .filter(([_severity, count]) => count > 0) + .sort( + compareBySeverityFn(([severity, _count]) => severity as Severity) + ) + .reverse() + .map(([severity, count], index) => ( + + + + + + {count} + + + ))} + + + + ); +}; diff --git a/ui/client/src/app/components/FilterToolbar/FilterControl.tsx b/ui/client/src/app/components/FilterToolbar/FilterControl.tsx new file mode 100644 index 000000000..52a0f73b4 --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/FilterControl.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; + +import { + FilterCategory, + FilterValue, + FilterType, + ISelectFilterCategory, + ISearchFilterCategory, + IMultiselectFilterCategory, +} from "./FilterToolbar"; +import { SelectFilterControl } from "./SelectFilterControl"; +import { SearchFilterControl } from "./SearchFilterControl"; +import { MultiselectFilterControl } from "./MultiselectFilterControl"; + +export interface IFilterControlProps { + category: FilterCategory; + filterValue: FilterValue; + setFilterValue: (newValue: FilterValue) => void; + showToolbarItem: boolean; + isDisabled?: boolean; +} + +export const FilterControl = ({ + category, + ...props +}: React.PropsWithChildren< + IFilterControlProps +>): JSX.Element | null => { + if (category.type === FilterType.select) { + return ( + } + {...props} + /> + ); + } + if ( + category.type === FilterType.search || + category.type === FilterType.numsearch + ) { + return ( + } + isNumeric={category.type === FilterType.numsearch} + {...props} + /> + ); + } + if (category.type === FilterType.multiselect) { + return ( + + } + {...props} + /> + ); + } + return null; +}; diff --git a/ui/client/src/app/components/FilterToolbar/FilterToolbar.tsx b/ui/client/src/app/components/FilterToolbar/FilterToolbar.tsx new file mode 100644 index 000000000..eaa169a2d --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/FilterToolbar.tsx @@ -0,0 +1,239 @@ +import * as React from "react"; +import { + Dropdown, + DropdownItem, + DropdownGroup, + DropdownList, + MenuToggle, + SelectOptionProps, + ToolbarToggleGroup, + ToolbarItem, +} from "@patternfly/react-core"; +import FilterIcon from "@patternfly/react-icons/dist/esm/icons/filter-icon"; + +import { FilterControl } from "./FilterControl"; + +export enum FilterType { + select = "select", + multiselect = "multiselect", + search = "search", + numsearch = "numsearch", +} + +export type FilterValue = string[] | undefined | null; + +export interface FilterSelectOptionProps { + optionProps?: SelectOptionProps; + value: string; + label?: string; + chipLabel?: string; + groupLabel?: string; +} + +export interface IBasicFilterCategory< + /** The actual API objects we're filtering */ + TItem, + TFilterCategoryKey extends string, // Unique identifiers for each filter category (inferred from key properties if possible) +> { + /** For use in the filterValues state object. Must be unique per category. */ + categoryKey: TFilterCategoryKey; + /** Title of the filter as displayed in the filter selection dropdown and filter chip groups. */ + title: string; + /** Type of filter component to use to select the filter's content. */ + type: FilterType; + /** Optional grouping to display this filter in the filter selection dropdown. */ + filterGroup?: string; + /** For client side filtering, return the value of `TItem` the filter will be applied against. */ + getItemValue?: (item: TItem) => string | boolean; // For client-side filtering + /** For server-side filtering, defaults to `key` if omitted. Does not need to be unique if the server supports joining repeated filters. */ + serverFilterField?: string; + /** + * For server-side filtering, return the search value for currently selected filter items. + * Defaults to using the UI state's value if omitted. + */ + getServerFilterValue?: (filterValue: FilterValue) => string[] | undefined; +} + +export interface IMultiselectFilterCategory< + TItem, + TFilterCategoryKey extends string, +> extends IBasicFilterCategory { + /** The full set of options to select from for this filter. */ + selectOptions: + | FilterSelectOptionProps[] + | Record; + /** Option search input field placeholder text. */ + placeholderText?: string; + /** How to connect multiple selected options together. Defaults to "AND". */ + logicOperator?: "AND" | "OR"; +} + +export interface ISelectFilterCategory + extends IBasicFilterCategory { + selectOptions: FilterSelectOptionProps[]; +} + +export interface ISearchFilterCategory + extends IBasicFilterCategory { + placeholderText: string; +} + +export type FilterCategory = + | IMultiselectFilterCategory + | ISelectFilterCategory + | ISearchFilterCategory; + +export type IFilterValues = Partial< + Record +>; + +export const getFilterLogicOperator = < + TItem, + TFilterCategoryKey extends string, +>( + filterCategory?: FilterCategory, + defaultOperator: "AND" | "OR" = "OR" +) => + (filterCategory && + (filterCategory as IMultiselectFilterCategory) + .logicOperator) || + defaultOperator; + +export interface IFilterToolbarProps { + filterCategories: FilterCategory[]; + filterValues: IFilterValues; + setFilterValues: (values: IFilterValues) => void; + beginToolbarItems?: JSX.Element; + endToolbarItems?: JSX.Element; + pagination?: JSX.Element; + showFiltersSideBySide?: boolean; + isDisabled?: boolean; +} + +export const FilterToolbar = ({ + filterCategories, + filterValues, + setFilterValues, + pagination, + showFiltersSideBySide = false, + isDisabled = false, +}: React.PropsWithChildren< + IFilterToolbarProps +>): JSX.Element | null => { + const [isCategoryDropdownOpen, setIsCategoryDropdownOpen] = + React.useState(false); + const [currentFilterCategoryKey, setCurrentFilterCategoryKey] = + React.useState(filterCategories[0].categoryKey); + + const onCategorySelect = ( + category: FilterCategory + ) => { + setCurrentFilterCategoryKey(category.categoryKey); + setIsCategoryDropdownOpen(false); + }; + + const setFilterValue = ( + category: FilterCategory, + newValue: FilterValue + ) => setFilterValues({ ...filterValues, [category.categoryKey]: newValue }); + + const currentFilterCategory = filterCategories.find( + (category) => category.categoryKey === currentFilterCategoryKey + ); + + const filterGroups = filterCategories.reduce( + (groups, category) => + !category.filterGroup || groups.includes(category.filterGroup) + ? groups + : [...groups, category.filterGroup], + [] as string[] + ); + + const renderDropdownItems = () => { + if (filterGroups.length) { + return filterGroups.map((filterGroup) => ( + + + {filterCategories + .filter( + (filterCategory) => filterCategory.filterGroup === filterGroup + ) + .map((filterCategory) => { + return ( + onCategorySelect(filterCategory)} + > + {filterCategory.title} + + ); + })} + + + )); + } else { + return filterCategories.map((category) => ( + onCategorySelect(category)} + > + {category.title} + + )); + } + }; + + return ( + <> + } + breakpoint="2xl" + spaceItems={ + showFiltersSideBySide ? { default: "spaceItemsMd" } : undefined + } + > + {!showFiltersSideBySide && ( + + ( + + setIsCategoryDropdownOpen(!isCategoryDropdownOpen) + } + isDisabled={isDisabled} + > + {currentFilterCategory?.title} + + )} + isOpen={isCategoryDropdownOpen} + > + {renderDropdownItems()} + + + )} + + {filterCategories.map((category) => ( + + key={category.categoryKey} + category={category} + filterValue={filterValues[category.categoryKey]} + setFilterValue={(newValue) => setFilterValue(category, newValue)} + showToolbarItem={ + showFiltersSideBySide || + currentFilterCategory?.categoryKey === category.categoryKey + } + isDisabled={isDisabled} + /> + ))} + + {pagination ? ( + {pagination} + ) : null} + + ); +}; diff --git a/ui/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx b/ui/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx new file mode 100644 index 000000000..25f4ba4a5 --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx @@ -0,0 +1,363 @@ +import * as React from "react"; +import { + Badge, + Button, + MenuToggle, + MenuToggleElement, + Select, + SelectGroup, + SelectList, + SelectOption, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + ToolbarChip, + ToolbarFilter, + Tooltip, +} from "@patternfly/react-core"; +import { IFilterControlProps } from "./FilterControl"; +import { + IMultiselectFilterCategory, + FilterSelectOptionProps, +} from "./FilterToolbar"; +import { css } from "@patternfly/react-styles"; +import { TimesIcon } from "@patternfly/react-icons"; + +import "./select-overrides.css"; + +export interface IMultiselectFilterControlProps + extends IFilterControlProps { + category: IMultiselectFilterCategory; + isScrollable?: boolean; +} + +export const MultiselectFilterControl = ({ + category, + filterValue, + setFilterValue, + showToolbarItem, + isDisabled = false, + isScrollable = false, +}: React.PropsWithChildren< + IMultiselectFilterControlProps +>): JSX.Element | null => { + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = React.useState(false); + + const [selectOptions, setSelectOptions] = React.useState< + FilterSelectOptionProps[] + >(Array.isArray(category.selectOptions) ? category.selectOptions : []); + + React.useEffect(() => { + setSelectOptions( + Array.isArray(category.selectOptions) ? category.selectOptions : [] + ); + }, [category.selectOptions]); + + const hasGroupings = !Array.isArray(selectOptions); + + const flatOptions: FilterSelectOptionProps[] = !hasGroupings + ? selectOptions + : (Object.values(selectOptions).flatMap( + (i) => i + ) as FilterSelectOptionProps[]); + + const getOptionFromOptionValue = (optionValue: string) => + flatOptions.find(({ value }) => value === optionValue); + + const [focusedItemIndex, setFocusedItemIndex] = React.useState( + null + ); + + const [activeItem, setActiveItem] = React.useState(null); + const textInputRef = React.useRef(); + const [inputValue, setInputValue] = React.useState(""); + + const onFilterClearAll = () => setFilterValue([]); + const onFilterClear = (chip: string | ToolbarChip) => { + const value = typeof chip === "string" ? chip : chip.key; + + if (value) { + const newValue = filterValue?.filter((val) => val !== value) ?? []; + setFilterValue(newValue.length > 0 ? newValue : null); + } + }; + + /* + * Note: Create chips only as `ToolbarChip` (no plain string) + */ + const chips = filterValue + ?.map((value, index) => { + const option = getOptionFromOptionValue(value); + if (!option) { + return null; + } + + const { chipLabel, label, groupLabel } = option; + const displayValue: string = chipLabel ?? label ?? value ?? ""; + + return { + key: value, + node: groupLabel ? ( + {groupLabel}} + > +
{displayValue}
+
+ ) : ( + displayValue + ), + }; + }) + + .filter(Boolean); + + const renderSelectOptions = ( + filter: (option: FilterSelectOptionProps, groupName?: string) => boolean + ) => + hasGroupings + ? Object.entries( + selectOptions as Record + ) + .sort(([groupA], [groupB]) => groupA.localeCompare(groupB)) + .map(([group, options]): [string, FilterSelectOptionProps[]] => [ + group, + options?.filter((o) => filter(o, group)) ?? [], + ]) + .filter(([, groupFiltered]) => groupFiltered?.length) + .map(([group, groupFiltered], index) => ( + + {groupFiltered.map(({ value, label, optionProps }) => ( + + {label ?? value} + + ))} + + )) + : flatOptions + .filter((o) => filter(o)) + .map(({ label, value, optionProps = {} }, index) => ( + + {label ?? value} + + )); + + const onSelect = (value: string | undefined) => { + if (value && value !== "No results") { + let newFilterValue: string[]; + + if (filterValue && filterValue.includes(value)) { + newFilterValue = filterValue.filter((item) => item !== value); + } else { + newFilterValue = filterValue ? [...filterValue, value] : [value]; + } + + setFilterValue(newFilterValue); + } + textInputRef.current?.focus(); + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + if (isFilterDropdownOpen) { + if (key === "ArrowUp") { + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + } + + if (key === "ArrowDown") { + if ( + focusedItemIndex === null || + focusedItemIndex === selectOptions.length - 1 + ) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + setFocusedItemIndex(indexToFocus); + const focusedItem = selectOptions.filter( + ({ optionProps }) => !optionProps?.isDisabled + )[indexToFocus]; + setActiveItem( + `select-multi-typeahead-checkbox-${focusedItem.value.replace(" ", "-")}` + ); + } + }; + + React.useEffect(() => { + let newSelectOptions = Array.isArray(category.selectOptions) + ? category.selectOptions + : []; + + if (inputValue) { + newSelectOptions = Array.isArray(category.selectOptions) + ? category.selectOptions?.filter((menuItem) => + String(menuItem.value) + .toLowerCase() + .includes(inputValue.trim().toLowerCase()) + ) + : []; + + if (!newSelectOptions.length) { + newSelectOptions = [ + { + value: "no-results", + optionProps: { + isDisabled: true, + hasCheckbox: false, + }, + label: `No results found for "${inputValue}"`, + }, + ]; + } + } + + setSelectOptions(newSelectOptions); + setFocusedItemIndex(null); + setActiveItem(null); + }, [inputValue, category.selectOptions]); + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const enabledMenuItems = Array.isArray(selectOptions) + ? selectOptions.filter(({ optionProps }) => !optionProps?.isDisabled) + : []; + const [firstMenuItem] = enabledMenuItems; + const focusedItem = focusedItemIndex + ? enabledMenuItems[focusedItemIndex] + : firstMenuItem; + + const newSelectOptions = flatOptions.filter((menuItem) => + menuItem.value.toLowerCase().includes(inputValue.toLowerCase()) + ); + const selectedItem = + newSelectOptions.find( + (option) => option.value.toLowerCase() === inputValue.toLowerCase() + ) || focusedItem; + + switch (event.key) { + case "Enter": + if (!isFilterDropdownOpen) { + setIsFilterDropdownOpen((prev) => !prev); + } else if (selectedItem && selectedItem.value !== "No results") { + onSelect(selectedItem.value); + } + break; + case "Tab": + case "Escape": + setIsFilterDropdownOpen(false); + setActiveItem(null); + break; + case "ArrowUp": + case "ArrowDown": + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + default: + break; + } + }; + + const onTextInputChange = ( + _event: React.FormEvent, + value: string + ) => { + setInputValue(value); + if (!isFilterDropdownOpen) { + setIsFilterDropdownOpen(true); + } + }; + + const toggle = (toggleRef: React.Ref) => ( + { + setIsFilterDropdownOpen(!isFilterDropdownOpen); + }} + isExpanded={isFilterDropdownOpen} + isDisabled={isDisabled || !category.selectOptions.length} + isFullWidth + > + + { + setIsFilterDropdownOpen(!isFilterDropdownOpen); + }} + onChange={onTextInputChange} + onKeyDown={onInputKeyDown} + id="typeahead-select-input" + autoComplete="off" + innerRef={textInputRef} + placeholder={category.placeholderText} + {...(activeItem && { "aria-activedescendant": activeItem })} + role="combobox" + isExpanded={isFilterDropdownOpen} + aria-controls="select-typeahead-listbox" + /> + + + {!!inputValue && ( + + )} + {filterValue?.length ? ( + {filterValue.length} + ) : null} + + + + ); + + return ( + onFilterClear(chip)} + deleteChipGroup={onFilterClearAll} + categoryName={category.title} + showToolbarItem={showToolbarItem} + > + + + ); +}; diff --git a/ui/client/src/app/components/FilterToolbar/SearchFilterControl.tsx b/ui/client/src/app/components/FilterToolbar/SearchFilterControl.tsx new file mode 100644 index 000000000..02244919e --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/SearchFilterControl.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import { + ToolbarFilter, + InputGroup, + TextInput, + Button, + ButtonVariant, +} from "@patternfly/react-core"; +import SearchIcon from "@patternfly/react-icons/dist/esm/icons/search-icon"; +import { IFilterControlProps } from "./FilterControl"; +import { ISearchFilterCategory } from "./FilterToolbar"; + +export interface ISearchFilterControlProps< + TItem, + TFilterCategoryKey extends string, +> extends IFilterControlProps { + category: ISearchFilterCategory; + isNumeric: boolean; +} + +export const SearchFilterControl = ({ + category, + filterValue, + setFilterValue, + showToolbarItem, + isNumeric, + isDisabled = false, +}: React.PropsWithChildren< + ISearchFilterControlProps +>): JSX.Element | null => { + // Keep internal copy of value until submitted by user + const [inputValue, setInputValue] = React.useState(filterValue?.[0] || ""); + // Update it if it changes externally + React.useEffect(() => { + setInputValue(filterValue?.[0] || ""); + }, [filterValue]); + + const onFilterSubmit = () => { + const trimmedValue = inputValue.trim(); + setFilterValue(trimmedValue ? [trimmedValue.replace(/\s+/g, " ")] : []); + }; + + const id = `${category.categoryKey}-input`; + return ( + setFilterValue([])} + categoryName={category.title} + showToolbarItem={showToolbarItem} + > + + setInputValue(value)} + aria-label={`${category.title} filter`} + value={inputValue} + placeholder={category.placeholderText} + onKeyDown={(event: React.KeyboardEvent) => { + if (event.key && event.key !== "Enter") return; + onFilterSubmit(); + }} + isDisabled={isDisabled} + /> + + + + ); +}; diff --git a/ui/client/src/app/components/FilterToolbar/SelectFilterControl.tsx b/ui/client/src/app/components/FilterToolbar/SelectFilterControl.tsx new file mode 100644 index 000000000..30eabc379 --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/SelectFilterControl.tsx @@ -0,0 +1,129 @@ +import * as React from "react"; +import { + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, + ToolbarFilter, +} from "@patternfly/react-core"; +import { IFilterControlProps } from "./FilterControl"; +import { ISelectFilterCategory } from "./FilterToolbar"; +import { css } from "@patternfly/react-styles"; + +import "./select-overrides.css"; + +export interface ISelectFilterControlProps< + TItem, + TFilterCategoryKey extends string, +> extends IFilterControlProps { + category: ISelectFilterCategory; + isScrollable?: boolean; +} + +export const SelectFilterControl = ({ + category, + filterValue, + setFilterValue, + showToolbarItem, + isDisabled = false, + isScrollable = false, +}: React.PropsWithChildren< + ISelectFilterControlProps +>): JSX.Element | null => { + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = React.useState(false); + + const getOptionFromOptionValue = (optionValue: string) => + category.selectOptions.find(({ value }) => value === optionValue); + + const chips = filterValue + ?.map((value) => { + const option = getOptionFromOptionValue(value); + if (!option) { + return null; + } + const { chipLabel, label } = option; + return { + key: value, + node: chipLabel ?? label ?? value, + }; + }) + .filter(Boolean); + + const onFilterSelect = (value: string) => { + const option = getOptionFromOptionValue(value); + setFilterValue(option ? [value] : null); + setIsFilterDropdownOpen(false); + }; + + const onFilterClear = (chip: string) => { + const newValue = filterValue?.filter((val) => val !== chip); + setFilterValue(newValue?.length ? newValue : null); + }; + + const toggle = (toggleRef: React.Ref) => { + let displayText = "Any"; + if (filterValue && filterValue.length > 0) { + const selectedKey = filterValue[0]; + const selectedDisplayValue = getOptionFromOptionValue(selectedKey)?.label; + displayText = selectedDisplayValue ? selectedDisplayValue : selectedKey; + } + + return ( + { + setIsFilterDropdownOpen(!isFilterDropdownOpen); + }} + isExpanded={isFilterDropdownOpen} + isDisabled={isDisabled || category.selectOptions.length === 0} + > + {displayText} + + ); + }; + + return ( + onFilterClear(chip as string)} + categoryName={category.title} + showToolbarItem={showToolbarItem} + > + + + ); +}; diff --git a/ui/client/src/app/components/FilterToolbar/index.ts b/ui/client/src/app/components/FilterToolbar/index.ts new file mode 100644 index 000000000..bab2f2751 --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/index.ts @@ -0,0 +1 @@ +export * from "./FilterToolbar"; diff --git a/ui/client/src/app/components/FilterToolbar/select-overrides.css b/ui/client/src/app/components/FilterToolbar/select-overrides.css new file mode 100644 index 000000000..8fe8da929 --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/select-overrides.css @@ -0,0 +1,4 @@ +.pf-v5-c-select.isScrollable .pf-v5-c-select__menu { + max-height: 60vh; + overflow-y: auto; +} diff --git a/ui/client/src/app/components/HookFormPFFields/HookFormPFGroupController.tsx b/ui/client/src/app/components/HookFormPFFields/HookFormPFGroupController.tsx new file mode 100644 index 000000000..47fe53f5a --- /dev/null +++ b/ui/client/src/app/components/HookFormPFFields/HookFormPFGroupController.tsx @@ -0,0 +1,135 @@ +import * as React from "react"; +import { + FormGroup, + FormGroupProps, + FormHelperText, + HelperText, + HelperTextItem, +} from "@patternfly/react-core"; +import { + Control, + Controller, + ControllerProps, + FieldValues, + Path, +} from "react-hook-form"; + +// We have separate interfaces for these props with and without `renderInput` for convenience. +// Generic type params here are the same as the ones used by react-hook-form's . +export interface BaseHookFormPFGroupControllerProps< + TFieldValues extends FieldValues, + TName extends Path, +> { + control: Control; + label?: React.ReactNode; + labelIcon?: React.ReactElement; + name: TName; + fieldId: string; + isRequired?: boolean; + errorsSuppressed?: boolean; + helperText?: React.ReactNode; + className?: string; + formGroupProps?: FormGroupProps; +} + +export interface HookFormPFGroupControllerProps< + TFieldValues extends FieldValues, + TName extends Path, +> extends BaseHookFormPFGroupControllerProps { + renderInput: ControllerProps["render"]; +} + +export const HookFormPFGroupController = < + TFieldValues extends FieldValues = FieldValues, + TName extends Path = Path, +>({ + control, + label, + labelIcon, + name, + fieldId, + isRequired = false, + errorsSuppressed = false, + helperText, + className, + formGroupProps = {}, + renderInput, +}: HookFormPFGroupControllerProps) => ( + + control={control} + name={name} + render={({ field, fieldState, formState }) => { + const { isDirty, isTouched, error } = fieldState; + const shouldDisplayError = + error?.message && (isDirty || isTouched) && !errorsSuppressed; + return ( + + {renderInput({ field, fieldState, formState })} + {helperText || shouldDisplayError ? ( + + + + {shouldDisplayError ? error.message : helperText} + + + + ) : null} + + ); + }} + /> +); + +// Utility for pulling props needed by this component and passing the rest to a rendered input +export const extractGroupControllerProps = < + TFieldValues extends FieldValues, + TName extends Path, + TProps extends BaseHookFormPFGroupControllerProps, +>( + props: TProps +): { + extractedProps: BaseHookFormPFGroupControllerProps; + remainingProps: Omit< + TProps, + keyof BaseHookFormPFGroupControllerProps + >; +} => { + const { + control, + label, + labelIcon, + name, + fieldId, + isRequired, + errorsSuppressed, + helperText, + className, + formGroupProps, + ...remainingProps + } = props; + return { + extractedProps: { + control, + labelIcon, + label, + name, + fieldId, + isRequired, + errorsSuppressed, + helperText, + className, + formGroupProps, + }, + remainingProps, + }; +}; diff --git a/ui/client/src/app/components/HookFormPFFields/HookFormPFSelect.tsx b/ui/client/src/app/components/HookFormPFFields/HookFormPFSelect.tsx new file mode 100644 index 000000000..129dbd133 --- /dev/null +++ b/ui/client/src/app/components/HookFormPFFields/HookFormPFSelect.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import { FieldValues, Path } from "react-hook-form"; +import { FormSelect, FormSelectProps } from "@patternfly/react-core"; +import { getValidatedFromErrors } from "@app/utils/utils"; +import { + extractGroupControllerProps, + HookFormPFGroupController, + BaseHookFormPFGroupControllerProps, +} from "./HookFormPFGroupController"; + +export type HookFormPFSelectProps< + TFieldValues extends FieldValues, + TName extends Path, +> = FormSelectProps & + BaseHookFormPFGroupControllerProps & { + children: React.ReactNode; + }; + +export const HookFormPFSelect = < + TFieldValues extends FieldValues = FieldValues, + TName extends Path = Path, +>( + props: HookFormPFSelectProps +) => { + const { extractedProps, remainingProps } = extractGroupControllerProps< + TFieldValues, + TName, + HookFormPFSelectProps + >(props); + const { fieldId, helperText, isRequired, errorsSuppressed } = extractedProps; + const { children, ref, ...rest } = remainingProps; + + return ( + + {...extractedProps} + renderInput={({ + field: { onChange, onBlur, value, name, ref }, + fieldState: { isDirty, error, isTouched }, + }) => ( + + {props.children} + + )} + /> + ); +}; diff --git a/ui/client/src/app/components/HookFormPFFields/HookFormPFTextArea.tsx b/ui/client/src/app/components/HookFormPFFields/HookFormPFTextArea.tsx new file mode 100644 index 000000000..9c6b93416 --- /dev/null +++ b/ui/client/src/app/components/HookFormPFFields/HookFormPFTextArea.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import { FieldValues, Path } from "react-hook-form"; +import { TextArea, TextAreaProps } from "@patternfly/react-core"; +import { getValidatedFromErrors } from "@app/utils/utils"; +import { + HookFormPFGroupController, + BaseHookFormPFGroupControllerProps, + extractGroupControllerProps, +} from "./HookFormPFGroupController"; + +export type HookFormPFTextAreaProps< + TFieldValues extends FieldValues, + TName extends Path, +> = TextAreaProps & BaseHookFormPFGroupControllerProps; + +export const HookFormPFTextArea = < + TFieldValues extends FieldValues = FieldValues, + TName extends Path = Path, +>( + props: HookFormPFTextAreaProps +) => { + const { extractedProps, remainingProps } = extractGroupControllerProps< + TFieldValues, + TName, + HookFormPFTextAreaProps + >(props); + const { fieldId, helperText, isRequired, errorsSuppressed } = extractedProps; + return ( + + {...extractedProps} + renderInput={({ + field: { onChange, onBlur, value, name, ref }, + fieldState: { isDirty, error, isTouched }, + }) => ( +