From 337db9449e2e9ebebd603d80dc99a2804ed5ca8a Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Fri, 29 Sep 2023 12:08:52 -0700 Subject: [PATCH] chore(ts): refactor types into separate modules (#1107) * chore(ts): refactor types into separate modules Signed-off-by: Thuan Vo * chore(ts): refactor types into separate modules Signed-off-by: Thuan Vo * fix(recordings): add padding to recording options label groups --------- Signed-off-by: Thuan Vo --- README.md | 13 + src/app/About/About.tsx | 4 +- src/app/About/AboutDescription.tsx | 2 +- src/app/Agent/AgentLiveProbes.tsx | 26 +- src/app/Agent/AgentProbeTemplates.tsx | 43 +- src/app/AppLayout/AppLayout.tsx | 36 +- src/app/AppLayout/AuthModal.tsx | 14 +- src/app/AppLayout/CredentialAuthForm.tsx | 4 +- .../NotificationCenter.tsx | 20 +- src/app/AppLayout/SslErrorModal.tsx | 8 +- .../Archives/AllArchivedRecordingsTable.tsx | 12 +- .../AllTargetsArchivedRecordingsTable.tsx | 13 +- src/app/Archives/ArchiveUploadModal.tsx | 10 +- src/app/Archives/Archives.tsx | 5 +- .../{ArchiveDirectoryUtil.tsx => utils.tsx} | 3 +- src/app/BreadcrumbPage/BreadcrumbPage.tsx | 21 +- src/app/BreadcrumbPage/types.ts | 19 + src/app/BreadcrumbPage/utils.ts | 20 + src/app/CreateRecording/CreateRecording.tsx | 45 +- .../CreateRecording/CustomRecordingForm.tsx | 455 ++-- .../CreateRecording/SnapshotRecordingForm.tsx | 9 +- src/app/CreateRecording/types.ts | 45 + src/app/CreateRecording/utils.ts | 22 + src/app/Dashboard/AddCard.tsx | 21 +- .../AutomatedAnalysisCard.tsx | 50 +- .../AutomatedAnalysisCardList.tsx | 10 +- .../AutomatedAnalysisConfigDrawer.tsx | 14 +- .../AutomatedAnalysisConfigForm.tsx | 95 +- .../AutomatedAnalysisFilters.tsx | 2 +- .../ClickableAutomatedAnalysisLabel.tsx | 10 +- .../Filters/AutomatedAnalysisNameFilter.tsx | 2 +- .../Filters/AutomatedAnalysisScoreFilter.tsx | 2 +- .../Filters/AutomatedAnalysisTopicFilter.tsx | 2 +- src/app/Dashboard/AutomatedAnalysis/types.ts | 21 + src/app/Dashboard/AutomatedAnalysis/utils.tsx | 59 + .../Charts/{ChartContext.tsx => context.tsx} | 0 .../Charts/jfr/JFRMetricsChartCard.tsx | 35 +- .../Charts/jfr/JFRMetricsChartController.tsx | 11 +- .../Charts/mbean/MBeanMetricsChartCard.tsx | 20 +- .../mbean/MBeanMetricsChartController.tsx | 13 +- src/app/Dashboard/Dashboard.tsx | 10 +- src/app/Dashboard/DashboardCard.tsx | 7 +- .../Dashboard/DashboardLayoutCreateModal.tsx | 10 +- .../DashboardLayoutSetAsTemplateModal.tsx | 12 +- src/app/Dashboard/DashboardLayoutToolbar.tsx | 12 +- src/app/Dashboard/DashboardSolo.tsx | 9 +- src/app/Dashboard/DraggableRef.tsx | 3 +- src/app/Dashboard/ErrorCard.tsx | 8 +- .../Dashboard/JvmDetails/JvmDetailsCard.tsx | 18 +- src/app/Dashboard/LayoutTemplateGroup.tsx | 14 +- src/app/Dashboard/LayoutTemplatePicker.tsx | 17 +- .../Dashboard/LayoutTemplateUploadModal.tsx | 28 +- src/app/Dashboard/ResizableRef.tsx | 13 +- src/app/Dashboard/const.ts | 18 + src/app/Dashboard/context.tsx | 18 + .../cryostat-dashboard-templates.tsx | 4 +- src/app/Dashboard/types.ts | 145 ++ .../{dashboard-utils.tsx => utils.tsx} | 222 +- src/app/DateTimePicker/DateTimePicker.tsx | 4 +- src/app/DateTimePicker/TimezonePicker.tsx | 6 +- src/app/DurationPicker/DurationPicker.tsx | 20 +- src/app/DynamicImport.tsx | 60 - src/app/ErrorView/ErrorView.tsx | 23 +- src/app/ErrorView/types.ts | 20 + src/app/Events/EventTemplates.tsx | 34 +- src/app/Events/EventTypes.tsx | 48 +- src/app/Events/Events.tsx | 5 +- src/app/Joyride/CryostatJoyride.tsx | 11 +- src/app/Joyride/JoyrideProvider.tsx | 21 +- src/app/Login/BasicAuthForm.tsx | 7 +- src/app/Login/ConnectionError.tsx | 2 +- src/app/Login/Login.tsx | 11 +- src/app/Login/NoopAuthForm.tsx | 5 +- .../Login/OpenShiftPlaceholderAuthForm.tsx | 11 +- src/app/Login/{FormProps.tsx => types.ts} | 3 +- src/app/Modal/CancelUploadModal.tsx | 14 +- src/app/Modal/DeleteWarningModal.tsx | 10 +- .../{DeleteWarningUtils.tsx => types.ts} | 5 - src/app/Modal/utils.ts | 19 + src/app/NotFound/NotFound.tsx | 4 +- src/app/NotFound/NotFoundCard.tsx | 9 +- src/app/QuickStarts/QuickStartDrawer.tsx | 8 +- src/app/QuickStarts/README.md | 1 - .../automated-rules-quickstart.tsx | 2 +- .../quickstarts/dashboard-quickstart.tsx | 2 +- .../quickstarts/generic-quickstart.tsx | 2 +- .../quickstarts/settings-quickstart.tsx | 2 +- .../quickstarts/start-a-recording.tsx | 2 +- .../topology/custom-target-quickstart.tsx | 2 +- .../topology/group-start-recordings.tsx | 2 +- src/app/RecordingMetadata/BulkEditLabels.tsx | 89 +- src/app/RecordingMetadata/ClickableLabel.tsx | 18 +- src/app/RecordingMetadata/LabelCell.tsx | 26 +- .../RecordingLabelFields.tsx | 112 +- src/app/RecordingMetadata/types.tsx | 20 + .../{RecordingLabel.tsx => utils.ts} | 31 +- src/app/Recordings/ActiveRecordingsTable.tsx | 68 +- .../Recordings/ArchivedRecordingsTable.tsx | 68 +- src/app/Recordings/Filters/DatetimeFilter.tsx | 2 +- src/app/Recordings/Filters/LabelFilter.tsx | 6 +- src/app/Recordings/Filters/NameFilter.tsx | 2 +- .../Filters/RecordingStateFilter.tsx | 2 +- src/app/Recordings/RecordingActions.tsx | 7 +- src/app/Recordings/RecordingFilters.tsx | 4 +- src/app/Recordings/RecordingLabelsPanel.tsx | 2 +- src/app/Recordings/Recordings.tsx | 2 +- src/app/Recordings/RecordingsTable.tsx | 5 +- src/app/Rules/CreateRule.tsx | 358 +-- src/app/Rules/RuleDeleteWarningModal.tsx | 4 +- src/app/Rules/Rules.tsx | 55 +- src/app/Rules/RulesUploadModal.tsx | 11 +- src/app/Rules/types.ts | 61 + src/app/Rules/utils.ts | 30 + .../SecurityPanel/CertificateUploadModal.tsx | 8 +- .../Credentials/CreateCredentialModal.tsx | 19 +- .../Credentials/CredentialTestTable.tsx | 14 +- .../Credentials/MatchedTargetsTable.tsx | 10 +- .../Credentials/StoreCredentials.tsx | 13 +- src/app/SecurityPanel/Credentials/types.ts | 21 + src/app/SecurityPanel/Credentials/utils.tsx | 7 +- src/app/SecurityPanel/ImportCertificate.tsx | 6 +- src/app/SecurityPanel/SecurityPanel.tsx | 6 - src/app/SecurityPanel/types.ts | 21 + src/app/Settings/{ => Config}/AutoRefresh.tsx | 2 +- .../AutomatedAnalysis.tsx} | 4 +- .../ChartCards.tsx} | 4 +- .../{ => Config}/CredentialsStorage.tsx | 2 +- .../Settings/{ => Config}/DatetimeControl.tsx | 4 +- .../{ => Config}/DeletionDialogControl.tsx | 5 +- .../Settings/{ => Config}/FeatureLevels.tsx | 6 +- src/app/Settings/{ => Config}/Language.tsx | 4 +- .../{ => Config}/NotificationControl.tsx | 8 +- src/app/Settings/{ => Config}/Theme.tsx | 4 +- .../{ => Config}/WebSocketDebounce.tsx | 2 +- src/app/Settings/Settings.tsx | 76 +- .../Settings/{SettingsUtils.ts => types.ts} | 32 +- src/app/Settings/utils.ts | 47 + .../Components}/EmptyText.tsx | 0 .../Shared/{ => Components}/ErrorBoundary.tsx | 4 +- .../FeatureFlag.tsx | 12 +- .../Shared/{ => Components}/FileUploads.tsx | 2 +- .../{ => Components}/LinearDotSpinner.tsx | 0 .../Components}/LoadingView.tsx | 4 +- .../MatchExpression/MatchExpressionHint.tsx | 3 +- .../MatchExpressionVisualizer.tsx | 19 +- .../MatchExpression/utils.tsx | 3 +- .../Components}/QuickSearchIcon.tsx | 0 .../SelectTemplateSelectorForm.tsx | 14 +- .../types.ts} | 2 +- .../Configurations/DashboardConfigSlice.tsx | 7 +- .../Redux/Filters/TopologyFilterSlice.tsx | 2 +- .../Redux/Middlewares/PersistMiddleware.tsx | 2 +- src/app/Shared/Redux/utils.ts | 4 +- src/app/Shared/Services/Api.service.tsx | 517 +---- .../Services/AuthCredentials.service.tsx | 10 +- src/app/Shared/Services/Login.service.tsx | 34 +- .../Services/MatchExpression.service.tsx | 33 + .../Services/NotificationChannel.service.tsx | 314 +-- .../Services/Notifications.service.tsx} | 26 +- src/app/Shared/Services/Report.service.tsx | 79 +- src/app/Shared/Services/Services.tsx | 11 +- src/app/Shared/Services/Settings.service.tsx | 46 +- src/app/Shared/Services/Target.service.tsx | 45 +- src/app/Shared/Services/Targets.service.tsx | 20 +- src/app/Shared/Services/api.types.ts | 589 +++++ src/app/Shared/Services/api.utils.ts | 414 ++++ src/app/Shared/Services/service.types.ts | 68 + src/app/Shared/Services/service.utils.ts | 56 + .../SerializedTarget.tsx | 9 +- src/app/TargetView/TargetContextSelector.tsx | 31 +- .../{Shared => TargetView}/TargetSelect.tsx | 31 +- src/app/TargetView/TargetView.tsx | 18 +- src/app/Topology/Actions/CreateTarget.tsx | 16 +- src/app/Topology/Actions/NodeActions.tsx | 356 +-- src/app/Topology/Actions/QuickSearchPanel.tsx | 10 +- src/app/Topology/Actions/WarningResolver.tsx | 6 +- .../Actions/quicksearches/custom-target.tsx | 4 +- .../Actions/quicksearches/dev-sample.tsx | 4 +- src/app/Topology/Actions/types.ts | 90 + src/app/Topology/Actions/utils.ts | 44 - src/app/Topology/Actions/utils.tsx | 367 +++ .../{Shared => }/Entity/EntityAnnotations.tsx | 2 +- .../{Shared => }/Entity/EntityDetails.tsx | 38 +- .../{Shared => }/Entity/EntityKeyValues.tsx | 20 +- .../{Shared => }/Entity/EntityTitle.tsx | 0 src/app/Topology/Entity/ResourceDetails.tsx | 83 + src/app/Topology/Entity/types.ts | 55 + .../Topology/{Shared => }/Entity/utils.tsx | 118 +- src/app/Topology/GraphView/CustomGroup.tsx | 11 +- src/app/Topology/GraphView/CustomNode.tsx | 20 +- src/app/Topology/GraphView/NodeDecorator.tsx | 6 +- .../Topology/GraphView/TopologyControlBar.tsx | 2 +- .../Topology/GraphView/TopologyGraphView.tsx | 20 +- src/app/Topology/GraphView/const.ts | 37 + .../GraphView/{UtilsFactory.tsx => utils.tsx} | 37 +- .../Topology/ListView/TopologyListView.tsx | 15 +- .../ListView/{UtilsFactory.tsx => utils.tsx} | 23 +- .../Shared/{ => Components}/CollapseIcon.tsx | 0 .../Shared/Components}/PropertyPath.tsx | 0 .../Shared/{ => Components}/Shortcuts.tsx | 0 .../{ => Components}/TopologyEmptyState.tsx | 4 +- .../TopologyExceedLimitState.tsx | 0 src/app/Topology/Shared/types.ts | 29 + src/app/Topology/Shared/utils.tsx | 154 +- .../Toolbar/FindByMatchExpression.tsx | 4 +- .../Topology/Toolbar/QuickSearchButton.tsx | 2 +- .../Topology/Toolbar/TopologyFilterChips.tsx | 2 +- src/app/Topology/Toolbar/TopologyFilters.tsx | 5 +- src/app/Topology/Toolbar/TopologyToolbar.tsx | 2 +- src/app/Topology/Topology.tsx | 14 +- src/app/Topology/typings.ts | 74 - src/app/index.tsx | 2 +- src/app/routes.tsx | 14 +- src/app/utils/LocalStorage.ts | 8 +- src/app/utils/fakeData.ts | 44 +- src/app/utils/{ => hooks}/useDayjs.ts | 0 src/app/utils/{ => hooks}/useDocumentTitle.ts | 0 src/app/utils/{ => hooks}/useFeatureLevel.ts | 2 +- src/app/utils/{ => hooks}/useLogin.ts | 2 +- src/app/utils/hooks/useMatchExpressionSvc.ts | 20 + src/app/utils/hooks/useMatchedTargetsSvc.ts | 19 + .../utils/hooks/useMatchedTargetsSvcSource.ts | 44 + src/app/utils/{ => hooks}/useSetState.ts | 0 src/app/utils/{ => hooks}/useSort.ts | 0 src/app/utils/{ => hooks}/useSubscriptions.ts | 0 src/app/utils/{ => hooks}/useTheme.ts | 2 +- src/app/utils/utils.ts | 38 +- src/app/utils/withThemedIcon.tsx | 4 +- src/itest/RecordingWorkflow.test.ts | 2 +- src/test/About/About.test.tsx | 2 +- src/test/Agent/AgentLiveProbes.test.tsx | 10 +- src/test/Agent/AgentProbeTemplates.test.tsx | 8 +- .../AllArchivedRecordingsTable.test.tsx | 5 +- ...AllTargetsArchivedRecordingsTable.test.tsx | 7 +- src/test/Archives/Archives.test.tsx | 2 +- src/test/Common.tsx | 3 +- .../CustomRecordingForm.test.tsx | 28 +- .../SnapshotRecordingForm.test.tsx | 4 +- .../AutomatedAnalysisCard.test.tsx | 29 +- .../AutomatedAnalysisCardList.test.tsx | 13 +- .../AutomatedAnalysisConfigDrawer.test.tsx | 3 +- .../AutomatedAnalysisConfigForm.test.tsx | 3 +- .../ClickableAutomatedAnalysisLabel.test.tsx | 32 +- .../AutomatedAnalysisNameFilter.test.tsx | 10 +- .../AutomatedAnalysisTopicFilter.test.tsx | 10 +- .../Charts/jfr/JFRMetricsChartCard.test.tsx | 26 +- .../mbean/MBeanMetricsChartCard.test.tsx | 8 +- src/test/Dashboard/Dashboard.test.tsx | 42 +- .../Dashboard/DashboardLayoutToolbar.test.tsx | 3 +- .../__snapshots__/Dashboard.test.tsx.snap | 1987 ++++++++++++++++- src/test/Events/EventTemplates.test.tsx | 9 +- src/test/Events/EventTypes.test.tsx | 7 +- .../RecordingMetadata/BulkEditLabels.test.tsx | 10 +- .../RecordingMetadata/ClickableLabel.test.tsx | 2 +- src/test/RecordingMetadata/LabelCell.test.tsx | 8 +- .../RecordingLabelFields.test.tsx | 5 +- .../Recordings/ActiveRecordingsTable.test.tsx | 7 +- .../ArchivedRecordingsTable.test.tsx | 5 +- .../Filters/DurationFilter.test.tsx | 2 +- .../Recordings/Filters/LabelFilter.test.tsx | 2 +- .../Recordings/Filters/NameFilter.test.tsx | 2 +- .../Filters/RecordingStateFilter.test.tsx | 2 +- src/test/Recordings/RecordingFilters.test.tsx | 7 +- .../Recordings/RecordingLabelsPanel.test.tsx | 2 +- src/test/Recordings/Recordings.test.tsx | 4 +- src/test/Rules/CreateRule.test.tsx | 9 +- src/test/Rules/Rules.test.tsx | 35 +- .../Credentials/StoreCredentials.test.tsx | 6 +- src/test/Settings/AutoRefresh.test.tsx | 2 +- .../Settings/AutomatedAnalysisConfig.test.tsx | 8 +- src/test/Settings/CredentialsStorage.test.tsx | 2 +- src/test/Settings/DatetimeControl.test.tsx | 2 +- .../Settings/DeletionDialogControl.test.tsx | 4 +- src/test/Settings/FeatureLevels.test.tsx | 4 +- src/test/Settings/Language.test.tsx | 2 +- .../Settings/NotificationControl.test.tsx | 4 +- src/test/Settings/Settings.test.tsx | 42 +- src/test/Settings/Theme.test.tsx | 5 +- src/test/Settings/WebSocketDebounce.test.tsx | 2 +- .../Components}/LoadingView.test.tsx | 2 +- .../__snapshots__/LoadingView.test.tsx.snap | 0 .../Shared/Services/Login.service.test.tsx | 5 +- .../TargetSelect.test.tsx | 4 +- test-setup.js | 1 + 284 files changed, 6458 insertions(+), 3761 deletions(-) rename src/app/{Notifications => AppLayout}/NotificationCenter.tsx (95%) rename src/app/Archives/{ArchiveDirectoryUtil.tsx => utils.tsx} (89%) create mode 100644 src/app/BreadcrumbPage/types.ts create mode 100644 src/app/BreadcrumbPage/utils.ts create mode 100644 src/app/CreateRecording/types.ts create mode 100644 src/app/CreateRecording/utils.ts create mode 100644 src/app/Dashboard/AutomatedAnalysis/types.ts create mode 100644 src/app/Dashboard/AutomatedAnalysis/utils.tsx rename src/app/Dashboard/Charts/{ChartContext.tsx => context.tsx} (100%) create mode 100644 src/app/Dashboard/const.ts create mode 100644 src/app/Dashboard/context.tsx create mode 100644 src/app/Dashboard/types.ts rename src/app/Dashboard/{dashboard-utils.tsx => utils.tsx} (61%) delete mode 100644 src/app/DynamicImport.tsx create mode 100644 src/app/ErrorView/types.ts rename src/app/Login/{FormProps.tsx => types.ts} (87%) rename src/app/Modal/{DeleteWarningUtils.tsx => types.ts} (96%) create mode 100644 src/app/Modal/utils.ts create mode 100644 src/app/RecordingMetadata/types.tsx rename src/app/RecordingMetadata/{RecordingLabel.tsx => utils.ts} (68%) create mode 100644 src/app/Rules/types.ts create mode 100644 src/app/Rules/utils.ts create mode 100644 src/app/SecurityPanel/Credentials/types.ts create mode 100644 src/app/SecurityPanel/types.ts rename src/app/Settings/{ => Config}/AutoRefresh.tsx (98%) rename src/app/Settings/{AutomatedAnalysisConfig.tsx => Config/AutomatedAnalysis.tsx} (90%) rename src/app/Settings/{ChartCardsConfig.tsx => Config/ChartCards.tsx} (95%) rename src/app/Settings/{ => Config}/CredentialsStorage.tsx (98%) rename src/app/Settings/{ => Config}/DatetimeControl.tsx (97%) rename src/app/Settings/{ => Config}/DeletionDialogControl.tsx (95%) rename src/app/Settings/{ => Config}/FeatureLevels.tsx (93%) rename src/app/Settings/{ => Config}/Language.tsx (94%) rename src/app/Settings/{ => Config}/NotificationControl.tsx (95%) rename src/app/Settings/{ => Config}/Theme.tsx (94%) rename src/app/Settings/{ => Config}/WebSocketDebounce.tsx (98%) rename src/app/Settings/{SettingsUtils.ts => types.ts} (69%) create mode 100644 src/app/Settings/utils.ts rename src/app/{Topology/Shared => Shared/Components}/EmptyText.tsx (100%) rename src/app/Shared/{ => Components}/ErrorBoundary.tsx (96%) rename src/app/Shared/{FeatureFlag => Components}/FeatureFlag.tsx (86%) rename src/app/Shared/{ => Components}/FileUploads.tsx (99%) rename src/app/Shared/{ => Components}/LinearDotSpinner.tsx (100%) rename src/app/{LoadingView => Shared/Components}/LoadingView.tsx (90%) rename src/app/Shared/{ => Components}/MatchExpression/MatchExpressionHint.tsx (97%) rename src/app/Shared/{ => Components}/MatchExpression/MatchExpressionVisualizer.tsx (94%) rename src/app/Shared/{ => Components}/MatchExpression/utils.tsx (96%) rename src/app/{Topology/Shared => Shared/Components}/QuickSearchIcon.tsx (100%) rename src/app/Shared/{ => Components}/SelectTemplateSelectorForm.tsx (90%) rename src/app/Shared/{ProgressIndicator.tsx => Components/types.ts} (96%) create mode 100644 src/app/Shared/Services/MatchExpression.service.tsx rename src/app/{Notifications/Notifications.tsx => Shared/Services/Notifications.service.tsx} (90%) create mode 100644 src/app/Shared/Services/api.types.ts create mode 100644 src/app/Shared/Services/api.utils.ts create mode 100644 src/app/Shared/Services/service.types.ts create mode 100644 src/app/Shared/Services/service.utils.ts rename src/app/{Shared => TargetView}/SerializedTarget.tsx (83%) rename src/app/{Shared => TargetView}/TargetSelect.tsx (84%) create mode 100644 src/app/Topology/Actions/types.ts delete mode 100644 src/app/Topology/Actions/utils.ts create mode 100644 src/app/Topology/Actions/utils.tsx rename src/app/Topology/{Shared => }/Entity/EntityAnnotations.tsx (96%) rename src/app/Topology/{Shared => }/Entity/EntityDetails.tsx (95%) rename src/app/Topology/{Shared => }/Entity/EntityKeyValues.tsx (76%) rename src/app/Topology/{Shared => }/Entity/EntityTitle.tsx (100%) create mode 100644 src/app/Topology/Entity/ResourceDetails.tsx create mode 100644 src/app/Topology/Entity/types.ts rename src/app/Topology/{Shared => }/Entity/utils.tsx (80%) create mode 100644 src/app/Topology/GraphView/const.ts rename src/app/Topology/GraphView/{UtilsFactory.tsx => utils.tsx} (92%) rename src/app/Topology/ListView/{UtilsFactory.tsx => utils.tsx} (94%) rename src/app/Topology/Shared/{ => Components}/CollapseIcon.tsx (100%) rename src/app/{Shared => Topology/Shared/Components}/PropertyPath.tsx (100%) rename src/app/Topology/Shared/{ => Components}/Shortcuts.tsx (100%) rename src/app/Topology/Shared/{ => Components}/TopologyEmptyState.tsx (93%) rename src/app/Topology/Shared/{ => Components}/TopologyExceedLimitState.tsx (100%) create mode 100644 src/app/Topology/Shared/types.ts delete mode 100644 src/app/Topology/typings.ts rename src/app/utils/{ => hooks}/useDayjs.ts (100%) rename src/app/utils/{ => hooks}/useDocumentTitle.ts (100%) rename src/app/utils/{ => hooks}/useFeatureLevel.ts (94%) rename src/app/utils/{ => hooks}/useLogin.ts (94%) create mode 100644 src/app/utils/hooks/useMatchExpressionSvc.ts create mode 100644 src/app/utils/hooks/useMatchedTargetsSvc.ts create mode 100644 src/app/utils/hooks/useMatchedTargetsSvcSource.ts rename src/app/utils/{ => hooks}/useSetState.ts (100%) rename src/app/utils/{ => hooks}/useSort.ts (100%) rename src/app/utils/{ => hooks}/useSubscriptions.ts (100%) rename src/app/utils/{ => hooks}/useTheme.ts (96%) rename src/test/{LoadingView => Shared/Components}/LoadingView.test.tsx (95%) rename src/test/{LoadingView => Shared/Components}/__snapshots__/LoadingView.test.tsx.snap (100%) rename src/test/{Shared => TargetView}/TargetSelect.test.tsx (97%) diff --git a/README.md b/README.md index de4cf16f3..a4dc75cf0 100644 --- a/README.md +++ b/README.md @@ -135,3 +135,16 @@ The extraction tool is [`i18next-parser`](https://www.npmjs.com/package/i18next- To workaround this, specify static values in `i18n.ts` file under any top-level directory below `src/app`. For example, `src/app/Settings/i18n.ts`. Refer to [LOCALIZATION.md](LOCALIZATION.md) for more details about our localization framework. + +## CONTRIBUTING + +### Code consistency + +- `[*].types.ts(x)`: Define type definitions, including types and enums. +- `[*].utils.ts(x)`: Define utility functions. These might contain constants (usually tightly coupled with the utility functions). +- `[*].const.ts`: Define constants. These constants are purely for UI rendering. +- `[*].context.tsx`: Define React contexts. These can be defined in util files. + +### Code contribution + +See [CONTRIBUTING.md](https://github.com/cryostatio/cryostat/blob/main/CONTRIBUTING.md). diff --git a/src/app/About/About.tsx b/src/app/About/About.tsx index 13ab3abc5..03aca8f7a 100644 --- a/src/app/About/About.tsx +++ b/src/app/About/About.tsx @@ -18,8 +18,8 @@ import cryostatLogo from '@app/assets/cryostat_logo_hori_rgb_default.svg'; import cryostatLogoDark from '@app/assets/cryostat_logo_hori_rgb_reverse.svg'; import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; import build from '@app/build.json'; -import { ThemeSetting } from '@app/Settings/SettingsUtils'; -import { useTheme } from '@app/utils/useTheme'; +import { ThemeSetting } from '@app/Settings/types'; +import { useTheme } from '@app/utils/hooks/useTheme'; import { Brand, Card, CardBody, CardFooter, CardHeader } from '@patternfly/react-core'; import React from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/app/About/AboutDescription.tsx b/src/app/About/AboutDescription.tsx index 5cf46e9c5..34c5d3804 100644 --- a/src/app/About/AboutDescription.tsx +++ b/src/app/About/AboutDescription.tsx @@ -15,7 +15,7 @@ */ import build from '@app/build.json'; -import { NotificationsContext } from '@app/Notifications/Notifications'; +import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; import { ServiceContext } from '@app/Shared/Services/Services'; import { Text, TextContent, TextList, TextListItem, TextVariants } from '@patternfly/react-core'; import * as React from 'react'; diff --git a/src/app/Agent/AgentLiveProbes.tsx b/src/app/Agent/AgentLiveProbes.tsx index d40c63ab9..587097897 100644 --- a/src/app/Agent/AgentLiveProbes.tsx +++ b/src/app/Agent/AgentLiveProbes.tsx @@ -13,15 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; -import { LoadingView } from '@app/LoadingView/LoadingView'; +import { ErrorView } from '@app/ErrorView/ErrorView'; +import { authFailMessage, isAuthFail } from '@app/ErrorView/types'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; -import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; -import { EventProbe } from '@app/Shared/Services/Api.service'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { DeleteOrDisableWarningType } from '@app/Modal/types'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { LoadingProps } from '@app/Shared/Components/types'; +import { EventProbe, NotificationCategory } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { sortResources, TableColumn } from '@app/utils/utils'; import { Button, @@ -89,10 +89,10 @@ export const AgentLiveProbes: React.FC = (_) => { const context = React.useContext(ServiceContext); const addSubscription = useSubscriptions(); - const [probes, setProbes] = React.useState([] as EventProbe[]); - const [filteredProbes, setFilteredProbes] = React.useState([] as EventProbe[]); + const [probes, setProbes] = React.useState([]); + const [filteredProbes, setFilteredProbes] = React.useState([]); const [filterText, setFilterText] = React.useState(''); - const [sortBy, setSortBy] = React.useState({} as ISortBy); + const [sortBy, setSortBy] = React.useState({}); const [isLoading, setIsLoading] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); const [warningModalOpen, setWarningModalOpen] = React.useState(false); @@ -210,7 +210,7 @@ export const AgentLiveProbes: React.FC = (_) => { context.target.target(), context.notificationChannel.messages(NotificationCategory.ProbeTemplateApplied), ]).subscribe(([currentTarget, e]) => { - if (currentTarget.connectUrl != e.message.targetId) { + if (currentTarget?.connectUrl != e.message.targetId) { return; } setProbes((old) => { @@ -283,13 +283,13 @@ export const AgentLiveProbes: React.FC = (_) => { [filteredProbes], ); - const actionLoadingProps = React.useMemo>( + const actionLoadingProps = React.useMemo>( () => ({ REMOVE: { spinnerAriaValueText: 'Removing', spinnerAriaLabel: 'removing-all-probes', isLoading: actionLoadings['REMOVE'], - } as LoadingPropsType, + } as LoadingProps, }), [actionLoadings], ); diff --git a/src/app/Agent/AgentProbeTemplates.tsx b/src/app/Agent/AgentProbeTemplates.tsx index 33916c670..3c9921b1b 100644 --- a/src/app/Agent/AgentProbeTemplates.tsx +++ b/src/app/Agent/AgentProbeTemplates.tsx @@ -14,15 +14,14 @@ * limitations under the License. */ import { ErrorView } from '@app/ErrorView/ErrorView'; -import { LoadingView } from '@app/LoadingView/LoadingView'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; -import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; -import { FUpload, MultiFileUpload, UploadCallbacks } from '@app/Shared/FileUploads'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; -import { ProbeTemplate } from '@app/Shared/Services/Api.service'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { DeleteOrDisableWarningType } from '@app/Modal/types'; +import { FUpload, MultiFileUpload, UploadCallbacks } from '@app/Shared/Components/FileUploads'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { LoadingProps } from '@app/Shared/Components/types'; +import { ProbeTemplate, NotificationCategory } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { TableColumn, portalRoot, sortResources } from '@app/utils/utils'; import { ActionGroup, @@ -81,18 +80,18 @@ export interface AgentProbeTemplatesProps { agentDetected: boolean; } -export const AgentProbeTemplates: React.FC = (props) => { +export const AgentProbeTemplates: React.FC = ({ agentDetected }) => { const context = React.useContext(ServiceContext); const addSubscription = useSubscriptions(); - const [templates, setTemplates] = React.useState([] as ProbeTemplate[]); - const [filteredTemplates, setFilteredTemplates] = React.useState([] as ProbeTemplate[]); + const [templates, setTemplates] = React.useState([]); + const [filteredTemplates, setFilteredTemplates] = React.useState([]); const [filterText, setFilterText] = React.useState(''); const [uploadModalOpen, setUploadModalOpen] = React.useState(false); - const [sortBy, setSortBy] = React.useState({} as ISortBy); + const [sortBy, setSortBy] = React.useState({}); const [isLoading, setIsLoading] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); - const [templateToDelete, setTemplateToDelete] = React.useState(undefined as ProbeTemplate | undefined); + const [templateToDelete, setTemplateToDelete] = React.useState(undefined); const [warningModalOpen, setWarningModalOpen] = React.useState(false); const getSortParams = React.useCallback( @@ -110,7 +109,7 @@ export const AgentProbeTemplates: React.FC = (props) = ); const handleTemplates = React.useCallback( - (templates) => { + (templates: ProbeTemplate[]) => { setTemplates(templates); setIsLoading(false); setErrorMessage(''); @@ -267,13 +266,13 @@ export const AgentProbeTemplates: React.FC = (props) = ); }), - [filteredTemplates, props.agentDetected, handleInsertAction, handleDeleteAction], + [filteredTemplates, agentDetected, handleInsertAction, handleDeleteAction], ); if (errorMessage != '') { @@ -357,7 +356,7 @@ export interface AgentProbeTemplateUploadModalProps { onClose: () => void; } -export const AgentProbeTemplateUploadModal: React.FC = ({ onClose, ...props }) => { +export const AgentProbeTemplateUploadModal: React.FC = ({ onClose, isOpen }) => { const addSubscription = useSubscriptions(); const context = React.useContext(ServiceContext); const submitRef = React.useRef(null); // Use ref to refer to submit trigger div @@ -439,14 +438,14 @@ export const AgentProbeTemplateUploadModal: React.FC void; } -export const AgentTemplateAction: React.FC = ({ onInsert, onDelete, ...props }) => { +export const AgentTemplateAction: React.FC = ({ onInsert, onDelete, template }) => { const [isOpen, setIsOpen] = React.useState(false); const actionItems = React.useMemo(() => { @@ -504,16 +503,16 @@ export const AgentTemplateAction: React.FC = ({ onInse { key: 'insert-template', title: 'Insert Probes...', - onClick: () => onInsert && onInsert(props.template), + onClick: () => onInsert && onInsert(template), isDisabled: !onInsert, }, { key: 'delete-template', title: 'Delete', - onClick: () => onDelete(props.template), + onClick: () => onDelete(template), }, ]; - }, [onInsert, onDelete, props.template]); + }, [onInsert, onDelete, template]); return ( = ({ children }) => { +export const AppLayout: React.FC = ({ children }) => { const serviceContext = React.useContext(ServiceContext); const notificationsContext = React.useContext(NotificationsContext); const addSubscription = useSubscriptions(); @@ -608,5 +610,3 @@ const AppLayout: React.FC = ({ children }) => { ); }; - -export { AppLayout }; diff --git a/src/app/AppLayout/AuthModal.tsx b/src/app/AppLayout/AuthModal.tsx index 413ee83e7..fdeb2e9e6 100644 --- a/src/app/AppLayout/AuthModal.tsx +++ b/src/app/AppLayout/AuthModal.tsx @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { NullableTarget, Target } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NO_TARGET, Target } from '@app/Shared/Services/Target.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { Modal, ModalVariant, Text } from '@patternfly/react-core'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -26,10 +26,10 @@ export interface AuthModalProps { visible: boolean; onDismiss: () => void; onSave: () => void; - targetObs: Observable; + targetObs: Observable; } -export const AuthModal: React.FC = ({ onDismiss, onSave: onPropsSave, targetObs, ...props }) => { +export const AuthModal: React.FC = ({ onDismiss, onSave: onPropsSave, targetObs, visible }) => { const context = React.useContext(ServiceContext); const [loading, setLoading] = React.useState(false); const addSubscription = useSubscriptions(); @@ -40,9 +40,9 @@ export const AuthModal: React.FC = ({ onDismiss, onSave: onProps addSubscription( targetObs .pipe( - filter((target) => target !== NO_TARGET), + filter((target) => !!target), first(), - map((target) => target.connectUrl), + map((target: Target) => target.connectUrl), mergeMap((connectUrl) => context.authCredentials.setCredential(connectUrl, username, password)), ) .subscribe((ok) => { @@ -58,7 +58,7 @@ export const AuthModal: React.FC = ({ onDismiss, onSave: onProps return ( = ({ spinnerAriaValueText: 'Saving', spinnerAriaLabel: 'saving-credentials', isLoading: loading, - }) as LoadingPropsType, + }) as LoadingProps, [loading], ); diff --git a/src/app/Notifications/NotificationCenter.tsx b/src/app/AppLayout/NotificationCenter.tsx similarity index 95% rename from src/app/Notifications/NotificationCenter.tsx rename to src/app/AppLayout/NotificationCenter.tsx index 936a9dd54..06e8bf9b2 100644 --- a/src/app/Notifications/NotificationCenter.tsx +++ b/src/app/AppLayout/NotificationCenter.tsx @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import useDayjs from '@app/utils/useDayjs'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { Notification } from '@app/Shared/Services/api.types'; +import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; +import useDayjs from '@app/utils/hooks/useDayjs'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { Dropdown, DropdownItem, @@ -32,19 +34,13 @@ import { Text, TextVariants, } from '@patternfly/react-core'; - import * as React from 'react'; import { combineLatest } from 'rxjs'; -import { Notification, NotificationsContext } from './Notifications'; const countUnreadNotifications = (notifications: Notification[]) => { return notifications.filter((n) => !n.read).length; }; -export interface NotificationCenterProps { - onClose: () => void; -} - export interface NotificationDrawerCategory { title: string; isExpanded: boolean; @@ -52,7 +48,11 @@ export interface NotificationDrawerCategory { unreadCount: number; } -export const NotificationCenter: React.FC = (props) => { +export interface NotificationCenterProps { + onClose: () => void; +} + +export const NotificationCenter: React.FC = ({ onClose }) => { const [dayjs, datetimeContext] = useDayjs(); const context = React.useContext(NotificationsContext); const addSubscription = useSubscriptions(); @@ -141,7 +141,7 @@ export const NotificationCenter: React.FC = (props) => return ( <> - + void; } -export const SslErrorModal: React.FC = (props) => { +export const SslErrorModal: React.FC = ({ visible, onDismiss }) => { const routerHistory = useHistory(); const handleClick = () => { routerHistory.push('/security'); - props.onDismiss(); + onDismiss(); }; return ( diff --git a/src/app/Archives/AllArchivedRecordingsTable.tsx b/src/app/Archives/AllArchivedRecordingsTable.tsx index 54709e319..5d405a627 100644 --- a/src/app/Archives/AllArchivedRecordingsTable.tsx +++ b/src/app/Archives/AllArchivedRecordingsTable.tsx @@ -13,14 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LoadingView } from '@app/LoadingView/LoadingView'; import { ArchivedRecordingsTable } from '@app/Recordings/ArchivedRecordingsTable'; -import { ArchivedRecording, RecordingDirectory } from '@app/Shared/Services/Api.service'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { ArchivedRecording, RecordingDirectory, Target, NotificationCategory } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; -import { useSort } from '@app/utils/useSort'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSort } from '@app/utils/hooks/useSort'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { TableColumn, portalRoot, sortResources } from '@app/utils/utils'; import { Toolbar, @@ -50,7 +48,7 @@ import { } from '@patternfly/react-table'; import * as React from 'react'; import { Observable, of } from 'rxjs'; -import { getTargetFromDirectory, includesDirectory, indexOfDirectory } from './ArchiveDirectoryUtil'; +import { getTargetFromDirectory, includesDirectory, indexOfDirectory } from './utils'; const tableColumns: TableColumn[] = [ { diff --git a/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx b/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx index d1db8d47d..df4d1b8bf 100644 --- a/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx +++ b/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx @@ -13,14 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LoadingView } from '@app/LoadingView/LoadingView'; import { ArchivedRecordingsTable } from '@app/Recordings/ArchivedRecordingsTable'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { Target, TargetDiscoveryEvent, NotificationCategory } from '@app/Shared/Services/api.types'; +import { isEqualTarget, indexOfTarget, includesTarget } from '@app/Shared/Services/api.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { includesTarget, indexOfTarget, isEqualTarget, Target } from '@app/Shared/Services/Target.service'; -import { TargetDiscoveryEvent } from '@app/Shared/Services/Targets.service'; -import { useSort } from '@app/utils/useSort'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSort } from '@app/utils/hooks/useSort'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { hashCode, sortResources, TableColumn } from '@app/utils/utils'; import { Toolbar, @@ -383,7 +382,7 @@ export const AllTargetsArchivedRecordingsTable: React.FC - No Targets + No Archived Recordings diff --git a/src/app/Archives/ArchiveUploadModal.tsx b/src/app/Archives/ArchiveUploadModal.tsx index 3be41c1f7..a9b4abf85 100644 --- a/src/app/Archives/ArchiveUploadModal.tsx +++ b/src/app/Archives/ArchiveUploadModal.tsx @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; import { RecordingLabelFields } from '@app/RecordingMetadata/RecordingLabelFields'; -import { FUpload, MultiFileUpload, UploadCallbacks } from '@app/Shared/FileUploads'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; +import { RecordingLabel } from '@app/RecordingMetadata/types'; +import { FUpload, MultiFileUpload, UploadCallbacks } from '@app/Shared/Components/FileUploads'; +import { LoadingProps } from '@app/Shared/Components/types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { portalRoot } from '@app/utils/utils'; import { ActionGroup, @@ -139,7 +139,7 @@ export const ArchiveUploadModal: React.FC = ({ onClose, spinnerAriaValueText: 'Submitting', spinnerAriaLabel: 'submitting-uploaded-recording', isLoading: uploading, - }) as LoadingPropsType, + }) as LoadingProps, [uploading], ); diff --git a/src/app/Archives/Archives.tsx b/src/app/Archives/Archives.tsx index 73a798cb8..58b169c1c 100644 --- a/src/app/Archives/Archives.tsx +++ b/src/app/Archives/Archives.tsx @@ -15,10 +15,9 @@ */ import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; import { ArchivedRecordingsTable } from '@app/Recordings/ArchivedRecordingsTable'; -import { UPLOADS_SUBDIRECTORY } from '@app/Shared/Services/Api.service'; +import { Target, UPLOADS_SUBDIRECTORY } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { getActiveTab, switchTab } from '@app/utils/utils'; import { Card, CardBody, EmptyState, EmptyStateIcon, Tab, Tabs, TabTitleText, Title } from '@patternfly/react-core'; import { SearchIcon } from '@patternfly/react-icons'; diff --git a/src/app/Archives/ArchiveDirectoryUtil.tsx b/src/app/Archives/utils.tsx similarity index 89% rename from src/app/Archives/ArchiveDirectoryUtil.tsx rename to src/app/Archives/utils.tsx index aba0378b5..377347f90 100644 --- a/src/app/Archives/ArchiveDirectoryUtil.tsx +++ b/src/app/Archives/utils.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ -import { RecordingDirectory } from '@app/Shared/Services/Api.service'; -import { Target } from '@app/Shared/Services/Target.service'; +import { RecordingDirectory, Target } from '@app/Shared/Services/api.types'; export const includesDirectory = (arr: RecordingDirectory[], dir: RecordingDirectory): boolean => { return arr.some((t) => t.connectUrl === dir.connectUrl); diff --git a/src/app/BreadcrumbPage/BreadcrumbPage.tsx b/src/app/BreadcrumbPage/BreadcrumbPage.tsx index 01d41cf8c..70f18a8db 100644 --- a/src/app/BreadcrumbPage/BreadcrumbPage.tsx +++ b/src/app/BreadcrumbPage/BreadcrumbPage.tsx @@ -24,6 +24,8 @@ import { } from '@patternfly/react-core'; import * as React from 'react'; import { Link } from 'react-router-dom'; +import { BreadcrumbTrail } from './types'; +import { isItemFilled } from './utils'; interface BreadcrumbPageProps { pageTitle: string; @@ -31,25 +33,20 @@ interface BreadcrumbPageProps { children?: React.ReactNode; } -export interface BreadcrumbTrail { - title: string; - path: string; -} - -export const BreadcrumbPage: React.FC = (props) => { +export const BreadcrumbPage: React.FC = ({ pageTitle, breadcrumbs, children }) => { return ( - {(props.breadcrumbs || []).map(({ title, path }) => ( + {(breadcrumbs || []).map(({ title, path }) => ( {title} ))} - {props.pageTitle} + {pageTitle} - {React.Children.map(props.children, (child) => ( + {React.Children.map(children, (child) => ( {child} ))} @@ -57,9 +54,3 @@ export const BreadcrumbPage: React.FC = (props) => { ); }; - -export const isItemFilled = (item: React.ReactNode): boolean => { - if (!item) return false; - const toCheck = item['props'] ? item['props'] : item; - return toCheck['isFilled'] || toCheck['isFullHeight'] || toCheck['data-full-height']; -}; diff --git a/src/app/BreadcrumbPage/types.ts b/src/app/BreadcrumbPage/types.ts new file mode 100644 index 000000000..a48df3c9b --- /dev/null +++ b/src/app/BreadcrumbPage/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export interface BreadcrumbTrail { + title: string; + path: string; +} diff --git a/src/app/BreadcrumbPage/utils.ts b/src/app/BreadcrumbPage/utils.ts new file mode 100644 index 000000000..23a20cb39 --- /dev/null +++ b/src/app/BreadcrumbPage/utils.ts @@ -0,0 +1,20 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const isItemFilled = (item: React.ReactNode): boolean => { + if (!item) return false; + const toCheck = item['props'] ? item['props'] : item; + return toCheck['isFilled'] || toCheck['isFullHeight'] || toCheck['data-full-height']; +}; diff --git a/src/app/CreateRecording/CreateRecording.tsx b/src/app/CreateRecording/CreateRecording.tsx index 4f6eeac22..08e2ad233 100644 --- a/src/app/CreateRecording/CreateRecording.tsx +++ b/src/app/CreateRecording/CreateRecording.tsx @@ -13,51 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; -import { TemplateType } from '@app/Shared/Services/Api.service'; + import { TargetView } from '@app/TargetView/TargetView'; import { Card, CardBody, Tab, Tabs } from '@patternfly/react-core'; import * as React from 'react'; -import { StaticContext } from 'react-router'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; import { CustomRecordingForm } from './CustomRecordingForm'; import { SnapshotRecordingForm } from './SnapshotRecordingForm'; -export interface CreateRecordingProps { - restartExisting?: boolean; - name?: string; - templateName?: string; - templateType?: TemplateType; - labels?: RecordingLabel[]; - duration?: number; - maxAge?: number; - maxSize?: number; -} - -export interface EventTemplate { - name: string; - description: string; - provider: string; - type: TemplateType; -} - -const Comp: React.FC, StaticContext, CreateRecordingProps>> = (props) => { +export const CreateRecording: React.FC = () => { const [activeTab, setActiveTab] = React.useState(0); - const onTabSelect = React.useCallback((evt, idx) => setActiveTab(Number(idx)), [setActiveTab]); - - const prefilled = React.useMemo( - () => ({ - restartExisting: props.location?.state?.restartExisting, - name: props.location?.state?.name, - templateName: props.location?.state?.templateName, - templateType: props.location?.state?.templateType, - labels: props.location?.state?.labels, - duration: props.location?.state?.duration, - maxAge: props.location?.state?.maxAge, - maxSize: props.location?.state?.maxSize, - }), - [props.location], + const onTabSelect = React.useCallback( + (_evt: MouseEvent | React.MouseEvent, idx: string | number) => setActiveTab(Number(idx)), + [setActiveTab], ); return ( @@ -66,7 +34,7 @@ const Comp: React.FC, StaticContext, C - + @@ -78,5 +46,4 @@ const Comp: React.FC, StaticContext, C ); }; -export const CreateRecording = withRouter(Comp); export default CreateRecording; diff --git a/src/app/CreateRecording/CustomRecordingForm.tsx b/src/app/CreateRecording/CustomRecordingForm.tsx index af6c594fe..ee83c7a5b 100644 --- a/src/app/CreateRecording/CustomRecordingForm.tsx +++ b/src/app/CreateRecording/CustomRecordingForm.tsx @@ -14,16 +14,17 @@ * limitations under the License. */ import { DurationPicker } from '@app/DurationPicker/DurationPicker'; -import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; -import { NotificationsContext } from '@app/Notifications/Notifications'; -import { RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; +import { ErrorView } from '@app/ErrorView/ErrorView'; +import { authFailMessage, isAuthFail } from '@app/ErrorView/types'; import { RecordingLabelFields } from '@app/RecordingMetadata/RecordingLabelFields'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; -import { SelectTemplateSelectorForm } from '@app/Shared/SelectTemplateSelectorForm'; -import { RecordingOptions, RecordingAttributes, TemplateType } from '@app/Shared/Services/Api.service'; +import { RecordingLabel } from '@app/RecordingMetadata/types'; +import { SelectTemplateSelectorForm } from '@app/Shared/Components/SelectTemplateSelectorForm'; +import { LoadingProps } from '@app/Shared/Components/types'; +import { EventTemplate, RecordingAttributes, AdvancedRecordingOptions, Target } from '@app/Shared/Services/api.types'; +import { isTargetAgentHttp } from '@app/Shared/Services/api.utils'; +import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { isTargetAgentHttp, NO_TARGET, Target } from '@app/Shared/Services/Target.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { portalRoot } from '@app/utils/utils'; import { ActionGroup, @@ -47,62 +48,42 @@ import { } from '@patternfly/react-core'; import { HelpIcon } from '@patternfly/react-icons'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; import { forkJoin } from 'rxjs'; import { first } from 'rxjs/operators'; -import { EventTemplate } from './CreateRecording'; - -export interface CustomRecordingFormProps { - prefilled?: { - restartExisting?: boolean; - name?: string; - templateName?: string; - templateType?: TemplateType; - labels?: RecordingLabel[]; - duration?: number; - maxAge?: number; - maxSize?: number; - }; -} - -export const RecordingNamePattern = /^[\w_]+$/; -export const DurationPattern = /^[1-9][0-9]*$/; - -export const CustomRecordingForm: React.FC = ({ prefilled }) => { +import { EventTemplateIdentifier, CustomRecordingFormData } from './types'; +import { isDurationValid, isRecordingNameValid } from './utils'; + +export const CustomRecordingForm: React.FC = () => { const context = React.useContext(ServiceContext); const notifications = React.useContext(NotificationsContext); const history = useHistory(); const addSubscription = useSubscriptions(); - - const [recordingName, setRecordingName] = React.useState(prefilled?.name || ''); - const [nameValid, setNameValid] = React.useState( - prefilled?.name - ? RecordingNamePattern.test(recordingName) - ? ValidatedOptions.success - : ValidatedOptions.error - : ValidatedOptions.default, - ); - const [restartExisting, setRestartExisting] = React.useState(prefilled?.restartExisting || false); - const [continuous, setContinuous] = React.useState((prefilled?.duration || 30) < 1); - const [archiveOnStop, setArchiveOnStop] = React.useState(true); - const [duration, setDuration] = React.useState(prefilled?.duration || 30); - const [durationUnit, setDurationUnit] = React.useState(1000); - const [durationValid, setDurationValid] = React.useState(ValidatedOptions.success); - const [templates, setTemplates] = React.useState([]); - const [template, setTemplate] = React.useState, 'name' | 'type'>>({ - name: prefilled?.templateName, - type: prefilled?.templateType, + const location = useLocation | undefined>(); + + const [formData, setFormData] = React.useState({ + name: '', + labels: [], + continuous: false, + archiveOnStop: true, + restart: false, + duration: 30, + durationUnit: 1000, + maxAge: 0, + maxAgeUnit: 1, + maxSize: 0, + maxSizeUnit: 1, + toDisk: true, + nameValid: ValidatedOptions.default, + labelsValid: ValidatedOptions.default, + durationValid: ValidatedOptions.success, }); - const [maxAge, setMaxAge] = React.useState(prefilled?.maxAge || 0); - const [maxAgeUnits, setMaxAgeUnits] = React.useState(1); - const [maxSize, setMaxSize] = React.useState(prefilled?.maxSize || 0); - const [maxSizeUnits, setMaxSizeUnits] = React.useState(1); - const [toDisk, setToDisk] = React.useState(true); - const [labels, setLabels] = React.useState(prefilled?.labels || []); - const [labelsValid, setLabelsValid] = React.useState(ValidatedOptions.default); + const [templates, setTemplates] = React.useState([]); const [loading, setLoading] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); + const exitForm = React.useCallback(() => history.goBack(), [history]); + const handleCreateRecording = React.useCallback( (recordingAttributes: RecordingAttributes) => { setLoading(true); @@ -113,137 +94,159 @@ export const CustomRecordingForm: React.FC = ({ prefil .subscribe((resp) => { setLoading(false); if (resp && resp.ok) { - history.goBack(); + exitForm(); } }), ); }, - [addSubscription, context.api, history, setLoading], + [addSubscription, context.api, exitForm, setLoading], ); const handleRestartExistingChange = React.useCallback( - (checked) => { - setRestartExisting(checked); - }, - [setRestartExisting], + (checked: boolean) => setFormData((old) => ({ ...old, restart: checked })), + [setFormData], ); const handleContinuousChange = React.useCallback( - (checked) => { - setContinuous(checked); - setDuration(0); - setDurationValid(checked ? ValidatedOptions.success : ValidatedOptions.error); - }, - [setContinuous, setDuration, setDurationValid], + (checked: boolean) => + setFormData((old) => ({ + ...old, + continuous: checked, + duration: 0, + durationValid: checked ? ValidatedOptions.success : ValidatedOptions.error, + })), + [setFormData], ); const handleDurationChange = React.useCallback( - (evt) => { - setDuration(Number(evt)); - setDurationValid(DurationPattern.test(evt) ? ValidatedOptions.success : ValidatedOptions.error); - }, - [setDurationValid, setDuration], + (value: number) => + setFormData((old) => ({ + ...old, + duration: value, + durationValid: isDurationValid(value) ? ValidatedOptions.success : ValidatedOptions.error, + })), + [setFormData], ); const handleDurationUnitChange = React.useCallback( - (evt) => { - setDurationUnit(Number(evt)); - }, - [setDurationUnit], + (unit: number) => setFormData((old) => ({ ...old, durationUnit: unit })), + [setFormData], ); const handleTemplateChange = React.useCallback( - (templateName?: string, templateType?: TemplateType) => { - setTemplate({ - name: templateName, - type: templateType, - }); - }, - [setTemplate], + (template: EventTemplateIdentifier) => setFormData((old) => ({ ...old, template })), + [setFormData], ); const eventSpecifierString = React.useMemo(() => { let str = ''; - const { name, type } = template; - if (name) { - str += `template=${name}`; + const { template } = formData; + if (template && template.name) { + str += `template=${template.name}`; } - if (type) { - str += `,type=${type}`; + if (template && template.type) { + str += `,type=${template.type}`; } return str; - }, [template]); + }, [formData]); const getFormattedLabels = React.useCallback(() => { const obj = {}; - labels.forEach((l) => { - if (!!l.key && !!l.value) { + formData.labels.forEach((l) => { + if (l.key && l.value) { obj[l.key] = l.value; } }); return obj; - }, [labels]); + }, [formData]); const handleRecordingNameChange = React.useCallback( - (name) => { - setNameValid(RecordingNamePattern.test(name) ? ValidatedOptions.success : ValidatedOptions.error); - setRecordingName(name); - }, - [setNameValid, setRecordingName], + (name: string) => + setFormData((old) => ({ + ...old, + name: name, + nameValid: isRecordingNameValid(name) ? ValidatedOptions.success : ValidatedOptions.error, + })), + [setFormData], ); const handleMaxAgeChange = React.useCallback( - (evt) => { - setMaxAge(Number(evt)); - }, - [setMaxAge], + (value: string) => setFormData((old) => ({ ...old, maxAge: Number(value) })), + [setFormData], ); const handleMaxAgeUnitChange = React.useCallback( - (evt) => { - setMaxAgeUnits(Number(evt)); - }, - [setMaxAgeUnits], + (unit: string) => setFormData((old) => ({ ...old, maxAgeUnit: Number(unit) })), + [setFormData], ); const handleMaxSizeChange = React.useCallback( - (evt) => { - setMaxSize(Number(evt)); - }, - [setMaxSize], + (value: string) => setFormData((old) => ({ ...old, maxSize: Number(value) })), + [setFormData], ); const handleMaxSizeUnitChange = React.useCallback( - (evt) => { - setMaxSizeUnits(Number(evt)); - }, - [setMaxSizeUnits], + (unit: string) => setFormData((old) => ({ ...old, maxSizeUnit: Number(unit) })), + [setFormData], ); const handleToDiskChange = React.useCallback( - (checked, evt) => { - setToDisk(evt.target.checked); + (toDisk: boolean) => setFormData((old) => ({ ...old, toDisk })), + [setFormData], + ); + + const handleLabelsChange = React.useCallback( + (labels: RecordingLabel[]) => { + setFormData((old) => ({ ...old, labels })); }, - [setToDisk], + [setFormData], + ); + + const handleLabelValidationChange = React.useCallback( + (labelsValid: ValidatedOptions) => setFormData((old) => ({ ...old, labelsValid })), + [setFormData], ); - const setRecordingOptions = React.useCallback( - (options: RecordingOptions) => { + const handleArchiveOnStopChange = React.useCallback( + (archiveOnStop: boolean) => setFormData((old) => ({ ...old, archiveOnStop })), + [setFormData], + ); + + const setAdvancedRecordingOptions = React.useCallback( + (options: AdvancedRecordingOptions) => { // toDisk is not set, and defaults to true because of https://github.com/cryostatio/cryostat/issues/263 - setMaxAge(prefilled?.maxAge || options.maxAge || 0); - setMaxAgeUnits(1); - setMaxSize(prefilled?.maxSize || options.maxSize || 0); - setMaxSizeUnits(1); + setFormData((old) => ({ + ...old, + maxAge: options.maxAge || 0, + maxAgeUnit: 1, + maxSize: options.maxSize || 0, + maxSizeUnit: 1, + })); }, - [setMaxAge, setMaxAgeUnits, setMaxSize, setMaxSizeUnits, prefilled], + [setFormData], ); const handleSubmit = React.useCallback(() => { + const { + name, + nameValid, + restart, + toDisk, + continuous, + maxAge, + maxAgeUnit, + maxSize, + maxSizeUnit, + duration, + durationUnit, + archiveOnStop, + } = formData; + const notificationMessages: string[] = []; if (nameValid !== ValidatedOptions.success) { - notificationMessages.push(`Recording name ${recordingName} is invalid`); + notificationMessages.push(`Recording name ${name} is invalid`); } if (notificationMessages.length > 0) { @@ -252,101 +255,68 @@ export const CustomRecordingForm: React.FC = ({ prefil return; } - const options: RecordingOptions = { - restart: restartExisting, - toDisk: toDisk, - maxAge: toDisk ? (continuous ? maxAge * maxAgeUnits : undefined) : undefined, - maxSize: toDisk ? maxSize * maxSizeUnits : undefined, - }; const recordingAttributes: RecordingAttributes = { - name: recordingName, + name: name, events: eventSpecifierString, duration: continuous ? undefined : duration * (durationUnit / 1000), archiveOnStop: archiveOnStop && !continuous, - options: options, + restart: restart, + advancedOptions: { + toDisk: toDisk, + maxAge: toDisk ? (continuous ? maxAge * maxAgeUnit : undefined) : undefined, + maxSize: toDisk ? maxSize * maxSizeUnit : undefined, + }, metadata: { labels: getFormattedLabels() }, }; handleCreateRecording(recordingAttributes); - }, [ - eventSpecifierString, - getFormattedLabels, - archiveOnStop, - continuous, - duration, - durationUnit, - maxAge, - maxAgeUnits, - maxSize, - maxSizeUnits, - nameValid, - notifications, - recordingName, - restartExisting, - toDisk, - handleCreateRecording, - ]); + }, [eventSpecifierString, getFormattedLabels, formData, notifications, handleCreateRecording]); const refreshFormOptions = React.useCallback( (target: Target) => { - if (target === NO_TARGET) { + if (!target) { return; } addSubscription( forkJoin({ templates: context.api.doGet(`targets/${encodeURIComponent(target.connectUrl)}/templates`), - recordingOptions: context.api.doGet( + recordingOptions: context.api.doGet( `targets/${encodeURIComponent(target.connectUrl)}/recordingOptions`, ), }).subscribe({ next: ({ templates, recordingOptions }) => { setErrorMessage(''); setTemplates(templates); - setTemplate((old) => { - const matched = templates.find((t) => t.name === old.name && t.type === t.type); - return matched ? { name: matched.name, type: matched.type } : {}; + setFormData((old) => { + const matched = templates.find((t) => t.name === old.template?.name && t.type === old.template?.type); + return { ...old, template: matched ? { name: matched.name, type: matched.type } : undefined }; }); - setRecordingOptions(recordingOptions); + setAdvancedRecordingOptions(recordingOptions); }, error: (error) => { - setErrorMessage(isTargetAgentHttp(target) ? 'Unsupported operation: Create recordings' : error.message); // If both throw, first error will be shown + setErrorMessage(isTargetAgentHttp(target) ? 'Unsupported operation: Create recordings' : error.message); setTemplates([]); - setTemplate({}); - setRecordingOptions({}); + setFormData((old) => ({ ...old, template: undefined })); + setAdvancedRecordingOptions({}); }, }), ); }, - [addSubscription, context.api, setTemplates, setTemplate, setRecordingOptions, setErrorMessage], + [addSubscription, context.api, setTemplates, setFormData, setAdvancedRecordingOptions, setErrorMessage], ); - React.useEffect(() => { - addSubscription( - context.target.authFailure().subscribe(() => { - setErrorMessage(authFailMessage); - setTemplates([]); - setTemplate({}); - setRecordingOptions({}); - }), - ); - }, [context.target, setErrorMessage, addSubscription, setTemplates, setTemplate, setRecordingOptions]); - - React.useEffect(() => { - addSubscription(context.target.target().subscribe(refreshFormOptions)); - }, [addSubscription, context.target, refreshFormOptions]); - const isFormInvalid: boolean = React.useMemo(() => { return ( - nameValid !== ValidatedOptions.success || - durationValid !== ValidatedOptions.success || - !template.name || - !template.type || - labelsValid !== ValidatedOptions.success + formData.nameValid !== ValidatedOptions.success || + formData.durationValid !== ValidatedOptions.success || + !formData.template?.name || + !formData.template?.type || + formData.labelsValid !== ValidatedOptions.success ); - }, [nameValid, durationValid, template, labelsValid]); + }, [formData]); const hasReservedLabels = React.useMemo( - () => labels.some((label) => label.key === 'template.name' || label.key === 'template.type'), - [labels], + () => formData.labels.some((label) => label.key === 'template.name' || label.key === 'template.type'), + [formData], ); const createButtonLoadingProps = React.useMemo( @@ -355,22 +325,76 @@ export const CustomRecordingForm: React.FC = ({ prefil spinnerAriaValueText: 'Creating', spinnerAriaLabel: 'create-active-recording', isLoading: loading, - }) as LoadingPropsType, + }) as LoadingProps, [loading], ); const selectedSpecifier = React.useMemo(() => { - const { name, type } = template; - if (name && type) { - return `${name},${type}`; + const { template } = formData; + if (template && template.name && template.type) { + return `${template.name},${template.type}`; } return ''; - }, [template]); + }, [formData]); const authRetry = React.useCallback(() => { context.target.setAuthRetry(); }, [context.target]); + React.useEffect(() => { + addSubscription( + context.target.authFailure().subscribe(() => { + setErrorMessage(authFailMessage); + setTemplates([]); + setFormData((old) => ({ ...old, template: undefined })); + setAdvancedRecordingOptions({}); + }), + ); + }, [context.target, setErrorMessage, addSubscription, setTemplates, setFormData, setAdvancedRecordingOptions]); + + React.useEffect(() => { + addSubscription(context.target.target().subscribe(refreshFormOptions)); + }, [addSubscription, context.target, refreshFormOptions]); + + React.useEffect(() => { + const { + name, + restart, + template, + labels, + duration, + durationUnit, + maxAge, + maxAgeUnit, + maxSize, + maxSizeUnit, + continuous, + skipDurationCheck, + } = location.state || {}; + setFormData((old) => ({ + ...old, + name: name ?? '', + nameValid: !name + ? ValidatedOptions.default + : isRecordingNameValid(name) + ? ValidatedOptions.success + : ValidatedOptions.error, + template, + restart: restart ?? false, + continuous: continuous || false, + labels: labels ?? [], + labelsValid: ValidatedOptions.default, // RecordingLabelFields component handles validating + duration: continuous ? 0 : duration ?? 30, + durationUnit: durationUnit ?? 1000, + durationValid: + skipDurationCheck || continuous || (duration ?? 30) > 0 ? ValidatedOptions.success : ValidatedOptions.error, + maxAge: maxAge ?? 0, + maxAgeUnit: maxAgeUnit ?? 1, + maxSize: maxSize ?? 0, + maxSizeUnit: maxSizeUnit ?? 1, + })); + }, [location, setFormData]); + if (errorMessage != '') { return ( = ({ prefil /> ); } + return ( <> @@ -393,22 +418,22 @@ export const CustomRecordingForm: React.FC = ({ prefil fieldId="recording-name" helperText="Enter a recording name. This will be unique within the target JVM." helperTextInvalid="A recording name can contain only letters, numbers, and underscores." - validated={nameValid} + validated={formData.nameValid} > = ({ prefil label="Duration" isRequired fieldId="recording-duration" - validated={durationValid} + validated={formData.durationValid} helperText={ - continuous + formData.continuous ? 'A continuous recording will never be automatically stopped.' - : archiveOnStop + : formData.archiveOnStop ? 'Time before the recording is automatically stopped and copied to archive.' : 'Time before the recording is automatically stopped.' } @@ -435,7 +460,7 @@ export const CustomRecordingForm: React.FC = ({ prefil = ({ prefil = ({ prefil @@ -467,14 +492,14 @@ export const CustomRecordingForm: React.FC = ({ prefil label="Template" isRequired fieldId="recording-template" - validated={!template.name ? ValidatedOptions.default : ValidatedOptions.success} + validated={!formData.template?.name ? ValidatedOptions.default : ValidatedOptions.success} helperText={'The Event Template to be applied in this recording'} helperTextInvalid="A Template must be selected" > @@ -510,9 +535,9 @@ export const CustomRecordingForm: React.FC = ({ prefil } > @@ -530,7 +555,7 @@ export const CustomRecordingForm: React.FC = ({ prefil @@ -543,22 +568,22 @@ export const CustomRecordingForm: React.FC = ({ prefil @@ -571,22 +596,22 @@ export const CustomRecordingForm: React.FC = ({ prefil diff --git a/src/app/CreateRecording/SnapshotRecordingForm.tsx b/src/app/CreateRecording/SnapshotRecordingForm.tsx index 6bb277acf..3fcfed52d 100644 --- a/src/app/CreateRecording/SnapshotRecordingForm.tsx +++ b/src/app/CreateRecording/SnapshotRecordingForm.tsx @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { authFailMessage, ErrorView, isAuthFail, missingSSLMessage } from '@app/ErrorView/ErrorView'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; +import { ErrorView } from '@app/ErrorView/ErrorView'; +import { authFailMessage, isAuthFail, missingSSLMessage } from '@app/ErrorView/types'; +import { LoadingProps } from '@app/Shared/Components/types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { ActionGroup, Button, Form, Text, TextVariants } from '@patternfly/react-core'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; @@ -52,7 +53,7 @@ export const SnapshotRecordingForm: React.FC = (_) = spinnerAriaValueText: 'Creating', spinnerAriaLabel: 'create-snapshot-recording', isLoading: loading, - }) as LoadingPropsType, + }) as LoadingProps, [loading], ); diff --git a/src/app/CreateRecording/types.ts b/src/app/CreateRecording/types.ts new file mode 100644 index 000000000..1e5766949 --- /dev/null +++ b/src/app/CreateRecording/types.ts @@ -0,0 +1,45 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { RecordingLabel } from '@app/RecordingMetadata/types'; +import { EventTemplate } from '@app/Shared/Services/api.types'; +import { ValidatedOptions } from '@patternfly/react-core'; + +export type EventTemplateIdentifier = Pick; + +interface _FormBaseData { + name: string; + template?: EventTemplateIdentifier; + labels: RecordingLabel[]; + continuous: boolean; + archiveOnStop: boolean; + restart: boolean; + duration: number; + durationUnit: number; + skipDurationCheck?: boolean; + maxAge: number; + maxAgeUnit: number; + maxSize: number; + maxSizeUnit: number; + toDisk: boolean; +} + +interface _FormValidationData { + nameValid: ValidatedOptions; + labelsValid: ValidatedOptions; + durationValid: ValidatedOptions; +} + +export type CustomRecordingFormData = _FormBaseData & _FormValidationData; diff --git a/src/app/CreateRecording/utils.ts b/src/app/CreateRecording/utils.ts new file mode 100644 index 000000000..b04dab30c --- /dev/null +++ b/src/app/CreateRecording/utils.ts @@ -0,0 +1,22 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const RecordingNamePattern = /^[\w_]+$/; + +export const DurationPattern = /^[1-9][0-9]*$/; + +export const isRecordingNameValid = (name: string) => RecordingNamePattern.test(name); + +export const isDurationValid = (duration: number) => DurationPattern.test(`${duration}`); diff --git a/src/app/Dashboard/AddCard.tsx b/src/app/Dashboard/AddCard.tsx index 639c02080..99f53b1b9 100644 --- a/src/app/Dashboard/AddCard.tsx +++ b/src/app/Dashboard/AddCard.tsx @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { EmptyText } from '@app/Shared/Components/EmptyText'; +import QuickSearchIcon from '@app/Shared/Components/QuickSearchIcon'; import { dashboardConfigAddCardIntent, StateDispatch } from '@app/Shared/Redux/ReduxStore'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { EmptyText } from '@app/Topology/Shared/EmptyText'; -import QuickSearchIcon from '@app/Topology/Shared/QuickSearchIcon'; import { fakeChartContext, fakeServices } from '@app/utils/fakeData'; -import { useFeatureLevel } from '@app/utils/useFeatureLevel'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useFeatureLevel } from '@app/utils/hooks/useFeatureLevel'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { portalRoot } from '@app/utils/utils'; import { Bullseye, @@ -80,20 +80,15 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { Observable, of } from 'rxjs'; -import { ChartContext } from './Charts/ChartContext'; -import { - getCardDescriptorByTitle, - getDashboardCards, - CardConfig, - DashboardCardDescriptor, - PropControl, -} from './dashboard-utils'; +import { ChartContext } from './Charts/context'; +import { CardConfig, DashboardCardDescriptor, PropControl } from './types'; +import { getCardDescriptorByTitle, getDashboardCards } from './utils'; interface AddCardProps { variant: 'card' | 'icon-button'; } -export const AddCard: React.FC = ({ variant, ..._props }) => { +export const AddCard: React.FC = ({ variant }) => { const dispatch = useDispatch(); const { t } = useTranslation(); diff --git a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.tsx b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.tsx index e270c8bf1..901257860 100644 --- a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.tsx @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; -import { LoadingView } from '@app/LoadingView/LoadingView'; + +import { ErrorView } from '@app/ErrorView/ErrorView'; +import { authFailMessage, isAuthFail } from '@app/ErrorView/types'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; import { - automatedAnalysisAddGlobalFilterIntent, emptyAutomatedAnalysisFilters, TargetAutomatedAnalysisFilters, } from '@app/Shared/Redux/Filters/AutomatedAnalysisFilterSlice'; @@ -27,28 +28,28 @@ import { automatedAnalysisDeleteAllFiltersIntent, automatedAnalysisDeleteCategoryFiltersIntent, automatedAnalysisDeleteFilterIntent, + automatedAnalysisAddGlobalFilterIntent, RootState, StateDispatch, } from '@app/Shared/Redux/ReduxStore'; import { ArchivedRecording, - automatedAnalysisRecordingName, - isGraphQLAuthError, Recording, -} from '@app/Shared/Services/Api.service'; -import { - AutomatedAnalysisScore, + Target, CategorizedRuleEvaluations, - FAILED_REPORT_MESSAGE, + automatedAnalysisRecordingName, NO_RECORDINGS_MESSAGE, + FAILED_REPORT_MESSAGE, + TEMPLATE_UNSUPPORTED_MESSAGE, RECORDING_FAILURE_MESSAGE, + AutomatedAnalysisScore, AnalysisResult, - TEMPLATE_UNSUPPORTED_MESSAGE, -} from '@app/Shared/Services/Report.service'; +} from '@app/Shared/Services/api.types'; +import { isGraphQLAuthError } from '@app/Shared/Services/api.utils'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; +import { automatedAnalysisConfigToRecordingAttributes } from '@app/Shared/Services/service.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { automatedAnalysisConfigToRecordingAttributes, FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { NO_TARGET } from '@app/Shared/Services/Target.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { calculateAnalysisTimer, portalRoot } from '@app/utils/utils'; import { Button, @@ -97,13 +98,8 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { filter, first, map, tap } from 'rxjs'; -import { - DashboardCardDescriptor, - DashboardCardFC, - DashboardCardSizes, - DashboardCardTypeProps, -} from '../dashboard-utils'; import { DashboardCard } from '../DashboardCard'; +import { DashboardCardDescriptor, DashboardCardFC, DashboardCardSizes, DashboardCardTypeProps } from '../types'; import { AutomatedAnalysisCardList } from './AutomatedAnalysisCardList'; import { AutomatedAnalysisConfigDrawer } from './AutomatedAnalysisConfigDrawer'; import { AutomatedAnalysisConfigForm } from './AutomatedAnalysisConfigForm'; @@ -257,10 +253,10 @@ export const AutomatedAnalysisCard: DashboardCardFC context.target .target() .pipe( - filter((target) => target !== NO_TARGET), + filter((target) => !!target), first(), ) - .subscribe((target) => { + .subscribe((target: Target) => { context.reports .reportJson(freshestRecording, target.connectUrl) .pipe(first()) @@ -344,10 +340,10 @@ export const AutomatedAnalysisCard: DashboardCardFC context.target .target() .pipe( - filter((target) => target !== NO_TARGET), + filter((target) => !!target), first(), ) - .subscribe((target) => { + .subscribe((target: Target) => { addSubscription( queryActiveRecordings(target.connectUrl) .pipe( @@ -457,8 +453,8 @@ export const AutomatedAnalysisCard: DashboardCardFC React.useEffect(() => { context.target.target().subscribe((target) => { - setTargetConnectURL(target.connectUrl); - dispatch(automatedAnalysisAddTargetIntent(target.connectUrl)); + setTargetConnectURL(target?.connectUrl || ''); + dispatch(automatedAnalysisAddTargetIntent(target?.connectUrl || '')); generateReport(); }); }, [context.target, generateReport, setTargetConnectURL, dispatch]); @@ -628,7 +624,7 @@ export const AutomatedAnalysisCard: DashboardCardFC key={topic} > {evaluations.map((evaluation) => { - return ; + return ; })} diff --git a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCardList.tsx b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCardList.tsx index 01324081b..75d8f9b5f 100644 --- a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCardList.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCardList.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { AutomatedAnalysisScore, CategorizedRuleEvaluations } from '@app/Shared/Services/Report.service'; +import { CategorizedRuleEvaluations, AutomatedAnalysisScore } from '@app/Shared/Services/api.types'; import { Flex, FlexItem } from '@patternfly/react-core'; import { CheckCircleIcon, @@ -37,13 +37,13 @@ import { } from '@patternfly/react-table'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { transformAADescription } from '../dashboard-utils'; +import { transformAADescription } from './utils'; export interface AutomatedAnalysisCardListProps { evaluations: CategorizedRuleEvaluations[]; } -export const AutomatedAnalysisCardList: React.FC = (props) => { +export const AutomatedAnalysisCardList: React.FC = ({ evaluations }) => { const { t } = useTranslation(); const [sortBy, setSortBy] = React.useState({}); @@ -80,7 +80,7 @@ export const AutomatedAnalysisCardList: React.FC ); const flatFiltered = React.useMemo(() => { - return props.evaluations + return evaluations .flatMap(([_, evaluations]) => { return evaluations.map((evaluation) => evaluation); }) @@ -108,7 +108,7 @@ export const AutomatedAnalysisCardList: React.FC } return 0; }); - }, [sortBy, props.evaluations]); + }, [sortBy, evaluations]); return ( diff --git a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigDrawer.tsx b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigDrawer.tsx index 0c036d08f..61834f0ca 100644 --- a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigDrawer.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigDrawer.tsx @@ -13,12 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LoadingView } from '@app/LoadingView/LoadingView'; -import { RecordingAttributes } from '@app/Shared/Services/Api.service'; -import { RECORDING_FAILURE_MESSAGE, TEMPLATE_UNSUPPORTED_MESSAGE } from '@app/Shared/Services/Report.service'; + +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { + RecordingAttributes, + TEMPLATE_UNSUPPORTED_MESSAGE, + RECORDING_FAILURE_MESSAGE, +} from '@app/Shared/Services/api.types'; +import { automatedAnalysisConfigToRecordingAttributes } from '@app/Shared/Services/service.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { automatedAnalysisConfigToRecordingAttributes } from '@app/Shared/Services/Settings.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { Button, Drawer, diff --git a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx index 2fc181b89..3a10c0ebd 100644 --- a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx @@ -14,19 +14,15 @@ * limitations under the License. */ import { AuthModal } from '@app/AppLayout/AuthModal'; -import { EventTemplate } from '@app/CreateRecording/CreateRecording'; -import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; -import { SelectTemplateSelectorForm } from '@app/Shared/SelectTemplateSelectorForm'; -import { - AutomatedAnalysisRecordingConfig, - automatedAnalysisRecordingName, - isHttpError, - TemplateType, -} from '@app/Shared/Services/Api.service'; +import { ErrorView } from '@app/ErrorView/ErrorView'; +import { authFailMessage, isAuthFail } from '@app/ErrorView/types'; +import { SelectTemplateSelectorForm } from '@app/Shared/Components/SelectTemplateSelectorForm'; +import { Target, EventTemplate, automatedAnalysisRecordingName, NullableTarget } from '@app/Shared/Services/api.types'; +import { isHttpError } from '@app/Shared/Services/api.utils'; +import type { AutomatedAnalysisRecordingConfig } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NO_TARGET, Target } from '@app/Shared/Services/Target.service'; -import { TargetSelect } from '@app/Shared/TargetSelect'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { TargetSelect } from '@app/TargetView/TargetSelect'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { Button, Card, @@ -61,20 +57,13 @@ import { CloseIcon, PencilAltIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { first, iif, of, ReplaySubject, take } from 'rxjs'; +import { AutomatedAnalysisConfigFormData } from './types'; -interface AutomatedAnalysisConfigFormProps { +export interface AutomatedAnalysisConfigFormProps { useTitle?: boolean; inlineForm?: boolean; } -interface FormConfig { - maxAge: number; - maxAgeUnits: number; - maxSize: number; - maxSizeUnits: number; - template: Pick, 'name' | 'type'>; -} - export const AutomatedAnalysisConfigForm: React.FC = ({ useTitle = false, inlineForm = false, @@ -83,17 +72,17 @@ export const AutomatedAnalysisConfigForm: React.FC(1)); + const targetSubjectRef = React.useRef(new ReplaySubject(1)); const targetSubject = targetSubjectRef.current; const [recordingConfig, setRecordingConfig] = React.useState( context.settings.automatedAnalysisRecordingConfig(), ); - const [formConfig, setFormConfig] = React.useState({ + const [formData, setFormConfig] = React.useState({ maxAge: context.settings.automatedAnalysisRecordingConfig().maxAge, - maxAgeUnits: 1, + maxAgeUnit: 1, maxSize: context.settings.automatedAnalysisRecordingConfig().maxSize, - maxSizeUnits: 1, + maxSizeUnit: 1, template: context.settings.automatedAnalysisRecordingConfig().template, }); @@ -102,18 +91,17 @@ export const AutomatedAnalysisConfigForm: React.FC { + (target?: Target) => { setIsLoading(true); addSubscription( iif( - () => { - return target === NO_TARGET; - }, + () => !target, of([]), context.api .doGet( - `targets/${encodeURIComponent(target.connectUrl)}/templates`, + `targets/${encodeURIComponent(target?.connectUrl || '')}/templates`, 'v1', undefined, undefined, @@ -126,10 +114,10 @@ export const AutomatedAnalysisConfigForm: React.FC { const oldTemplate = old.template; - const matched = templates.find((t) => t.name === oldTemplate.name && t.type === t.type); + const matched = templates.find((t) => t.name === oldTemplate?.name && t.type === oldTemplate?.type); return { ...old, - template: matched ? { name: matched.name, type: matched.type } : {}, + template: matched ? { name: matched.name, type: matched.type } : undefined, }; }); setIsLoading(false); @@ -218,14 +206,11 @@ export const AutomatedAnalysisConfigForm: React.FC { + (template) => { setFormConfig((old) => { return { ...old, - template: { - name: templateName, - type: templateType, - }, + template, }; }); }, @@ -233,26 +218,26 @@ export const AutomatedAnalysisConfigForm: React.FC { - const { template, maxSize, maxSizeUnits, maxAge, maxAgeUnits } = formConfig; + const { template, maxSize, maxSizeUnit, maxAge, maxAgeUnit } = formData; setAAConfig({ template: template as Pick, - maxSize: maxSize * maxSizeUnits, - maxAge: maxAge * maxAgeUnits, + maxSize: maxSize * maxSizeUnit, + maxAge: maxAge * maxAgeUnit, }); setEditing(false); - }, [setAAConfig, setEditing, formConfig]); + }, [setAAConfig, setEditing, formData]); const authRetry = React.useCallback(() => { setIsAuthModalOpen(true); }, [setIsAuthModalOpen]); const selectedSpecifier = React.useMemo(() => { - const { name, type } = formConfig.template; - if (name && type) { - return `${name},${type}`; + const { template } = formData; + if (template && template.name && template.type) { + return `${template.name},${template.type}`; } return ''; - }, [formConfig.template]); + }, [formData]); const targetSelect = React.useMemo(() => { return editing && targetSubject.next(target)} />; @@ -271,7 +256,7 @@ export const AutomatedAnalysisConfigForm: React.FC {t('AutomatedAnalysisConfigForm.TEMPLATE_HELPER_TEXT')} - {formConfig.template.type == 'TARGET' && errorMessage === '' && ( + {formData.template?.type == 'TARGET' && errorMessage === '' && ( {t('AutomatedAnalysisConfigForm.TEMPLATE_INVALID_WARNING')} @@ -308,7 +293,7 @@ export const AutomatedAnalysisConfigForm: React.FC @@ -340,7 +325,7 @@ export const AutomatedAnalysisConfigForm: React.FC @@ -389,7 +374,7 @@ export const AutomatedAnalysisConfigForm: React.FC {t('AutomatedAnalysisConfigForm.SAVE_CHANGES')} @@ -469,7 +454,7 @@ export const AutomatedAnalysisConfigForm: React.FC ), - [t, handleSubmit, toggleEdit, formConfig.template, targetSelect, configData, editing, authModal], + [t, handleSubmit, toggleEdit, formData.template, targetSelect, configData, editing, authModal], ); const formSection = React.useMemo( diff --git a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisFilters.tsx b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisFilters.tsx index 3c3a6599f..ef844753e 100644 --- a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisFilters.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisFilters.tsx @@ -16,7 +16,7 @@ import { allowedAutomatedAnalysisFilters } from '@app/Shared/Redux/Filters/AutomatedAnalysisFilterSlice'; import { UpdateFilterOptions } from '@app/Shared/Redux/Filters/Common'; import { automatedAnalysisUpdateCategoryIntent, RootState, StateDispatch } from '@app/Shared/Redux/ReduxStore'; -import { AnalysisResult } from '@app/Shared/Services/Report.service'; +import { AnalysisResult } from '@app/Shared/Services/api.types'; import { Dropdown, DropdownItem, diff --git a/src/app/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel.tsx b/src/app/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel.tsx index b4214945b..904abec0a 100644 --- a/src/app/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { AutomatedAnalysisScore, AnalysisResult } from '@app/Shared/Services/Report.service'; +import { AutomatedAnalysisScore, AnalysisResult } from '@app/Shared/Services/api.types'; import { portalRoot } from '@app/utils/utils'; import { Label, LabelProps, Popover } from '@patternfly/react-core'; import { CheckCircleIcon, ExclamationCircleIcon, InfoCircleIcon, WarningTriangleIcon } from '@patternfly/react-icons'; @@ -22,15 +22,15 @@ import { css } from '@patternfly/react-styles'; import popoverStyles from '@patternfly/react-styles/css/components/Popover/popover'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { transformAADescription } from '../dashboard-utils'; +import { transformAADescription } from './utils'; export interface ClickableAutomatedAnalysisLabelProps { - label: AnalysisResult; + result: AnalysisResult; } export const clickableAutomatedAnalysisKey = 'clickable-automated-analysis-label'; -export const ClickableAutomatedAnalysisLabel: React.FC = ({ label: result }) => { +export const ClickableAutomatedAnalysisLabel: React.FC = ({ result }) => { const { t } = useTranslation(); const [isHoveredOrFocused, setIsHoveredOrFocused] = React.useState(false); @@ -83,7 +83,7 @@ export const ClickableAutomatedAnalysisLabel: React.FC{result.name}} alertSeverityVariant={alertPopoverVariant} diff --git a/src/app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisNameFilter.tsx b/src/app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisNameFilter.tsx index bd5535f34..ea9090a72 100644 --- a/src/app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisNameFilter.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisNameFilter.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { CategorizedRuleEvaluations } from '@app/Shared/Services/Report.service'; +import { CategorizedRuleEvaluations } from '@app/Shared/Services/api.types'; import { portalRoot } from '@app/utils/utils'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import React from 'react'; diff --git a/src/app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisScoreFilter.tsx b/src/app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisScoreFilter.tsx index a497b2b9c..bc805732f 100644 --- a/src/app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisScoreFilter.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisScoreFilter.tsx @@ -15,7 +15,7 @@ */ import { automatedAnalysisAddGlobalFilterIntent, RootState, StateDispatch } from '@app/Shared/Redux/ReduxStore'; -import { AutomatedAnalysisScore } from '@app/Shared/Services/Report.service'; +import { AutomatedAnalysisScore } from '@app/Shared/Services/api.types'; import { portalRoot } from '@app/utils/utils'; import { Button, diff --git a/src/app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisTopicFilter.tsx b/src/app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisTopicFilter.tsx index a29b54431..18567cbd7 100644 --- a/src/app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisTopicFilter.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisTopicFilter.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { CategorizedRuleEvaluations } from '@app/Shared/Services/Report.service'; +import { CategorizedRuleEvaluations } from '@app/Shared/Services/api.types'; import { portalRoot } from '@app/utils/utils'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import React from 'react'; diff --git a/src/app/Dashboard/AutomatedAnalysis/types.ts b/src/app/Dashboard/AutomatedAnalysis/types.ts new file mode 100644 index 000000000..222edc5f9 --- /dev/null +++ b/src/app/Dashboard/AutomatedAnalysis/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CustomRecordingFormData } from '@app/CreateRecording/types'; + +export type AutomatedAnalysisConfigFormData = Pick< + CustomRecordingFormData, + 'maxAge' | 'maxAgeUnit' | 'maxSize' | 'maxSizeUnit' | 'template' +>; diff --git a/src/app/Dashboard/AutomatedAnalysis/utils.tsx b/src/app/Dashboard/AutomatedAnalysis/utils.tsx new file mode 100644 index 000000000..e329f92d4 --- /dev/null +++ b/src/app/Dashboard/AutomatedAnalysis/utils.tsx @@ -0,0 +1,59 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AnalysisResult, Evaluation } from '@app/Shared/Services/api.types'; +import { Stack, StackItem, Label, Title, Text } from '@patternfly/react-core'; +import _ from 'lodash'; +import * as React from 'react'; + +export const transformAADescription = (result: AnalysisResult): JSX.Element => { + const format = (s: Evaluation): JSX.Element => { + if (typeof s === 'string') { + return {s}; + } + if (Array.isArray(s)) { + return ( + + {s.map((e) => ( + + {e.setting} + + + ))} + + ); + } + throw `Unrecognized item: ${s}`; + }; + return ( +
+ {Object.entries(result.evaluation || {}).map(([k, v]) => + v && v.length ? ( +
+ + {_.capitalize(k)} + {format(result.evaluation[k])} + +
+
+ ) : ( +
+ ), + )} +
+ ); +}; diff --git a/src/app/Dashboard/Charts/ChartContext.tsx b/src/app/Dashboard/Charts/context.tsx similarity index 100% rename from src/app/Dashboard/Charts/ChartContext.tsx rename to src/app/Dashboard/Charts/context.tsx diff --git a/src/app/Dashboard/Charts/jfr/JFRMetricsChartCard.tsx b/src/app/Dashboard/Charts/jfr/JFRMetricsChartCard.tsx index 91183c5c5..7dba0895d 100644 --- a/src/app/Dashboard/Charts/jfr/JFRMetricsChartCard.tsx +++ b/src/app/Dashboard/Charts/jfr/JFRMetricsChartCard.tsx @@ -14,18 +14,18 @@ * limitations under the License. */ -import { CreateRecordingProps } from '@app/CreateRecording/CreateRecording'; +import { CustomRecordingFormData } from '@app/CreateRecording/types'; import { - DashboardCardDescriptor, + DashboardCardTypeProps, DashboardCardFC, DashboardCardSizes, - DashboardCardTypeProps, -} from '@app/Dashboard/dashboard-utils'; -import { LoadingView } from '@app/LoadingView/LoadingView'; + DashboardCardDescriptor, +} from '@app/Dashboard/types'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { useTheme } from '@app/utils/useTheme'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; +import { useTheme } from '@app/utils/hooks/useTheme'; import { Bullseye, Button, @@ -46,7 +46,7 @@ import { Trans, useTranslation } from 'react-i18next'; import { useHistory } from 'react-router-dom'; import { interval } from 'rxjs'; import { DashboardCard } from '../../DashboardCard'; -import { ChartContext } from './../ChartContext'; +import { ChartContext } from '../context'; import { ControllerState, RECORDING_NAME } from './JFRMetricsChartController'; export interface JFRMetricsChartCardProps extends DashboardCardTypeProps { @@ -207,17 +207,22 @@ export const JFRMetricsChartCard: DashboardCardFC = (p history.push({ pathname: '/recordings/create', state: { - restartExisting: true, name: RECORDING_NAME, - templateName: 'Continuous', - templateType: 'TARGET', + template: { + name: 'Continuous', + type: 'TARGET', + }, + restart: true, labels: [{ key: 'origin', value: RECORDING_NAME }], duration: -1, + skipDurationCheck: true, // TODO these are arbitrary defaults that will be set in the recording creation form. // Should these values be inferred in some more intelligent way? - maxAge: 120, // seconds - maxSize: 100 * 1024 * 1024, // bytes - } as CreateRecordingProps, + maxAge: 120, + maxAgeUnit: 1, // seconds + maxSize: 100 * 1024 * 1024, + maxSizeUnit: 1, // bytes + } as Partial, }); }, [history]); diff --git a/src/app/Dashboard/Charts/jfr/JFRMetricsChartController.tsx b/src/app/Dashboard/Charts/jfr/JFRMetricsChartController.tsx index 16a4205ee..44987bdac 100644 --- a/src/app/Dashboard/Charts/jfr/JFRMetricsChartController.tsx +++ b/src/app/Dashboard/Charts/jfr/JFRMetricsChartController.tsx @@ -14,10 +14,11 @@ * limitations under the License. */ -import { ApiService, RecordingState } from '@app/Shared/Services/Api.service'; -import { NotificationCategory, NotificationChannel } from '@app/Shared/Services/NotificationChannel.service'; +import { ApiService } from '@app/Shared/Services/Api.service'; +import { NotificationCategory, Target, RecordingState } from '@app/Shared/Services/api.types'; +import { NotificationChannel } from '@app/Shared/Services/NotificationChannel.service'; import { SettingsService } from '@app/Shared/Services/Settings.service'; -import { NO_TARGET, Target, TargetService } from '@app/Shared/Services/Target.service'; +import { TargetService } from '@app/Shared/Services/Target.service'; import { BehaviorSubject, concatMap, @@ -124,8 +125,8 @@ export class JFRMetricsChartController { }); } - private _hasRecording(target: Target): Observable { - if (target === NO_TARGET) { + private _hasRecording(target?: Target): Observable { + if (!target) { return of(false); } return this._api.targetHasRecording(target, { diff --git a/src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx b/src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx index 84d8e0dde..016742443 100644 --- a/src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx +++ b/src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx @@ -15,18 +15,18 @@ */ import { - DashboardCardDescriptor, + DashboardCardTypeProps, DashboardCardFC, DashboardCardSizes, - DashboardCardTypeProps, -} from '@app/Dashboard/dashboard-utils'; -import { ThemeSetting, ThemeType } from '@app/Settings/SettingsUtils'; -import { MBeanMetrics } from '@app/Shared/Services/Api.service'; + DashboardCardDescriptor, +} from '@app/Dashboard/types'; +import { ThemeType, ThemeSetting } from '@app/Settings/types'; +import { MBeanMetrics } from '@app/Shared/Services/api.types'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import useDayjs from '@app/utils/useDayjs'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { useTheme } from '@app/utils/useTheme'; +import useDayjs from '@app/utils/hooks/useDayjs'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; +import { useTheme } from '@app/utils/hooks/useTheme'; import { Chart, ChartArea, @@ -46,7 +46,7 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { interval } from 'rxjs'; import { DashboardCard } from '../../DashboardCard'; -import { ChartContext } from './../ChartContext'; +import { ChartContext } from '../context'; export interface MBeanMetricsChartCardProps extends DashboardCardTypeProps { themeColor: string; diff --git a/src/app/Dashboard/Charts/mbean/MBeanMetricsChartController.tsx b/src/app/Dashboard/Charts/mbean/MBeanMetricsChartController.tsx index 0df501765..151a9398f 100644 --- a/src/app/Dashboard/Charts/mbean/MBeanMetricsChartController.tsx +++ b/src/app/Dashboard/Charts/mbean/MBeanMetricsChartController.tsx @@ -14,11 +14,13 @@ * limitations under the License. */ -import { ApiService, MBeanMetrics } from '@app/Shared/Services/Api.service'; +import { ApiService } from '@app/Shared/Services/Api.service'; +import { MBeanMetrics, Target } from '@app/Shared/Services/api.types'; import { SettingsService } from '@app/Shared/Services/Settings.service'; -import { Target, TargetService } from '@app/Shared/Services/Target.service'; +import { TargetService } from '@app/Shared/Services/Target.service'; import { BehaviorSubject, + catchError, concatMap, distinctUntilChanged, finalize, @@ -26,6 +28,7 @@ import { map, merge, Observable, + of, pairwise, ReplaySubject, Subject, @@ -121,12 +124,16 @@ export class MBeanMetricsChartController { .pipe( tap((_) => this._loading$.next(true)), concatMap((t) => this._queryMetrics(t)), + catchError((_) => of({})), tap((_) => this._loading$.next(false)), ) .subscribe((v) => this._state$.next(v)); } - private _queryMetrics(target: Target): Observable { + private _queryMetrics(target?: Target): Observable { + if (!target) { + return of({}); + } const q: string[] = []; const m = new Map>(); this._metrics.forEach((fields, category) => { diff --git a/src/app/Dashboard/Dashboard.tsx b/src/app/Dashboard/Dashboard.tsx index 4bdc13a0c..bf8c1efc6 100644 --- a/src/app/Dashboard/Dashboard.tsx +++ b/src/app/Dashboard/Dashboard.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FeatureFlag } from '@app/Shared/FeatureFlag/FeatureFlag'; +import { FeatureFlag } from '@app/Shared/Components/FeatureFlag'; import { DashboardConfig } from '@app/Shared/Redux/Configurations/DashboardConfigSlice'; import { dashboardConfigDeleteCardIntent, @@ -22,8 +22,8 @@ import { RootState, StateDispatch, } from '@app/Shared/Redux/ReduxStore'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; import { TargetView } from '@app/TargetView/TargetView'; import { getFromLocalStorage } from '@app/utils/LocalStorage'; import { Grid, GridItem } from '@patternfly/react-core'; @@ -32,13 +32,13 @@ import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; import { AddCard } from './AddCard'; -import { ChartContext } from './Charts/ChartContext'; +import { ChartContext } from './Charts/context'; import { JFRMetricsChartController } from './Charts/jfr/JFRMetricsChartController'; import { MBeanMetricsChartController } from './Charts/mbean/MBeanMetricsChartController'; -import { getCardDescriptorByName, validateCardConfig } from './dashboard-utils'; import { DashboardCardActionMenu } from './DashboardCardActionMenu'; import { DashboardLayoutToolbar } from './DashboardLayoutToolbar'; import { ErrorCard } from './ErrorCard'; +import { getCardDescriptorByName, validateCardConfig } from './utils'; export interface DashboardComponentProps {} @@ -65,7 +65,7 @@ export const Dashboard: React.FC = (_) => { }, [dashboardConfigs]); React.useEffect(() => { - const layouts = getFromLocalStorage('DASHBOARD_CFG', {}) as DashboardConfig; + const layouts = getFromLocalStorage>('DASHBOARD_CFG', {}); if (layouts._version === undefined) { dispatch(dashboardConfigFirstRunIntent()); } diff --git a/src/app/Dashboard/DashboardCard.tsx b/src/app/Dashboard/DashboardCard.tsx index 4f3cb725f..fb583ce16 100644 --- a/src/app/Dashboard/DashboardCard.tsx +++ b/src/app/Dashboard/DashboardCard.tsx @@ -17,11 +17,11 @@ import { Card, CardProps } from '@patternfly/react-core'; import { css } from '@patternfly/react-styles'; import * as React from 'react'; -import { DRAGGABLE_REF_KLAZZ, DashboardCardSizes } from './dashboard-utils'; +import { DRAGGABLE_REF_KLAZZ } from './const'; +import { DashboardCardContext } from './context'; import { DraggableRef } from './DraggableRef'; import { ResizableRef } from './ResizableRef'; - -export const DashboardCardContext = React.createContext>(React.createRef()); +import { DashboardCardSizes } from './types'; export interface DashboardCardProps extends CardProps { dashboardId: number; @@ -93,4 +93,5 @@ export const DashboardCard: React.FC = ({ return {content}; }; + DashboardCard.displayName = 'DashboardCard'; diff --git a/src/app/Dashboard/DashboardLayoutCreateModal.tsx b/src/app/Dashboard/DashboardLayoutCreateModal.tsx index 92a933e52..5d3240f46 100644 --- a/src/app/Dashboard/DashboardLayoutCreateModal.tsx +++ b/src/app/Dashboard/DashboardLayoutCreateModal.tsx @@ -36,15 +36,11 @@ import { import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; +import { DEFAULT_DASHBOARD_NAME } from './const'; import { BlankLayout } from './cryostat-dashboard-templates'; -import { - DashboardLayoutNamePattern, - DEFAULT_DASHBOARD_NAME, - layoutize, - LayoutTemplateContext, - SelectedLayoutTemplate, -} from './dashboard-utils'; import { LayoutTemplatePicker } from './LayoutTemplatePicker'; +import { SelectedLayoutTemplate } from './types'; +import { DashboardLayoutNamePattern, layoutize, LayoutTemplateContext } from './utils'; export interface DashboardLayoutCreateModalProps { oldName?: string; diff --git a/src/app/Dashboard/DashboardLayoutSetAsTemplateModal.tsx b/src/app/Dashboard/DashboardLayoutSetAsTemplateModal.tsx index b7b85598a..aac654140 100644 --- a/src/app/Dashboard/DashboardLayoutSetAsTemplateModal.tsx +++ b/src/app/Dashboard/DashboardLayoutSetAsTemplateModal.tsx @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotificationsContext } from '@app/Notifications/Notifications'; import { dashboardConfigCreateTemplateIntent, RootState } from '@app/Shared/Redux/ReduxStore'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { NotificationCategory } from '@app/Shared/Services/api.types'; +import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; import { ServiceContext } from '@app/Shared/Services/Services'; import { portalRoot } from '@app/utils/utils'; import { ActionGroup, Button, Form, FormGroup, Modal, ModalVariant, TextArea, TextInput } from '@patternfly/react-core'; @@ -23,12 +23,8 @@ import { ValidatedOptions } from '@patternfly/react-core/dist/js/helpers'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { - DashboardLayoutNamePattern, - LayoutTemplateDescriptionPattern, - LAYOUT_TEMPLATE_DESCRIPTION_WORD_LIMIT, - templatize, -} from './dashboard-utils'; +import { LAYOUT_TEMPLATE_DESCRIPTION_WORD_LIMIT } from './const'; +import { DashboardLayoutNamePattern, LayoutTemplateDescriptionPattern, templatize } from './utils'; export interface DashboardLayoutSetAsTemplateModalProps { visible: boolean; diff --git a/src/app/Dashboard/DashboardLayoutToolbar.tsx b/src/app/Dashboard/DashboardLayoutToolbar.tsx index 354f58c9a..23c7705db 100644 --- a/src/app/Dashboard/DashboardLayoutToolbar.tsx +++ b/src/app/Dashboard/DashboardLayoutToolbar.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; -import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; +import { DeleteOrDisableWarningType } from '@app/Modal/types'; import { dashboardConfigCreateLayoutIntent, dashboardConfigDeleteLayoutIntent, @@ -59,17 +59,13 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { AddCard } from './AddCard'; +import { DEFAULT_DASHBOARD_NAME } from './const'; import { BlankLayout } from './cryostat-dashboard-templates'; -import { - DashboardLayout, - DEFAULT_DASHBOARD_NAME, - getUniqueIncrementingName, - LayoutTemplateContext, - SelectedLayoutTemplate, -} from './dashboard-utils'; import { DashboardLayoutCreateModal } from './DashboardLayoutCreateModal'; import { DashboardLayoutSetAsTemplateModal } from './DashboardLayoutSetAsTemplateModal'; import { LayoutTemplateUploadModal } from './LayoutTemplateUploadModal'; +import { SelectedLayoutTemplate, DashboardLayout } from './types'; +import { getUniqueIncrementingName, LayoutTemplateContext } from './utils'; export interface DashboardLayoutToolbarProps { children?: React.ReactNode; diff --git a/src/app/Dashboard/DashboardSolo.tsx b/src/app/Dashboard/DashboardSolo.tsx index 66705a351..6b0297af1 100644 --- a/src/app/Dashboard/DashboardSolo.tsx +++ b/src/app/Dashboard/DashboardSolo.tsx @@ -20,11 +20,12 @@ import { MonitoringIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { useSelector } from 'react-redux'; import { useHistory, useLocation, withRouter } from 'react-router-dom'; -import { CardConfig, getCardDescriptorByName } from './dashboard-utils'; +import { CardConfig } from './types'; +import { getCardDescriptorByName } from './utils'; export interface DashboardSoloProps {} -const DashboardSolo: React.FC = ({ ..._props }) => { +const DashboardSolo: React.FC = () => { const { search } = useLocation(); const history = useHistory(); @@ -65,7 +66,7 @@ const DashboardSolo: React.FC = ({ ..._props }) => { ); } else { - const { id, name, span, props } = cardConfig; + const { name, span, props } = cardConfig; return ( // Use default chart controller @@ -76,7 +77,7 @@ const DashboardSolo: React.FC = ({ ..._props }) => { isDraggable: false, isResizable: false, isFullHeight: true, - dashboardId: id, + dashboardId: 0, })} <> diff --git a/src/app/Dashboard/DraggableRef.tsx b/src/app/Dashboard/DraggableRef.tsx index 2a9050399..a7ff47a7a 100644 --- a/src/app/Dashboard/DraggableRef.tsx +++ b/src/app/Dashboard/DraggableRef.tsx @@ -13,12 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { dashboardConfigReorderCardIntent } from '@app/Shared/Redux/ReduxStore'; import { clickOutside } from '@app/utils/utils'; import { css } from '@patternfly/react-styles'; import React from 'react'; import { useDispatch } from 'react-redux'; -import { DRAGGABLE_REF_KLAZZ } from './dashboard-utils'; +import { DRAGGABLE_REF_KLAZZ } from './const'; import { handleDisabledElements } from './ResizableRef'; const getOverlapScales = (dragIndex: number, hoverIndex: number): [number, number] => { diff --git a/src/app/Dashboard/ErrorCard.tsx b/src/app/Dashboard/ErrorCard.tsx index 4293ea436..e2b3a4799 100644 --- a/src/app/Dashboard/ErrorCard.tsx +++ b/src/app/Dashboard/ErrorCard.tsx @@ -31,14 +31,8 @@ import { import { WrenchIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { - CardConfig, - CardValidationResult, - DashboardCardFC, - DashboardCardSizes, - DashboardCardTypeProps, -} from './dashboard-utils'; import { DashboardCard } from './DashboardCard'; +import { CardConfig, CardValidationResult, DashboardCardFC, DashboardCardSizes, DashboardCardTypeProps } from './types'; export interface ErrorCardProps extends DashboardCardTypeProps { validationResult: CardValidationResult; diff --git a/src/app/Dashboard/JvmDetails/JvmDetailsCard.tsx b/src/app/Dashboard/JvmDetails/JvmDetailsCard.tsx index 80fb43eb7..0adc1ebc4 100644 --- a/src/app/Dashboard/JvmDetails/JvmDetailsCard.tsx +++ b/src/app/Dashboard/JvmDetails/JvmDetailsCard.tsx @@ -14,23 +14,17 @@ * limitations under the License. */ +import { NodeType, Target } from '@app/Shared/Services/api.types'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { Target } from '@app/Shared/Services/Target.service'; -import { NodeAction } from '@app/Topology/Actions/NodeActions'; -import EntityDetails from '@app/Topology/Shared/Entity/EntityDetails'; -import { NodeType } from '@app/Topology/typings'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { NodeAction } from '@app/Topology/Actions/types'; +import EntityDetails from '@app/Topology/Entity/EntityDetails'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { CardActions, CardBody, CardHeader } from '@patternfly/react-core'; import { ContainerNodeIcon } from '@patternfly/react-icons'; import * as React from 'react'; -import { - DashboardCardDescriptor, - DashboardCardFC, - DashboardCardSizes, - DashboardCardTypeProps, -} from '../dashboard-utils'; import { DashboardCard } from '../DashboardCard'; +import { DashboardCardDescriptor, DashboardCardFC, DashboardCardSizes, DashboardCardTypeProps } from '../types'; import '@app/Topology/styles/base.css'; export interface JvmDetailsCardProps extends DashboardCardTypeProps {} diff --git a/src/app/Dashboard/LayoutTemplateGroup.tsx b/src/app/Dashboard/LayoutTemplateGroup.tsx index bed2a4362..13dc087a9 100644 --- a/src/app/Dashboard/LayoutTemplateGroup.tsx +++ b/src/app/Dashboard/LayoutTemplateGroup.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ import { dashboardConfigTemplateHistoryClearIntent } from '@app/Shared/Redux/ReduxStore'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; import { portalRoot } from '@app/utils/utils'; import { CatalogTile } from '@patternfly/react-catalog-view-extension'; import { @@ -33,16 +33,14 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { - CardConfig, - iconify, - LayoutTemplate, - LayoutTemplateContext, LayoutTemplateFilter, - LayoutTemplateVendor, + LayoutTemplate, SelectedLayoutTemplate, SerialCardConfig, - getCardDescriptorByName, -} from './dashboard-utils'; + CardConfig, + LayoutTemplateVendor, +} from './types'; +import { getCardDescriptorByName, iconify, LayoutTemplateContext } from './utils'; export interface LayoutTemplateGroupProps { title: LayoutTemplateFilter; diff --git a/src/app/Dashboard/LayoutTemplatePicker.tsx b/src/app/Dashboard/LayoutTemplatePicker.tsx index 61296afe4..b0b06440d 100644 --- a/src/app/Dashboard/LayoutTemplatePicker.tsx +++ b/src/app/Dashboard/LayoutTemplatePicker.tsx @@ -14,11 +14,11 @@ * limitations under the License. */ import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; -import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; +import { DeleteOrDisableWarningType } from '@app/Modal/types'; import { RootState, dashboardConfigDeleteTemplateIntent } from '@app/Shared/Redux/ReduxStore'; import { ServiceContext } from '@app/Shared/Services/Services'; import { fakeChartContext, fakeServices } from '@app/utils/fakeData'; -import { useFeatureLevel } from '@app/utils/useFeatureLevel'; +import { useFeatureLevel } from '@app/utils/hooks/useFeatureLevel'; import { portalRoot } from '@app/utils/utils'; import { Bullseye, @@ -75,20 +75,17 @@ import { InnerScrollContainer, OuterScrollContainer } from '@patternfly/react-ta import React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { ChartContext } from './Charts/ChartContext'; +import { ChartContext } from './Charts/context'; import { CryostatLayoutTemplates, BlankLayout } from './cryostat-dashboard-templates'; +import { LayoutTemplateGroup, smallestFeatureLevel } from './LayoutTemplateGroup'; +import { SearchAutocomplete } from './SearchAutocomplete'; +import { LayoutTemplate, LayoutTemplateFilter, LayoutTemplateRecord, SelectedLayoutTemplate } from './types'; import { getCardDescriptorByName, hasCardDescriptorByName, - LayoutTemplate, LayoutTemplateContext, - LayoutTemplateFilter, - LayoutTemplateRecord, - SelectedLayoutTemplate, recordToLayoutTemplate, -} from './dashboard-utils'; -import { LayoutTemplateGroup, smallestFeatureLevel } from './LayoutTemplateGroup'; -import { SearchAutocomplete } from './SearchAutocomplete'; +} from './utils'; export enum LayoutTemplateSort { NAME = 'Name', diff --git a/src/app/Dashboard/LayoutTemplateUploadModal.tsx b/src/app/Dashboard/LayoutTemplateUploadModal.tsx index df476926b..152739dd9 100644 --- a/src/app/Dashboard/LayoutTemplateUploadModal.tsx +++ b/src/app/Dashboard/LayoutTemplateUploadModal.tsx @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FUpload, MultiFileUpload, UploadCallbacks } from '@app/Shared/FileUploads'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; +import { FUpload, MultiFileUpload, UploadCallbacks } from '@app/Shared/Components/FileUploads'; +import { LoadingProps } from '@app/Shared/Components/types'; import { dashboardConfigCreateTemplateIntent, RootState } from '@app/Shared/Redux/ReduxStore'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { portalRoot } from '@app/utils/utils'; import { ActionGroup, Button, Form, FormGroup, Modal, ModalVariant, Popover, Text } from '@patternfly/react-core'; import { HelpIcon } from '@patternfly/react-icons'; @@ -26,20 +26,22 @@ import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { forkJoin, from, Observable, of } from 'rxjs'; import { catchError, concatMap, defaultIfEmpty, first } from 'rxjs/operators'; +import { LAYOUT_TEMPLATE_DESCRIPTION_WORD_LIMIT } from './const'; +import { smallestFeatureLevel } from './LayoutTemplateGroup'; import { - DashboardLayoutNamePattern, - LAYOUT_TEMPLATE_DESCRIPTION_WORD_LIMIT, LayoutTemplate, - LayoutTemplateContext, - LayoutTemplateDescriptionPattern, - LayoutTemplateVendor, - LayoutTemplateVersion, SerialLayoutTemplate, mockSerialCardConfig, + LayoutTemplateVersion, + LayoutTemplateVendor, +} from './types'; +import { + DashboardLayoutNamePattern, + LayoutTemplateContext, + LayoutTemplateDescriptionPattern, mockSerialLayoutTemplate, getDashboardCards, -} from './dashboard-utils'; -import { smallestFeatureLevel } from './LayoutTemplateGroup'; +} from './utils'; export interface LayoutTemplateUploadModalProps { visible: boolean; @@ -222,7 +224,7 @@ export const LayoutTemplateUploadModal: React.FC spinnerAriaValueText: t('SUBMITTING', { ns: 'common' }), spinnerAriaLabel: 'submitting-layout-templates', isLoading: uploading, - }) as LoadingPropsType, + }) as LoadingProps, [t, uploading], ); diff --git a/src/app/Dashboard/ResizableRef.tsx b/src/app/Dashboard/ResizableRef.tsx index f097169a9..3ba2a9538 100644 --- a/src/app/Dashboard/ResizableRef.tsx +++ b/src/app/Dashboard/ResizableRef.tsx @@ -13,30 +13,31 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { dashboardConfigResizeCardIntent, RootState } from '@app/Shared/Redux/ReduxStore'; import { gridSpans } from '@patternfly/react-core'; import _ from 'lodash'; import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { CardConfig, DashboardCardSizes } from './dashboard-utils'; -import { DashboardCardContext } from './DashboardCard'; +import { DashboardCardContext } from './context'; +import type { CardConfig, DashboardCardSizes } from './types'; export interface ResizableRefProps { cardId: number; cardSizes: DashboardCardSizes; } -function normalizeAsGridSpans(val: number, min: number, max: number, a: gridSpans, b: gridSpans): gridSpans { +const normalizeAsGridSpans = (val: number, min: number, max: number, a: gridSpans, b: gridSpans): gridSpans => { if (val < min) val = min; else if (val > max) val = max; const ans = Math.round((b - a) * ((val - min) / (max - min)) + a); return _.clamp(ans, a, b) as gridSpans; -} +}; -export function handleDisabledElements(disabled: boolean): void { +export const handleDisabledElements = (disabled: boolean): void => { const disabledElements: HTMLElement[] = Array.from(document.querySelectorAll('.disabled-pointer')); disabledElements.forEach((el) => (el.style['pointer-events'] = disabled ? 'none' : 'auto')); -} +}; export const ResizableRef: React.FC = ({ cardId: dashboardId, diff --git a/src/app/Dashboard/const.ts b/src/app/Dashboard/const.ts new file mode 100644 index 000000000..7a0df1f4d --- /dev/null +++ b/src/app/Dashboard/const.ts @@ -0,0 +1,18 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const DEFAULT_DASHBOARD_NAME = 'Default'; +export const DRAGGABLE_REF_KLAZZ = `draggable-ref`; +export const LAYOUT_TEMPLATE_DESCRIPTION_WORD_LIMIT = 100; diff --git a/src/app/Dashboard/context.tsx b/src/app/Dashboard/context.tsx new file mode 100644 index 000000000..39e83e1c9 --- /dev/null +++ b/src/app/Dashboard/context.tsx @@ -0,0 +1,18 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as React from 'react'; + +export const DashboardCardContext = React.createContext>(React.createRef()); diff --git a/src/app/Dashboard/cryostat-dashboard-templates.tsx b/src/app/Dashboard/cryostat-dashboard-templates.tsx index 878c4116b..b3c6993df 100644 --- a/src/app/Dashboard/cryostat-dashboard-templates.tsx +++ b/src/app/Dashboard/cryostat-dashboard-templates.tsx @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LayoutTemplate, LayoutTemplateVendor, LayoutTemplateVersion } from './dashboard-utils'; +import { LayoutTemplate, LayoutTemplateVendor, LayoutTemplateVersion } from './types'; -const CURR_VERSION: LayoutTemplateVersion = LayoutTemplateVersion['v2.3']; +const CURR_VERSION: LayoutTemplateVersion = LayoutTemplateVersion['v2.4']; export const BlankLayout: LayoutTemplate = { name: 'Blank', diff --git a/src/app/Dashboard/types.ts b/src/app/Dashboard/types.ts new file mode 100644 index 000000000..659531459 --- /dev/null +++ b/src/app/Dashboard/types.ts @@ -0,0 +1,145 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { FeatureLevel } from '@app/Shared/Services/service.types'; +import { gridSpans, LabelProps } from '@patternfly/react-core'; +import { Observable } from 'rxjs'; + +export interface Sized { + minimum: T; + default: T; + maximum: T; +} + +export interface DashboardCardSizes { + span: Sized; + height: Sized; +} + +export interface DashboardCardDescriptor { + featureLevel: FeatureLevel; + icon?: React.ReactNode; + labels?: { + content: string; + color?: LabelProps['color']; + icon?: React.ReactNode; + }[]; + preview?: React.ReactNode; + title: string; + cardSizes: DashboardCardSizes; + description: string; + descriptionFull: JSX.Element | string; + component: DashboardCardFC; + propControls: PropControl[]; + advancedConfig?: JSX.Element; +} +export type DashboardCardFC

= React.FC

& { + cardComponentName: string; +}; + +export interface PropControlExtra { + displayMapper?: (value: string) => string /* only has effect with 'select' PropControl kind */; + min?: number; + max?: number; + [key: string]: unknown; +} + +export interface PropControl { + name: string; + key: string; + description: string; + kind: 'boolean' | 'number' | 'string' | 'text' | 'select'; + values?: unknown[] | Observable; + defaultValue: unknown; + extras?: PropControlExtra; +} + +export interface DashboardCardTypeProps { + span: number; + dashboardId: number; + isDraggable?: boolean; + isResizable?: boolean; + isFullHeight?: boolean; + actions?: JSX.Element[]; +} + +export interface ValidationError { + message: React.ReactNode; +} + +export interface CardValidationResult { + errors: ValidationError[]; + callForAction?: React.ReactNode; +} + +export type LayoutTemplateFilter = 'Suggested' | 'Cryostat' | 'User-submitted'; + +export interface LayoutTemplateController { + selectedTemplate: SelectedLayoutTemplate | undefined; + setSelectedTemplate: (template: React.SetStateAction) => void; + isUploadModalOpen: boolean; + setIsUploadModalOpen: (isOpen: React.SetStateAction) => void; +} + +export interface CardConfig { + id: string; + name: string; + span: gridSpans; + props: object; +} + +export type SerialCardConfig = Omit; + +export const mockSerialCardConfig: SerialCardConfig = { + name: 'Default', + span: 12, + props: {}, +}; +export interface DashboardLayout { + name: string; + cards: CardConfig[]; + favorite: boolean; +} + +export type SerialDashboardLayout = Omit & { cards: SerialCardConfig[] }; + +// only name and vendor are needed to identify a template +export type LayoutTemplateRecord = Pick; + +export enum LayoutTemplateVersion { + 'v2.3' = 'v2.3', + 'v2.4' = 'v2.4', +} + +export enum LayoutTemplateVendor { + BLANK = 'Blank', + CRYOSTAT = 'Cryostat', + USER = 'User-submitted', +} + +export interface LayoutTemplate { + name: string; + description: string; + cards: SerialCardConfig[]; + version: LayoutTemplateVersion; + vendor: LayoutTemplateVendor; +} + +export interface SelectedLayoutTemplate { + template: LayoutTemplate; + category: LayoutTemplateFilter; +} + +export type SerialLayoutTemplate = Omit; diff --git a/src/app/Dashboard/dashboard-utils.tsx b/src/app/Dashboard/utils.tsx similarity index 61% rename from src/app/Dashboard/dashboard-utils.tsx rename to src/app/Dashboard/utils.tsx index 2592a3026..17bb102a6 100644 --- a/src/app/Dashboard/dashboard-utils.tsx +++ b/src/app/Dashboard/utils.tsx @@ -17,90 +17,37 @@ import cryostatLogo from '@app/assets/cryostat_icon_rgb_default.svg'; import cryostatLogoDark from '@app/assets/cryostat_icon_rgb_reverse.svg'; import { dashboardConfigDeleteCardIntent } from '@app/Shared/Redux/ReduxStore'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { withThemedIcon } from '@app/utils/withThemedIcon'; -import { - LabelProps, - gridSpans, - Button, - ButtonVariant, - Stack, - StackItem, - Label, - Title, - Text, -} from '@patternfly/react-core'; +import { Button, ButtonVariant } from '@patternfly/react-core'; import { FileIcon, UnknownIcon, UserIcon } from '@patternfly/react-icons'; import { nanoid } from '@reduxjs/toolkit'; import { TFunction } from 'i18next'; -import React from 'react'; +import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; -import { Observable } from 'rxjs'; import { AutomatedAnalysisCardDescriptor } from './AutomatedAnalysis/AutomatedAnalysisCard'; import { JFRMetricsChartCardDescriptor } from './Charts/jfr/JFRMetricsChartCard'; import { MBeanMetricsChartCardDescriptor } from './Charts/mbean/MBeanMetricsChartCard'; import { JvmDetailsCardDescriptor } from './JvmDetails/JvmDetailsCard'; -import { AnalysisResult, Suggestion } from '@app/Shared/Services/Report.service'; -import _ from 'lodash'; - -export const DEFAULT_DASHBOARD_NAME = 'Default'; -export const DRAGGABLE_REF_KLAZZ = `draggable-ref`; -export const LAYOUT_TEMPLATE_DESCRIPTION_WORD_LIMIT = 100; +import { + SerialLayoutTemplate, + SerialCardConfig, + LayoutTemplateVersion, + LayoutTemplateController, + LayoutTemplateVendor, + DashboardLayout, + LayoutTemplate, + LayoutTemplateRecord, + CardConfig, + CardValidationResult, + ValidationError, + DashboardCardDescriptor, +} from './types'; export const DashboardLayoutNamePattern = /^[a-zA-Z0-9_.-]+( [a-zA-Z0-9_.-]+)*$/; export const LayoutTemplateDescriptionPattern = /^[a-zA-Z0-9\s.,\-'";?!@#$%^&*()[\]_+=:{}]*$/; -export interface CardConfig { - id: string; - name: string; - span: gridSpans; - props: object; -} - -export type SerialCardConfig = Omit; - -export const mockSerialCardConfig: SerialCardConfig = { - name: 'Default', - span: 12, - props: {}, -}; -export interface DashboardLayout { - name: string; - cards: CardConfig[]; - favorite: boolean; -} - -export type SerialDashboardLayout = Omit & { cards: SerialCardConfig[] }; - -// only name and vendor are needed to identify a template -export type LayoutTemplateRecord = Pick; - -export enum LayoutTemplateVersion { - 'v2.3' = 'v2.3', -} - -export enum LayoutTemplateVendor { - BLANK = 'Blank', - CRYOSTAT = 'Cryostat', - USER = 'User-submitted', -} - -export interface LayoutTemplate { - name: string; - description: string; - cards: SerialCardConfig[]; - version: LayoutTemplateVersion; - vendor: LayoutTemplateVendor; -} - -export interface SelectedLayoutTemplate { - template: LayoutTemplate; - category: LayoutTemplateFilter; -} - -export type SerialLayoutTemplate = Omit; - export const mockSerialLayoutTemplate: SerialLayoutTemplate = { name: 'Default', description: 'Default.', @@ -108,15 +55,6 @@ export const mockSerialLayoutTemplate: SerialLayoutTemplate = { version: LayoutTemplateVersion['v2.3'], }; -export type LayoutTemplateFilter = 'Suggested' | 'Cryostat' | 'User-submitted'; - -export interface LayoutTemplateController { - selectedTemplate: SelectedLayoutTemplate | undefined; - setSelectedTemplate: (template: React.SetStateAction) => void; - isUploadModalOpen: boolean; - setIsUploadModalOpen: (isOpen: React.SetStateAction) => void; -} - // use a provider export const LayoutTemplateContext = React.createContext({ selectedTemplate: undefined, @@ -183,41 +121,41 @@ export const getUniqueIncrementingName = (init = 'Custom', names: string[]): str return name; }; -export function hasCardDescriptorByName(name: string): boolean { +export const hasCardDescriptorByName = (name: string): boolean => { for (const choice of getDashboardCards()) { if (choice.component.cardComponentName === name) { return true; } } return false; -} +}; -export function getCardDescriptorByName(name: string): DashboardCardDescriptor { +export const getCardDescriptorByName = (name: string): DashboardCardDescriptor => { for (const choice of getDashboardCards()) { if (choice.component.cardComponentName === name) { return choice; } } throw new Error(`Unknown card type selection: ${name}`); -} +}; -export function hasCardDescriptorByTitle(title: string, t: TFunction): boolean { +export const hasCardDescriptorByTitle = (title: string, t: TFunction): boolean => { for (const choice of getDashboardCards()) { if (t(choice.title) === title) { return true; } } return false; -} +}; -export function getCardDescriptorByTitle(title: string, t: TFunction): DashboardCardDescriptor { +export const getCardDescriptorByTitle = (title: string, t: TFunction): DashboardCardDescriptor => { for (const choice of getDashboardCards()) { if (t(choice.title) === title) { return choice; } } throw new Error(`Unknown card type selection: ${title}`); -} +}; export const getDashboardCards: (featureLevel?: FeatureLevel) => DashboardCardDescriptor[] = ( featureLevel = FeatureLevel.DEVELOPMENT, @@ -231,15 +169,6 @@ export const getDashboardCards: (featureLevel?: FeatureLevel) => DashboardCardDe return cards.filter((card) => card.featureLevel >= featureLevel); }; -interface ValidationError { - message: React.ReactNode; -} - -export interface CardValidationResult { - errors: ValidationError[]; - callForAction?: React.ReactNode; -} - export const RemoveCardAction: React.FC<{ cardIndex: number }> = ({ cardIndex }) => { const dispatch = useDispatch(); const { t } = useTranslation(); @@ -353,104 +282,3 @@ export const validateCardConfig = ({ name, props }: CardConfig, cardIndex: numbe callForAction: errs.length ? : undefined, }; }; - -/* CARD SECTION */ -export interface Sized { - minimum: T; - default: T; - maximum: T; -} - -export interface DashboardCardSizes { - span: Sized; - height: Sized; -} - -/* eslint-disable @typescript-eslint/no-explicit-any */ -export interface DashboardCardDescriptor { - featureLevel: FeatureLevel; - icon?: React.ReactNode; - labels?: { - content: string; - color?: LabelProps['color']; - icon?: React.ReactNode; - }[]; - preview?: React.ReactNode; - title: string; - cardSizes: DashboardCardSizes; - description: string; - descriptionFull: JSX.Element | string; - component: DashboardCardFC; - propControls: PropControl[]; - advancedConfig?: JSX.Element; -} -export type DashboardCardFC

= React.FC

& { - cardComponentName: string; -}; - -export interface PropControlExtra { - displayMapper?: (value: string) => string /* only has effect with 'select' PropControl kind */; - min?: number; - max?: number; - [key: string]: any; -} - -export interface PropControl { - name: string; - key: string; - description: string; - kind: 'boolean' | 'number' | 'string' | 'text' | 'select'; - values?: any[] | Observable; - defaultValue: any; - extras?: PropControlExtra; -} -/* eslint-enable @typescript-eslint/no-explicit-any */ - -export interface DashboardCardTypeProps { - span: number; - dashboardId: number; - isDraggable?: boolean; - isResizable?: boolean; - isFullHeight?: boolean; - actions?: JSX.Element[]; -} - -export const transformAADescription = (result: AnalysisResult): JSX.Element => { - const format = (s): JSX.Element => { - if (typeof s === 'string') { - return {s}; - } - if (Array.isArray(s)) { - return ( - - {s.map((e) => ( - - {e.setting} - - - ))} - - ); - } - throw `Unrecognized item: ${s}`; - }; - return ( -

- {Object.entries(result.evaluation || {}).map(([k, v]) => - v && v.length ? ( -
- - {_.capitalize(k)} - {format(result.evaluation[k])} - -
-
- ) : ( -
- ), - )} -
- ); -}; diff --git a/src/app/DateTimePicker/DateTimePicker.tsx b/src/app/DateTimePicker/DateTimePicker.tsx index 15744181c..b9297edbd 100644 --- a/src/app/DateTimePicker/DateTimePicker.tsx +++ b/src/app/DateTimePicker/DateTimePicker.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { TimePicker } from '@app/DateTimePicker/TimePicker'; -import { useDayjs } from '@app/utils/useDayjs'; +import { useDayjs } from '@app/utils/hooks/useDayjs'; import { Timezone, defaultDatetimeFormat } from '@i18n/datetime'; import { isHourIn24hAM } from '@i18n/datetimeUtils'; import { @@ -51,7 +51,7 @@ export const DateTimePicker: React.FC = ({ onSelect, onDism const [timezone, setTimezone] = React.useState(defaultDatetimeFormat.timeZone); // Not affected by user preferences const handleTabSelect = React.useCallback( - (_, key: string | number) => setActiveTab(`${key}` as _TabKey), + (_: MouseEvent | React.MouseEvent, key: string | number) => setActiveTab(`${key}` as _TabKey), [setActiveTab], ); diff --git a/src/app/DateTimePicker/TimezonePicker.tsx b/src/app/DateTimePicker/TimezonePicker.tsx index 45935e435..1d4a51491 100644 --- a/src/app/DateTimePicker/TimezonePicker.tsx +++ b/src/app/DateTimePicker/TimezonePicker.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useDayjs } from '@app/utils/useDayjs'; +import { useDayjs } from '@app/utils/hooks/useDayjs'; import { supportedTimezones, Timezone } from '@i18n/datetime'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import { GlobeIcon } from '@patternfly/react-icons'; @@ -22,9 +22,9 @@ import { css } from '@patternfly/react-styles'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -export const DEFAULT_NUM_OPTIONS = 10; +const DEFAULT_NUM_OPTIONS = 10; -export const OPTION_INCREMENT = 15; +const OPTION_INCREMENT = 15; export interface TimezonePickerProps { isFlipEnabled?: boolean; diff --git a/src/app/DurationPicker/DurationPicker.tsx b/src/app/DurationPicker/DurationPicker.tsx index f9c3ff850..9f60726ca 100644 --- a/src/app/DurationPicker/DurationPicker.tsx +++ b/src/app/DurationPicker/DurationPicker.tsx @@ -24,28 +24,34 @@ export interface DurationPickerProps { enabled: boolean; } -export const DurationPicker: React.FC = (props) => { +export const DurationPicker: React.FC = ({ + onPeriodChange, + onUnitScalarChange, + period, + unitScalar, + enabled, +}) => { return ( <> props.onPeriodChange(Number(v))} - isDisabled={!props.enabled} + onChange={(v) => onPeriodChange(Number(v))} + isDisabled={!enabled} min="0" /> props.onUnitScalarChange(Number(v))} + value={unitScalar} + onChange={(v) => onUnitScalarChange(Number(v))} aria-label="Duration Picker Units Input" - isDisabled={!props.enabled} + isDisabled={!enabled} > diff --git a/src/app/DynamicImport.tsx b/src/app/DynamicImport.tsx deleted file mode 100644 index 4cf303f38..000000000 --- a/src/app/DynamicImport.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright The Cryostat Authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { accessibleRouteChangeHandler } from '@app/utils/utils'; -import * as React from 'react'; - -interface IDynamicImport { - /* eslint-disable @typescript-eslint/no-explicit-any */ - load: () => Promise; - children: any; - /* eslint-enable @typescript-eslint/no-explicit-any */ - focusContentAfterMount: boolean; -} - -class DynamicImport extends React.Component { - public state = { - component: null, - }; - private routeFocusTimer: number; - constructor(props: IDynamicImport) { - super(props); - this.routeFocusTimer = 0; - } - public componentWillUnmount() { - window.clearTimeout(this.routeFocusTimer); - } - public componentDidMount() { - this.props - .load() - .then((component) => { - if (component) { - this.setState({ - component: component.default ? component.default : component, - }); - } - }) - .then(() => { - if (this.props.focusContentAfterMount) { - this.routeFocusTimer = accessibleRouteChangeHandler(); - } - }); - } - public render() { - return this.props.children(this.state.component); - } -} - -export { DynamicImport }; diff --git a/src/app/ErrorView/ErrorView.tsx b/src/app/ErrorView/ErrorView.tsx index 072689ec3..217c58d4e 100644 --- a/src/app/ErrorView/ErrorView.tsx +++ b/src/app/ErrorView/ErrorView.tsx @@ -17,37 +17,32 @@ import { Button, EmptyState, EmptyStateBody, EmptyStateIcon, Title, StackItem, S import { ExclamationCircleIcon } from '@patternfly/react-icons'; import * as React from 'react'; -export const authFailMessage = 'Authentication failure'; - -export const missingSSLMessage = 'Bad Gateway'; - -export const isAuthFail = (message: string) => message === authFailMessage; export interface ErrorViewProps { - title: string | React.ReactNode; - message: string | React.ReactNode; + title: React.ReactNode; + message: React.ReactNode; retryButtonMessage?: string; retry?: () => void; } -export const ErrorView: React.FC = (props) => { +export const ErrorView: React.FC = ({ title, message, retryButtonMessage = 'Retry', retry }) => { return ( <> - {props.title} + {title} <> - {props.message} - {props.retry && ( + {message} + {retry ? ( - - )} + ) : null} diff --git a/src/app/ErrorView/types.ts b/src/app/ErrorView/types.ts new file mode 100644 index 000000000..0d2914a9b --- /dev/null +++ b/src/app/ErrorView/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const authFailMessage = 'Authentication failure'; + +export const missingSSLMessage = 'Bad Gateway'; + +export const isAuthFail = (message: string) => message === authFailMessage; diff --git a/src/app/Events/EventTemplates.tsx b/src/app/Events/EventTemplates.tsx index 1ea42a3e7..97a062272 100644 --- a/src/app/Events/EventTemplates.tsx +++ b/src/app/Events/EventTemplates.tsx @@ -13,18 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { CreateRecordingProps } from '@app/CreateRecording/CreateRecording'; -import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; -import { LoadingView } from '@app/LoadingView/LoadingView'; + +import { CustomRecordingFormData } from '@app/CreateRecording/types'; +import { ErrorView } from '@app/ErrorView/ErrorView'; +import { authFailMessage, isAuthFail } from '@app/ErrorView/types'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; -import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; -import { FUpload, MultiFileUpload, UploadCallbacks } from '@app/Shared/FileUploads'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; -import { EventTemplate } from '@app/Shared/Services/Api.service'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { DeleteOrDisableWarningType } from '@app/Modal/types'; +import { FUpload, MultiFileUpload, UploadCallbacks } from '@app/Shared/Components/FileUploads'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { LoadingProps } from '@app/Shared/Components/types'; +import { EventTemplate, NotificationCategory, Target } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NO_TARGET } from '@app/Shared/Services/Target.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { portalRoot, sortResources, TableColumn } from '@app/utils/utils'; import { ActionGroup, @@ -91,12 +91,12 @@ export const EventTemplates: React.FC = (_) => { const context = React.useContext(ServiceContext); const history = useHistory(); - const [templates, setTemplates] = React.useState([] as EventTemplate[]); - const [filteredTemplates, setFilteredTemplates] = React.useState([] as EventTemplate[]); + const [templates, setTemplates] = React.useState([]); + const [filteredTemplates, setFilteredTemplates] = React.useState([]); const [filterText, setFilterText] = React.useState(''); const [warningModalOpen, setWarningModalOpen] = React.useState(false); const [uploadModalOpen, setUploadModalOpen] = React.useState(false); - const [sortBy, setSortBy] = React.useState({} as ISortBy); + const [sortBy, setSortBy] = React.useState({}); const [isLoading, setIsLoading] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); const [templateToDelete, setTemplateToDelete] = React.useState(undefined); @@ -165,9 +165,9 @@ export const EventTemplates: React.FC = (_) => { context.target .target() .pipe( - filter((target) => target !== NO_TARGET), + filter((target) => !!target), first(), - concatMap((target) => + concatMap((target: Target) => context.api.doGet(`targets/${encodeURIComponent(target.connectUrl)}/templates`), ), ) @@ -258,7 +258,7 @@ export const EventTemplates: React.FC = (_) => { onClick: () => history.push({ pathname: '/recordings/create', - state: { templateName: t.name, templateType: t.type } as CreateRecordingProps, + state: { template: { name: t.name, type: t.type } } as Partial, }), }, ] as IAction[]; @@ -499,7 +499,7 @@ export const EventTemplatesUploadModal: React.FC spinnerAriaValueText: 'Submitting', spinnerAriaLabel: 'submitting-custom-event-template', isLoading: uploading, - }) as LoadingPropsType, + }) as LoadingProps, [uploading], ); diff --git a/src/app/Events/EventTypes.tsx b/src/app/Events/EventTypes.tsx index 8def9e2c2..dfee86849 100644 --- a/src/app/Events/EventTypes.tsx +++ b/src/app/Events/EventTypes.tsx @@ -13,13 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; -import { LoadingView } from '@app/LoadingView/LoadingView'; +import { ErrorView } from '@app/ErrorView/ErrorView'; +import { authFailMessage, isAuthFail } from '@app/ErrorView/types'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { EventType, Target } from '@app/Shared/Services/api.types'; +import { getCategoryString } from '@app/Shared/Services/api.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NO_TARGET } from '@app/Shared/Services/Target.service'; -import { useSort } from '@app/utils/useSort'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { hashCode, sortResources, TableColumn } from '@app/utils/utils'; +import { useSort } from '@app/utils/hooks/useSort'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; +import { hashCode, includesSubstr, sortResources, TableColumn } from '@app/utils/utils'; import { Toolbar, ToolbarContent, @@ -47,20 +49,6 @@ import { import * as React from 'react'; import { concatMap, filter, first } from 'rxjs/operators'; -export interface EventType { - name: string; - typeId: string; - description: string; - category: string[]; - options: { [key: string]: OptionDescriptor }[]; -} - -export interface OptionDescriptor { - name: string; - description: string; - defaultValue: string; -} - interface RowData { eventType: EventType; isExpanded: boolean; @@ -68,12 +56,6 @@ interface RowData { children?: React.ReactNode; } -const getCategoryString = (eventType: EventType): string => { - return eventType.category.join(', ').trim(); -}; - -const includesSubstr = (a: string, b: string) => !!a && !!b && a.toLowerCase().includes(b.trim().toLowerCase()); - const tableColumns: TableColumn[] = [ { title: 'Name', @@ -104,7 +86,7 @@ export const EventTypes: React.FC = (_) => { const addSubscription = useSubscriptions(); const prevPerPage = React.useRef(10); - const [types, setTypes] = React.useState([] as EventType[]); + const [types, setTypes] = React.useState([]); const [currentPage, setCurrentPage] = React.useState(1); const [perPage, setPerPage] = React.useState(10); const [openRows, setOpenRows] = React.useState([]); @@ -114,7 +96,7 @@ export const EventTypes: React.FC = (_) => { const [sortBy, getSortParams] = useSort(); const handleTypes = React.useCallback( - (types) => { + (types: EventType[]) => { setTypes(types); setIsLoading(false); setErrorMessage(''); @@ -123,7 +105,7 @@ export const EventTypes: React.FC = (_) => { ); const handleError = React.useCallback( - (error) => { + (error: Error) => { setIsLoading(false); setErrorMessage(error.message); }, @@ -136,9 +118,9 @@ export const EventTypes: React.FC = (_) => { context.target .target() .pipe( - filter((target) => target !== NO_TARGET), + filter((target) => !!target), first(), - concatMap((target) => + concatMap((target: Target) => context.api.doGet(`targets/${encodeURIComponent(target.connectUrl)}/events`), ), ) @@ -201,14 +183,14 @@ export const EventTypes: React.FC = (_) => { }, [currentPage, perPage, filterTypesByText, openRows]); const onCurrentPage = React.useCallback( - (_, currentPage: number) => { + (_: MouseEvent | React.MouseEvent, currentPage: number) => { setCurrentPage(currentPage); }, [setCurrentPage], ); const onPerPage = React.useCallback( - (_, perPage: number) => { + (_: MouseEvent | React.MouseEvent, perPage: number) => { const offset = (currentPage - 1) * prevPerPage.current; prevPerPage.current = perPage; setPerPage(perPage); diff --git a/src/app/Events/Events.tsx b/src/app/Events/Events.tsx index e8511a3d7..033111242 100644 --- a/src/app/Events/Events.tsx +++ b/src/app/Events/Events.tsx @@ -16,9 +16,8 @@ import { AgentLiveProbes } from '@app/Agent/AgentLiveProbes'; import { AgentProbeTemplates } from '@app/Agent/AgentProbeTemplates'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NO_TARGET } from '@app/Shared/Services/Target.service'; import { TargetView } from '@app/TargetView/TargetView'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { getActiveTab, switchTab } from '@app/utils/utils'; import { Card, CardBody, Stack, StackItem, Tab, Tabs, Tooltip } from '@patternfly/react-core'; import * as React from 'react'; @@ -112,7 +111,7 @@ export const AgentTabs: React.FC = () => { context.target .target() .pipe( - filter((target) => target !== NO_TARGET), + filter((target) => !!target), concatMap((_) => context.api.isProbeEnabled()), ) .subscribe(setAgentDetected), diff --git a/src/app/Joyride/CryostatJoyride.tsx b/src/app/Joyride/CryostatJoyride.tsx index afdf48c3a..fc086c9ac 100644 --- a/src/app/Joyride/CryostatJoyride.tsx +++ b/src/app/Joyride/CryostatJoyride.tsx @@ -18,15 +18,16 @@ import cryostatLogoDark from '@app/assets/cryostat_logo_vert_rgb_reverse.svg'; import build from '@app/build.json'; import { useJoyride } from '@app/Joyride/JoyrideProvider'; import JoyrideTooltip from '@app/Joyride/JoyrideTooltip'; -import { ThemeSetting } from '@app/Settings/SettingsUtils'; -import { useTheme } from '@app/utils/useTheme'; +import { ThemeSetting } from '@app/Settings/types'; +import { useTheme } from '@app/utils/hooks/useTheme'; import React from 'react'; import ReactJoyride, { CallBackProps, ACTIONS, EVENTS, STATUS } from 'react-joyride'; + interface CryostatJoyrideProps { - children: React.ReactNode; + children?: React.ReactNode; } -const CryostatJoyride: React.FC = (props) => { +const CryostatJoyride: React.FC = ({ children }) => { const { setState, state: { run, stepIndex, steps }, @@ -265,7 +266,7 @@ const CryostatJoyride: React.FC = (props) => { }, }} /> - {props.children} + {children} ); }; diff --git a/src/app/Joyride/JoyrideProvider.tsx b/src/app/Joyride/JoyrideProvider.tsx index 51deb07b5..73b3c3ccc 100644 --- a/src/app/Joyride/JoyrideProvider.tsx +++ b/src/app/Joyride/JoyrideProvider.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import useSetState from '@app/utils/useSetState'; +import useSetState from '@app/utils/hooks/useSetState'; import React from 'react'; import { Step } from 'react-joyride'; @@ -23,7 +23,7 @@ export interface JoyrideState { steps: Step[]; } -const defaultState = { +const defaultState: JoyrideState = { run: false, stepIndex: 0, steps: [] as Step[], @@ -31,30 +31,33 @@ const defaultState = { export interface JoyrideContextType { state: JoyrideState; - setState: (patch: Partial | ((previousState: JoyrideState) => Partial)) => void; isNavBarOpen: boolean; - setIsNavBarOpen: (isOpen: React.SetStateAction) => void; + setState: (patch: Partial | ((previousState: JoyrideState) => Partial)) => void; + setIsNavBarOpen: (patch: boolean | ((prev: boolean) => boolean)) => void; } -/* eslint-disable @typescript-eslint/no-empty-function */ export const JoyrideContext = React.createContext({ state: defaultState, setState: () => undefined, isNavBarOpen: true, setIsNavBarOpen: () => undefined, }); -/* eslint-enable @typescript-eslint/no-empty-function */ -export const JoyrideProvider: React.FC<{ children }> = (props) => { +export interface JoyrideProviderProps { + children?: React.ReactNode; +} + +export const JoyrideProvider: React.FC = ({ children, ...props }) => { const [state, setState] = useSetState(defaultState); const [isNavBarOpen, setIsNavBarOpen] = React.useState(true); - const value = React.useMemo( + + const value = React.useMemo( () => ({ state, setState, isNavBarOpen, setIsNavBarOpen }), [state, setState, isNavBarOpen, setIsNavBarOpen], ); return ( - {props.children} + {children} ); }; diff --git a/src/app/Login/BasicAuthForm.tsx b/src/app/Login/BasicAuthForm.tsx index 8df64dffa..0bfec7142 100644 --- a/src/app/Login/BasicAuthForm.tsx +++ b/src/app/Login/BasicAuthForm.tsx @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AuthMethod } from '@app/Shared/Services/Login.service'; + +import { AuthMethod } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; import { ActionGroup, Button, Checkbox, Form, FormGroup, Text, TextInput, TextVariants } from '@patternfly/react-core'; import { Base64 } from 'js-base64'; import * as React from 'react'; import { map } from 'rxjs/operators'; -import { FormProps } from './FormProps'; +import { FormProps } from './types'; export const BasicAuthForm: React.FC = ({ onSubmit }) => { const context = React.useContext(ServiceContext); @@ -118,6 +119,6 @@ export const BasicAuthForm: React.FC = ({ onSubmit }) => { ); }; -export const BasicAuthDescriptionText = () => { +export const BasicAuthDescriptionText: React.FC = () => { return The Cryostat server is configured with Basic authentication.; }; diff --git a/src/app/Login/ConnectionError.tsx b/src/app/Login/ConnectionError.tsx index 377f6358b..02291f59e 100644 --- a/src/app/Login/ConnectionError.tsx +++ b/src/app/Login/ConnectionError.tsx @@ -17,7 +17,7 @@ import { EmptyState, EmptyStateBody, EmptyStateIcon, Title } from '@patternfly/r import { ExclamationCircleIcon } from '@patternfly/react-icons'; import * as React from 'react'; -export const ConnectionError = () => ( +export const ConnectionError: React.FC = () => ( diff --git a/src/app/Login/Login.tsx b/src/app/Login/Login.tsx index cfec2b4fe..bdb050a3a 100644 --- a/src/app/Login/Login.tsx +++ b/src/app/Login/Login.tsx @@ -13,12 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Language } from '@app/Settings/Language'; -import { FeatureFlag } from '@app/Shared/FeatureFlag/FeatureFlag'; -import { AuthMethod } from '@app/Shared/Services/Login.service'; +import { Language } from '@app/Settings/Config/Language'; +import { FeatureFlag } from '@app/Shared/Components/FeatureFlag'; +import { AuthMethod, FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { Card, CardActions, @@ -30,7 +29,7 @@ import { Text, } from '@patternfly/react-core'; import * as React from 'react'; -import { NotificationsContext } from '../Notifications/Notifications'; +import { NotificationsContext } from '../Shared/Services/Notifications.service'; import { BasicAuthDescriptionText, BasicAuthForm } from './BasicAuthForm'; import { ConnectionError } from './ConnectionError'; import { NoopAuthForm } from './NoopAuthForm'; diff --git a/src/app/Login/NoopAuthForm.tsx b/src/app/Login/NoopAuthForm.tsx index 182327ead..ed588dc8e 100644 --- a/src/app/Login/NoopAuthForm.tsx +++ b/src/app/Login/NoopAuthForm.tsx @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AuthMethod } from '@app/Shared/Services/Login.service'; + +import { AuthMethod } from '@app/Shared/Services/service.types'; import * as React from 'react'; -import { FormProps } from './FormProps'; +import { FormProps } from './types'; export const NoopAuthForm: React.FC<FormProps> = ({ onSubmit }) => { React.useEffect(() => { diff --git a/src/app/Login/OpenShiftPlaceholderAuthForm.tsx b/src/app/Login/OpenShiftPlaceholderAuthForm.tsx index f84441aa4..10c7584d9 100644 --- a/src/app/Login/OpenShiftPlaceholderAuthForm.tsx +++ b/src/app/Login/OpenShiftPlaceholderAuthForm.tsx @@ -13,15 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotificationsContext } from '@app/Notifications/Notifications'; -import { AuthMethod, SessionState } from '@app/Shared/Services/Login.service'; + +import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; +import { AuthMethod, SessionState } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { Button, EmptyState, EmptyStateBody, EmptyStateIcon, Text, TextVariants, Title } from '@patternfly/react-core'; import { LockIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { combineLatest } from 'rxjs'; -import { FormProps } from './FormProps'; +import { FormProps } from './types'; export const OpenShiftPlaceholderAuthForm: React.FC<FormProps> = ({ onSubmit }) => { const context = React.useContext(ServiceContext); @@ -76,7 +77,7 @@ export const OpenShiftPlaceholderAuthForm: React.FC<FormProps> = ({ onSubmit }) return <>{showPermissionDenied && permissionDenied}</>; }; -export const OpenShiftAuthDescriptionText = () => { +export const OpenShiftAuthDescriptionText: React.FC = () => { return ( <Text component={TextVariants.p}>The Cryostat server is configured to use OpenShift OAuth authentication.</Text> ); diff --git a/src/app/Login/FormProps.tsx b/src/app/Login/types.ts similarity index 87% rename from src/app/Login/FormProps.tsx rename to src/app/Login/types.ts index 60ff483cf..0cbe66cbb 100644 --- a/src/app/Login/FormProps.tsx +++ b/src/app/Login/types.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export interface FormProps { - onSubmit(evt: Event, token: string, authMethod: string, rememberMe: boolean): void; + onSubmit: (evt: Event, token: string, authMethod: string, rememberMe: boolean) => void; } diff --git a/src/app/Modal/CancelUploadModal.tsx b/src/app/Modal/CancelUploadModal.tsx index 82c7bf551..0d609a70a 100644 --- a/src/app/Modal/CancelUploadModal.tsx +++ b/src/app/Modal/CancelUploadModal.tsx @@ -26,25 +26,25 @@ export interface CancelUploadModalProps { message: string; } -export const CancelUploadModal: React.FC<CancelUploadModalProps> = (props) => { +export const CancelUploadModal: React.FC<CancelUploadModalProps> = ({ visible, onYes, onNo, title, message }) => { return ( <Modal appendTo={portalRoot} width={'40%'} - isOpen={props.visible} + isOpen={visible} showClose={true} - onClose={props.onNo} - title={props.title} + onClose={onNo} + title={title} actions={[ - <Button key={'Yes'} variant="primary" onClick={props.onYes}> + <Button key={'Yes'} variant="primary" onClick={onYes}> Yes </Button>, - <Button key={'No'} variant="secondary" onClick={props.onNo}> + <Button key={'No'} variant="secondary" onClick={onNo}> No </Button>, ]} > - {props.message} + {message} </Modal> ); }; diff --git a/src/app/Modal/DeleteWarningModal.tsx b/src/app/Modal/DeleteWarningModal.tsx index 4878c9fe6..9990ce6b1 100644 --- a/src/app/Modal/DeleteWarningModal.tsx +++ b/src/app/Modal/DeleteWarningModal.tsx @@ -18,7 +18,8 @@ import { portalRoot } from '@app/utils/utils'; import { Modal, ModalVariant, Button, Checkbox, Stack, Split } from '@patternfly/react-core'; import * as React from 'react'; import { useState } from 'react'; -import { DeleteOrDisableWarningType, getFromWarningMap } from './DeleteWarningUtils'; +import { DeleteOrDisableWarningType } from './types'; +import { getFromWarningMap } from './utils'; export interface DeleteWarningProps { warningType: DeleteOrDisableWarningType; @@ -32,12 +33,13 @@ export const DeleteWarningModal: React.FC<DeleteWarningProps> = ({ onAccept, onClose, acceptButtonText = 'Delete', - ...props + warningType, + visible, }) => { const context = React.useContext(ServiceContext); const [doNotAsk, setDoNotAsk] = useState(false); - const realWarningType = getFromWarningMap(props.warningType); + const realWarningType = getFromWarningMap(warningType); const onAcceptClose = React.useCallback( (ev: React.MouseEvent) => { @@ -67,7 +69,7 @@ export const DeleteWarningModal: React.FC<DeleteWarningProps> = ({ aria-label={realWarningType?.ariaLabel} titleIconVariant="warning" variant={ModalVariant.medium} - isOpen={props.visible} + isOpen={visible} showClose onClose={onInnerClose} actions={[ diff --git a/src/app/Modal/DeleteWarningUtils.tsx b/src/app/Modal/types.ts similarity index 96% rename from src/app/Modal/DeleteWarningUtils.tsx rename to src/app/Modal/types.ts index 4c1884143..52f997f47 100644 --- a/src/app/Modal/DeleteWarningUtils.tsx +++ b/src/app/Modal/types.ts @@ -146,8 +146,3 @@ export const DeleteWarningKinds: DeleteOrDisableWarning[] = [ DeleteLayoutTemplate, ClearDashboardLayout, ]; - -export const getFromWarningMap = (warning: DeleteOrDisableWarningType): DeleteOrDisableWarning | undefined => { - const wt = DeleteWarningKinds.find((t) => t.id === warning); - return wt; -}; diff --git a/src/app/Modal/utils.ts b/src/app/Modal/utils.ts new file mode 100644 index 000000000..894344d11 --- /dev/null +++ b/src/app/Modal/utils.ts @@ -0,0 +1,19 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { DeleteOrDisableWarningType, DeleteOrDisableWarning, DeleteWarningKinds } from './types'; + +export const getFromWarningMap = (warning: DeleteOrDisableWarningType): DeleteOrDisableWarning | undefined => + DeleteWarningKinds.find((t) => t.id === warning); diff --git a/src/app/NotFound/NotFound.tsx b/src/app/NotFound/NotFound.tsx index d3c606e02..4f59224b5 100644 --- a/src/app/NotFound/NotFound.tsx +++ b/src/app/NotFound/NotFound.tsx @@ -15,9 +15,9 @@ */ import '@app/app.css'; import { IAppRoute, routes, flatten } from '@app/routes'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { Button, EmptyState, diff --git a/src/app/NotFound/NotFoundCard.tsx b/src/app/NotFound/NotFoundCard.tsx index 316c7ae4e..77d9722fb 100644 --- a/src/app/NotFound/NotFoundCard.tsx +++ b/src/app/NotFound/NotFoundCard.tsx @@ -18,7 +18,14 @@ import { Card, CardTitle, CardBody, CardFooter } from '@patternfly/react-core'; import * as React from 'react'; import { Link } from 'react-router-dom'; -export const NotFoundCard = ({ title, bodyText, linkText, linkPath }) => { +export interface NotFoundCardProps { + title: React.ReactNode; + bodyText: React.ReactNode; + linkText: React.ReactNode; + linkPath: string; +} + +export const NotFoundCard: React.FC<NotFoundCardProps> = ({ title, bodyText, linkText, linkPath }) => { return ( <> <Card className="pf-c-card-not-found"> diff --git a/src/app/QuickStarts/QuickStartDrawer.tsx b/src/app/QuickStarts/QuickStartDrawer.tsx index 2800d167c..e95cd42f0 100644 --- a/src/app/QuickStarts/QuickStartDrawer.tsx +++ b/src/app/QuickStarts/QuickStartDrawer.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ import build from '@app/build.json'; -import { LoadingView } from '@app/LoadingView/LoadingView'; import { allQuickStarts } from '@app/QuickStarts/all-quickstarts'; -import { SessionState } from '@app/Shared/Services/Login.service'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { SessionState } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useFeatureLevel } from '@app/utils/useFeatureLevel'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useFeatureLevel } from '@app/utils/hooks/useFeatureLevel'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { QuickStartContext, QuickStartDrawer, diff --git a/src/app/QuickStarts/README.md b/src/app/QuickStarts/README.md index 7404207ef..661e79ca6 100644 --- a/src/app/QuickStarts/README.md +++ b/src/app/QuickStarts/README.md @@ -1,4 +1,3 @@ -# Cryostat quick starts ## Adding quick starts <!--- TODO: Fix this section when quick starts are categorized diff --git a/src/app/QuickStarts/quickstarts/automated-rules-quickstart.tsx b/src/app/QuickStarts/quickstarts/automated-rules-quickstart.tsx index 8a6f8cdc3..8f772730d 100644 --- a/src/app/QuickStarts/quickstarts/automated-rules-quickstart.tsx +++ b/src/app/QuickStarts/quickstarts/automated-rules-quickstart.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { QuickStart } from '@patternfly/quickstarts'; import React from 'react'; import { CryostatIcon, conclusion } from '../quickstart-utils'; diff --git a/src/app/QuickStarts/quickstarts/dashboard-quickstart.tsx b/src/app/QuickStarts/quickstarts/dashboard-quickstart.tsx index 8c03d25f0..db92a155c 100644 --- a/src/app/QuickStarts/quickstarts/dashboard-quickstart.tsx +++ b/src/app/QuickStarts/quickstarts/dashboard-quickstart.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import build from '@app/build.json'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { QuickStart } from '@patternfly/quickstarts'; import React from 'react'; import { CryostatIcon, conclusion } from '../quickstart-utils'; diff --git a/src/app/QuickStarts/quickstarts/generic-quickstart.tsx b/src/app/QuickStarts/quickstarts/generic-quickstart.tsx index 4f79b24c3..cbb6118ca 100644 --- a/src/app/QuickStarts/quickstarts/generic-quickstart.tsx +++ b/src/app/QuickStarts/quickstarts/generic-quickstart.tsx @@ -15,7 +15,7 @@ */ import cryostatLogo from '@app/assets/cryostat_icon_rgb_default.svg'; import build from '@app/build.json'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { QuickStart } from '@patternfly/quickstarts'; import { PficonTemplateIcon } from '@patternfly/react-icons'; import React from 'react'; diff --git a/src/app/QuickStarts/quickstarts/settings-quickstart.tsx b/src/app/QuickStarts/quickstarts/settings-quickstart.tsx index 138d1ee74..b198a1241 100644 --- a/src/app/QuickStarts/quickstarts/settings-quickstart.tsx +++ b/src/app/QuickStarts/quickstarts/settings-quickstart.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { QuickStart } from '@patternfly/quickstarts'; import { CogIcon } from '@patternfly/react-icons'; import React from 'react'; diff --git a/src/app/QuickStarts/quickstarts/start-a-recording.tsx b/src/app/QuickStarts/quickstarts/start-a-recording.tsx index cacd4e4ee..1b39aa016 100644 --- a/src/app/QuickStarts/quickstarts/start-a-recording.tsx +++ b/src/app/QuickStarts/quickstarts/start-a-recording.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import build from '@app/build.json'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { QuickStart } from '@patternfly/quickstarts'; import React from 'react'; import { CryostatIcon, conclusion } from '../quickstart-utils'; diff --git a/src/app/QuickStarts/quickstarts/topology/custom-target-quickstart.tsx b/src/app/QuickStarts/quickstarts/topology/custom-target-quickstart.tsx index 21c9e74ef..d07d2c668 100644 --- a/src/app/QuickStarts/quickstarts/topology/custom-target-quickstart.tsx +++ b/src/app/QuickStarts/quickstarts/topology/custom-target-quickstart.tsx @@ -15,7 +15,7 @@ */ import { conclusion, CryostatIcon } from '@app/QuickStarts/quickstart-utils'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { QuickStart } from '@patternfly/quickstarts'; import * as React from 'react'; diff --git a/src/app/QuickStarts/quickstarts/topology/group-start-recordings.tsx b/src/app/QuickStarts/quickstarts/topology/group-start-recordings.tsx index b7327da85..c22ab1746 100644 --- a/src/app/QuickStarts/quickstarts/topology/group-start-recordings.tsx +++ b/src/app/QuickStarts/quickstarts/topology/group-start-recordings.tsx @@ -15,7 +15,7 @@ */ import { conclusion, CryostatIcon } from '@app/QuickStarts/quickstart-utils'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { QuickStart } from '@patternfly/quickstarts'; import * as React from 'react'; diff --git a/src/app/RecordingMetadata/BulkEditLabels.tsx b/src/app/RecordingMetadata/BulkEditLabels.tsx index 76d17a22f..d4558dd8d 100644 --- a/src/app/RecordingMetadata/BulkEditLabels.tsx +++ b/src/app/RecordingMetadata/BulkEditLabels.tsx @@ -15,25 +15,26 @@ */ import { uploadAsTarget } from '@app/Archives/Archives'; import { LabelCell } from '@app/RecordingMetadata/LabelCell'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; +import { LoadingProps } from '@app/Shared/Components/types'; import { - ActiveRecording, + RecordingDirectory, ArchivedRecording, Recording, - RecordingDirectory, + ActiveRecording, UPLOADS_SUBDIRECTORY, -} from '@app/Shared/Services/Api.service'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; + NotificationCategory, + Target, +} from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NO_TARGET } from '@app/Shared/Services/Target.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { hashCode, portalRoot } from '@app/utils/utils'; import { Button, Split, SplitItem, Stack, StackItem, Text, Tooltip, ValidatedOptions } from '@patternfly/react-core'; import { HelpIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { combineLatest, concatMap, filter, first, forkJoin, map, Observable, of } from 'rxjs'; -import { includesLabel, parseLabels, RecordingLabel } from './RecordingLabel'; import { RecordingLabelFields } from './RecordingLabelFields'; +import { RecordingLabel } from './types'; +import { includesLabel, parseLabels } from './utils'; export interface BulkEditLabelsProps { isTargetRecording: boolean; @@ -43,7 +44,13 @@ export interface BulkEditLabelsProps { directoryRecordings?: ArchivedRecording[]; } -export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { +export const BulkEditLabels: React.FC<BulkEditLabelsProps> = ({ + isTargetRecording, + isUploadsTable, + checkedIndices, + directory, + directoryRecordings, +}) => { const context = React.useContext(ServiceContext); const [recordings, setRecordings] = React.useState([] as Recording[]); const [editing, setEditing] = React.useState(false); @@ -54,8 +61,8 @@ export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { const addSubscription = useSubscriptions(); const getIdxFromRecording = React.useCallback( - (r: Recording): number => (props.isTargetRecording ? (r as ActiveRecording).id : hashCode(r.name)), - [props.isTargetRecording], + (r: Recording): number => (isTargetRecording ? (r as ActiveRecording).id : hashCode(r.name)), + [isTargetRecording], ); const handlePostUpdate = React.useCallback(() => { @@ -70,19 +77,17 @@ export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { recordings.forEach((r: Recording) => { const idx = getIdxFromRecording(r); - if (props.checkedIndices.includes(idx)) { + if (checkedIndices.includes(idx)) { let updatedLabels = [...parseLabels(r.metadata.labels), ...commonLabels]; updatedLabels = updatedLabels.filter((label) => { return !includesLabel(toDelete, label); }); - if (props.directory) { - tasks.push( - context.api.postRecordingMetadataFromPath(props.directory.jvmId, r.name, updatedLabels).pipe(first()), - ); + if (directory) { + tasks.push(context.api.postRecordingMetadataFromPath(directory.jvmId, r.name, updatedLabels).pipe(first())); } - if (props.isTargetRecording) { + if (isTargetRecording) { tasks.push(context.api.postTargetRecordingMetadata(r.name, updatedLabels).pipe(first())); - } else if (props.isUploadsTable) { + } else if (isUploadsTable) { tasks.push(context.api.postUploadedRecordingMetadata(r.name, updatedLabels).pipe(first())); } else { tasks.push(context.api.postRecordingMetadata(r.name, updatedLabels).pipe(first())); @@ -103,10 +108,10 @@ export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { commonLabels, savedCommonLabels, recordings, - props.checkedIndices, - props.isTargetRecording, - props.isUploadsTable, - props.directory, + checkedIndices, + isTargetRecording, + isUploadsTable, + directory, ]); const handleEditLabels = React.useCallback(() => { @@ -124,7 +129,7 @@ export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { recordings.forEach((r: Recording) => { const idx = getIdxFromRecording(r); - if (props.checkedIndices.includes(idx)) { + if (checkedIndices.includes(idx)) { allRecordingLabels.push(parseLabels(r.metadata.labels)); } }); @@ -139,24 +144,24 @@ export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { setLabels(updatedCommonLabels); }, - [recordings, getIdxFromRecording, props.checkedIndices], + [recordings, getIdxFromRecording, checkedIndices], ); /* eslint-disable @typescript-eslint/no-explicit-any */ const refreshRecordingList = React.useCallback(() => { let observable: Observable<Recording[]>; - if (props.directoryRecordings) { - observable = of(props.directoryRecordings); - } else if (props.isTargetRecording) { + if (directoryRecordings) { + observable = of(directoryRecordings); + } else if (isTargetRecording) { observable = context.target.target().pipe( - filter((target) => target !== NO_TARGET), - concatMap((target) => + filter((target) => !!target), + concatMap((target: Target) => context.api.doGet<ActiveRecording[]>(`targets/${encodeURIComponent(target.connectUrl)}/recordings`), ), first(), ); } else { - observable = props.isUploadsTable + observable = isUploadsTable ? context.api .graphql<any>( `query GetUploadedRecordings($filter: ArchivedRecordingFilterInput) { @@ -178,8 +183,8 @@ export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { first(), ) : context.target.target().pipe( - filter((target) => target !== NO_TARGET), - concatMap((target) => + filter((target) => !!target), + concatMap((target: Target) => context.api.graphql<any>( `query ArchivedRecordingsForTarget($connectUrl: String) { targetNodes(filter: { name: $connectUrl }) { @@ -208,9 +213,9 @@ export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { addSubscription(observable.subscribe((value) => setRecordings(value))); }, [ addSubscription, - props.isTargetRecording, - props.isUploadsTable, - props.directoryRecordings, + isTargetRecording, + isUploadsTable, + directoryRecordings, context.target, context.api, setRecordings, @@ -223,7 +228,7 @@ export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { spinnerAriaValueText: 'Saving', spinnerAriaLabel: 'saving-recording-labels', isLoading: loading, - }) as LoadingPropsType, + }) as LoadingProps, [loading], ); @@ -236,12 +241,12 @@ export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { React.useEffect(() => { addSubscription( combineLatest([ - props.isUploadsTable ? of(uploadAsTarget) : context.target.target(), + isUploadsTable ? of(uploadAsTarget) : context.target.target(), context.notificationChannel.messages(NotificationCategory.RecordingMetadataUpdated), ]).subscribe((parts) => { const currentTarget = parts[0]; const event = parts[1]; - if (currentTarget.connectUrl != event.message.target) { + if (currentTarget?.connectUrl != event.message.target) { return; } setRecordings((old) => @@ -251,7 +256,7 @@ export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { ); }), ); - }, [addSubscription, context.target, context.notificationChannel, setRecordings, props.isUploadsTable]); + }, [addSubscription, context.target, context.notificationChannel, setRecordings, isUploadsTable]); React.useEffect(() => { updateCommonLabels(setCommonLabels); @@ -263,10 +268,10 @@ export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { }, [editing, recordings, setCommonLabels, setSavedCommonLabels, updateCommonLabels, setEditing]); React.useEffect(() => { - if (!props.checkedIndices.length) { + if (!checkedIndices.length) { setEditing(false); } - }, [props.checkedIndices, setEditing]); + }, [checkedIndices, setEditing]); return ( <> @@ -327,7 +332,7 @@ export const BulkEditLabels: React.FC<BulkEditLabelsProps> = (props) => { aria-label="Edit Labels" variant="secondary" onClick={handleEditLabels} - isDisabled={!props.checkedIndices.length} + isDisabled={!checkedIndices.length} > Edit </Button> diff --git a/src/app/RecordingMetadata/ClickableLabel.tsx b/src/app/RecordingMetadata/ClickableLabel.tsx index a5e3bd5a6..87d07cd88 100644 --- a/src/app/RecordingMetadata/ClickableLabel.tsx +++ b/src/app/RecordingMetadata/ClickableLabel.tsx @@ -16,7 +16,7 @@ import { Label } from '@patternfly/react-core'; import React from 'react'; -import { RecordingLabel } from './RecordingLabel'; +import { RecordingLabel } from './types'; export interface ClickableLabelCellProps { label: RecordingLabel; @@ -24,9 +24,9 @@ export interface ClickableLabelCellProps { onLabelClick: (label: RecordingLabel) => void; } -export const ClickableLabel: React.FC<ClickableLabelCellProps> = ({ onLabelClick, ...props }) => { +export const ClickableLabel: React.FC<ClickableLabelCellProps> = ({ label, isSelected, onLabelClick }) => { const [isHoveredOrFocused, setIsHoveredOrFocused] = React.useState(false); - const labelColor = React.useMemo(() => (props.isSelected ? 'blue' : 'grey'), [props.isSelected]); + const labelColor = React.useMemo(() => (isSelected ? 'blue' : 'grey'), [isSelected]); const handleHoveredOrFocused = React.useCallback(() => setIsHoveredOrFocused(true), [setIsHoveredOrFocused]); const handleNonHoveredOrFocused = React.useCallback(() => setIsHoveredOrFocused(false), [setIsHoveredOrFocused]); @@ -34,29 +34,29 @@ export const ClickableLabel: React.FC<ClickableLabelCellProps> = ({ onLabelClick const style = React.useMemo(() => { if (isHoveredOrFocused) { const defaultStyle = { cursor: 'pointer', '--pf-c-label__content--before--BorderWidth': '2.5px' }; - if (props.isSelected) { + if (isSelected) { return { ...defaultStyle, '--pf-c-label__content--before--BorderColor': '#06c' }; } return { ...defaultStyle, '--pf-c-label__content--before--BorderColor': '#8a8d90' }; } return {}; - }, [props.isSelected, isHoveredOrFocused]); + }, [isSelected, isHoveredOrFocused]); - const handleLabelClicked = React.useCallback(() => onLabelClick(props.label), [props.label, onLabelClick]); + const handleLabelClicked = React.useCallback(() => onLabelClick(label), [label, onLabelClick]); return ( <> <Label - aria-label={`${props.label.key}: ${props.label.value}`} + aria-label={`${label.key}: ${label.value}`} style={style} onMouseEnter={handleHoveredOrFocused} onMouseLeave={handleNonHoveredOrFocused} onFocus={handleHoveredOrFocused} onClick={handleLabelClicked} - key={props.label.key} + key={label.key} color={labelColor} > - {`${props.label.key}: ${props.label.value}`} + {`${label.key}: ${label.value}`} </Label> </> ); diff --git a/src/app/RecordingMetadata/LabelCell.tsx b/src/app/RecordingMetadata/LabelCell.tsx index 7d1383548..6116bf648 100644 --- a/src/app/RecordingMetadata/LabelCell.tsx +++ b/src/app/RecordingMetadata/LabelCell.tsx @@ -14,33 +14,33 @@ * limitations under the License. */ -import { getLabelDisplay } from '@app/Recordings/Filters/LabelFilter'; import { UpdateFilterOptions } from '@app/Shared/Redux/Filters/Common'; import { Label, Text } from '@patternfly/react-core'; import React from 'react'; import { ClickableLabel } from './ClickableLabel'; -import { RecordingLabel } from './RecordingLabel'; +import { RecordingLabel } from './types'; +import { getLabelDisplay } from './utils'; export interface LabelCellProps { target: string; labels: RecordingLabel[]; + // If undefined, labels are not clickable (i.e. display only) and only displayed in grey. clickableOptions?: { - // If undefined, labels are not clickable (i.e. display only) and only displayed in grey. labelFilters: string[]; updateFilters: (target: string, updateFilterOptions: UpdateFilterOptions) => void; }; } -export const LabelCell: React.FC<LabelCellProps> = (props) => { +export const LabelCell: React.FC<LabelCellProps> = ({ target, labels, clickableOptions }) => { const isLabelSelected = React.useCallback( (label: RecordingLabel) => { - if (props.clickableOptions) { - const labelFilterSet = new Set(props.clickableOptions.labelFilters); + if (clickableOptions) { + const labelFilterSet = new Set(clickableOptions.labelFilters); return labelFilterSet.has(getLabelDisplay(label)); } return false; }, - [props.clickableOptions], + [clickableOptions], ); const getLabelColor = React.useCallback( @@ -49,22 +49,22 @@ export const LabelCell: React.FC<LabelCellProps> = (props) => { ); const onLabelSelectToggle = React.useCallback( (clickedLabel: RecordingLabel) => { - if (props.clickableOptions) { - props.clickableOptions.updateFilters(props.target, { + if (clickableOptions) { + clickableOptions.updateFilters(target, { filterKey: 'Label', filterValue: getLabelDisplay(clickedLabel), deleted: isLabelSelected(clickedLabel), }); } }, - [isLabelSelected, props.clickableOptions, props.target], + [isLabelSelected, clickableOptions, target], ); return ( <> - {!!props.labels && props.labels.length ? ( - props.labels.map((label) => - props.clickableOptions ? ( + {!!labels && labels.length ? ( + labels.map((label) => + clickableOptions ? ( <ClickableLabel key={label.key} label={label} diff --git a/src/app/RecordingMetadata/RecordingLabelFields.tsx b/src/app/RecordingMetadata/RecordingLabelFields.tsx index 9d3b2e0f4..f891bb238 100644 --- a/src/app/RecordingMetadata/RecordingLabelFields.tsx +++ b/src/app/RecordingMetadata/RecordingLabelFields.tsx @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LoadingView } from '@app/LoadingView/LoadingView'; -import { parseLabelsFromFile, RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { portalRoot } from '@app/utils/utils'; import { Button, @@ -35,6 +34,8 @@ import { CloseIcon, ExclamationCircleIcon, FileIcon, PlusCircleIcon, UploadIcon import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { catchError, Observable, of, zip } from 'rxjs'; +import { RecordingLabel } from './types'; +import { matchesLabelSyntax, getValidatedOption, LabelPattern, parseLabelsFromFile } from './utils'; export interface RecordingLabelFieldsProps { labels: RecordingLabel[]; @@ -44,17 +45,13 @@ export interface RecordingLabelFieldsProps { isDisabled?: boolean; } -export const LabelPattern = /^\S+$/; - -const getValidatedOption = (isValid: boolean) => { - return isValid ? ValidatedOptions.success : ValidatedOptions.error; -}; - -const matchesLabelSyntax = (l: RecordingLabel) => { - return l && LabelPattern.test(l.key) && LabelPattern.test(l.value); -}; - -export const RecordingLabelFields: React.FC<RecordingLabelFieldsProps> = ({ setLabels, setValid, ...props }) => { +export const RecordingLabelFields: React.FC<RecordingLabelFieldsProps> = ({ + labels, + setLabels, + setValid, + isUploadable, + isDisabled, +}) => { const inputRef = React.useRef<HTMLInputElement>(null); // Use ref to refer to child component const addSubscription = useSubscriptions(); const { t } = useTranslation(); @@ -63,83 +60,76 @@ export const RecordingLabelFields: React.FC<RecordingLabelFieldsProps> = ({ setL const [invalidUploads, setInvalidUploads] = React.useState<string[]>([]); const handleKeyChange = React.useCallback( - (idx, key) => { - const updatedLabels = [...props.labels]; + (idx: number, key: string) => { + const updatedLabels = [...labels]; updatedLabels[idx].key = key; setLabels(updatedLabels); }, - [props.labels, setLabels], + [labels, setLabels], ); const handleValueChange = React.useCallback( - (idx, value) => { - const updatedLabels = [...props.labels]; + (idx: number, value: string) => { + const updatedLabels = [...labels]; updatedLabels[idx].value = value; setLabels(updatedLabels); }, - [props.labels, setLabels], + [labels, setLabels], ); const handleAddLabelButtonClick = React.useCallback(() => { - setLabels([...props.labels, { key: '', value: '' } as RecordingLabel]); - }, [props.labels, setLabels]); + setLabels([...labels, { key: '', value: '' } as RecordingLabel]); + }, [labels, setLabels]); const handleDeleteLabelButtonClick = React.useCallback( - (idx) => { - const updated = [...props.labels]; + (idx: number) => { + const updated = [...labels]; updated.splice(idx, 1); setLabels(updated); }, - [props.labels, setLabels], + [labels, setLabels], ); - const isLabelValid = React.useCallback(matchesLabelSyntax, [matchesLabelSyntax]); - - const isDuplicateKey = React.useCallback((key: string, labels: RecordingLabel[]) => { - return labels.filter((label) => label.key === key).length > 1; - }, []); - - const allLabelsValid = React.useMemo(() => { - if (!props.labels.length) { - return true; - } - return props.labels.reduce( - (prev, curr) => isLabelValid(curr) && !isDuplicateKey(curr.key, props.labels) && prev, - true, - ); - }, [props.labels, isLabelValid, isDuplicateKey]); + const isDuplicateKey = React.useCallback( + (key: string, labels: RecordingLabel[]) => labels.filter((label) => label.key === key).length > 1, + [], + ); const validKeys = React.useMemo(() => { - const arr = Array(props.labels.length).fill(ValidatedOptions.default); - props.labels.forEach((label, index) => { + const arr = Array(labels.length).fill(ValidatedOptions.default); + labels.forEach((label, index) => { if (label.key.length > 0) { - arr[index] = getValidatedOption(LabelPattern.test(label.key) && !isDuplicateKey(label.key, props.labels)); + arr[index] = getValidatedOption(LabelPattern.test(label.key) && !isDuplicateKey(label.key, labels)); } // Ignore initial empty key inputs }); return arr; - }, [props.labels, isDuplicateKey]); + }, [labels, isDuplicateKey]); const validValues = React.useMemo(() => { - const arr = Array(props.labels.length).fill(ValidatedOptions.default); - props.labels.forEach((label, index) => { + const arr = Array(labels.length).fill(ValidatedOptions.default); + labels.forEach((label, index) => { if (label.value.length > 0) { arr[index] = getValidatedOption(LabelPattern.test(label.value)); } // Ignore initial empty value inputs }); return arr; - }, [props.labels]); + }, [labels]); React.useEffect(() => { - setValid(getValidatedOption(allLabelsValid)); - }, [setValid, allLabelsValid]); + const valid = labels.reduce( + (prev, curr) => matchesLabelSyntax(curr) && !isDuplicateKey(curr.key, labels) && prev, + true, + ); + setValid(getValidatedOption(valid)); + }, [setValid, labels, isDuplicateKey]); const handleUploadLabel = React.useCallback( - (e) => { + (e: React.ChangeEvent<HTMLInputElement>) => { const files = e.target.files; if (files && files.length) { const tasks: Observable<RecordingLabel[]>[] = []; setLoading(true); - for (const labelFile of e.target.files) { + for (const labelFile of Array.from(files)) { tasks.push( parseLabelsFromFile(labelFile).pipe( catchError((_) => { @@ -152,13 +142,13 @@ export const RecordingLabelFields: React.FC<RecordingLabelFieldsProps> = ({ setL addSubscription( zip(tasks).subscribe((labelArrays: RecordingLabel[][]) => { setLoading(false); - const labels = labelArrays.reduce((acc, next) => acc.concat(next), []); - setLabels([...props.labels, ...labels]); + const newLabels = labelArrays.reduce((acc, next) => acc.concat(next), []); + setLabels([...labels, ...newLabels]); }), ); } }, - [setLabels, props.labels, addSubscription, setLoading], + [setLabels, addSubscription, setLoading, labels], ); const closeWarningPopover = React.useCallback(() => setInvalidUploads([]), [setInvalidUploads]); @@ -176,11 +166,11 @@ export const RecordingLabelFields: React.FC<RecordingLabelFieldsProps> = ({ setL onClick={handleAddLabelButtonClick} variant="link" icon={<PlusCircleIcon />} - isDisabled={props.isDisabled} + isDisabled={isDisabled} > Add Label </Button> - {props.isUploadable && ( + {isUploadable && ( <> <Popover appendTo={portalRoot} @@ -211,7 +201,7 @@ export const RecordingLabelFields: React.FC<RecordingLabelFieldsProps> = ({ setL onClick={openLabelFileBrowse} variant="link" icon={<UploadIcon />} - isDisabled={props.isDisabled} + isDisabled={isDisabled} > Upload Labels </Button> @@ -226,7 +216,7 @@ export const RecordingLabelFields: React.FC<RecordingLabelFieldsProps> = ({ setL /> </> )} - {props.labels.map((label, idx) => ( + {labels.map((label, idx) => ( <Split hasGutter key={idx}> <SplitItem isFilled> <TextInput @@ -239,7 +229,7 @@ export const RecordingLabelFields: React.FC<RecordingLabelFieldsProps> = ({ setL value={label.key ?? ''} onChange={(key) => handleKeyChange(idx, key)} validated={validKeys[idx]} - isDisabled={props.isDisabled} + isDisabled={isDisabled} /> <Text>Key</Text> <FormHelperText @@ -264,7 +254,7 @@ export const RecordingLabelFields: React.FC<RecordingLabelFieldsProps> = ({ setL value={label.value ?? ''} onChange={(value) => handleValueChange(idx, value)} validated={validValues[idx]} - isDisabled={props.isDisabled} + isDisabled={isDisabled} /> <Text>Value</Text> </SplitItem> @@ -273,7 +263,7 @@ export const RecordingLabelFields: React.FC<RecordingLabelFieldsProps> = ({ setL onClick={() => handleDeleteLabelButtonClick(idx)} variant="link" aria-label="Remove Label" - isDisabled={props.isDisabled} + isDisabled={isDisabled} icon={<CloseIcon color="gray" size="sm" />} /> </SplitItem> diff --git a/src/app/RecordingMetadata/types.tsx b/src/app/RecordingMetadata/types.tsx new file mode 100644 index 000000000..810222ff1 --- /dev/null +++ b/src/app/RecordingMetadata/types.tsx @@ -0,0 +1,20 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface RecordingLabel { + key: string; + value: string; +} diff --git a/src/app/RecordingMetadata/RecordingLabel.tsx b/src/app/RecordingMetadata/utils.ts similarity index 68% rename from src/app/RecordingMetadata/RecordingLabel.tsx rename to src/app/RecordingMetadata/utils.ts index e3f59b754..a5b8c3b17 100644 --- a/src/app/RecordingMetadata/RecordingLabel.tsx +++ b/src/app/RecordingMetadata/utils.ts @@ -14,14 +14,11 @@ * limitations under the License. */ -import { from, Observable } from 'rxjs'; +import { ValidatedOptions } from '@patternfly/react-core'; +import { Observable, from } from 'rxjs'; +import { RecordingLabel } from './types'; -export interface RecordingLabel { - key: string; - value: string; -} - -export const parseLabels = (jsonLabels: object) => { +export const parseLabels = (jsonLabels?: { [key: string]: string }) => { if (!jsonLabels) return []; return Object.entries(jsonLabels).map(([k, v]) => { @@ -29,6 +26,14 @@ export const parseLabels = (jsonLabels: object) => { }); }; +export const isEqualLabel = (a: RecordingLabel, b: RecordingLabel) => { + return a.key === b.key && a.value === b.value; +}; + +export const includesLabel = (arr: RecordingLabel[], searchLabel: RecordingLabel) => { + return arr.some((l) => isEqualLabel(searchLabel, l)); +}; + export const parseLabelsFromFile = (file: File): Observable<RecordingLabel[]> => { return from( file @@ -51,10 +56,14 @@ export const parseLabelsFromFile = (file: File): Observable<RecordingLabel[]> => ); }; -export const includesLabel = (arr: RecordingLabel[], searchLabel: RecordingLabel) => { - return arr.some((l) => isEqualLabel(searchLabel, l)); +export const getLabelDisplay = (label: RecordingLabel) => `${label.key}:${label.value}`; + +export const LabelPattern = /^\S+$/; + +export const getValidatedOption = (isValid: boolean) => { + return isValid ? ValidatedOptions.success : ValidatedOptions.error; }; -const isEqualLabel = (a: RecordingLabel, b: RecordingLabel) => { - return a.key === b.key && a.value === b.value; +export const matchesLabelSyntax = (l: RecordingLabel) => { + return l && LabelPattern.test(l.key) && LabelPattern.test(l.value); }; diff --git a/src/app/Recordings/ActiveRecordingsTable.tsx b/src/app/Recordings/ActiveRecordingsTable.tsx index 1ffa62541..b1d4c861c 100644 --- a/src/app/Recordings/ActiveRecordingsTable.tsx +++ b/src/app/Recordings/ActiveRecordingsTable.tsx @@ -14,10 +14,14 @@ * limitations under the License. */ -import { authFailMessage } from '@app/ErrorView/ErrorView'; -import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; -import { parseLabels } from '@app/RecordingMetadata/RecordingLabel'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; +import { + ClickableAutomatedAnalysisLabel, + clickableAutomatedAnalysisKey, +} from '@app/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel'; +import { authFailMessage } from '@app/ErrorView/types'; +import { DeleteOrDisableWarningType } from '@app/Modal/types'; +import { parseLabels } from '@app/RecordingMetadata/utils'; +import { LoadingProps } from '@app/Shared/Components/types'; import { UpdateFilterOptions } from '@app/Shared/Redux/Filters/Common'; import { emptyActiveRecordingFilters, TargetRecordingFilters } from '@app/Shared/Redux/Filters/RecordingFilterSlice'; import { @@ -29,13 +33,18 @@ import { RootState, StateDispatch, } from '@app/Shared/Redux/ReduxStore'; -import { ActiveRecording, RecordingState } from '@app/Shared/Services/Api.service'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { + ActiveRecording, + AnalysisResult, + CategorizedRuleEvaluations, + NotificationCategory, + RecordingState, + Target, +} from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NO_TARGET } from '@app/Shared/Services/Target.service'; -import { useDayjs } from '@app/utils/useDayjs'; -import { useSort } from '@app/utils/useSort'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useDayjs } from '@app/utils/hooks/useDayjs'; +import { useSort } from '@app/utils/hooks/useSort'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { formatBytes, sortResources, TableColumn } from '@app/utils/utils'; import { Bullseye, @@ -57,9 +66,6 @@ import { OverflowMenuGroup, OverflowMenuItem, Spinner, - Stack, - StackItem, - Text, Timestamp, TimestampTooltipVariant, Title, @@ -68,6 +74,7 @@ import { ToolbarGroup, ToolbarItem, } from '@patternfly/react-core'; +import { RedoIcon } from '@patternfly/react-icons'; import { ExpandableRowContent, SortByDirection, Tbody, Td, Tr } from '@patternfly/react-table'; import * as React from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -80,12 +87,6 @@ import { RecordingActions } from './RecordingActions'; import { filterRecordings, RecordingFilters, RecordingFiltersCategories } from './RecordingFilters'; import { RecordingLabelsPanel } from './RecordingLabelsPanel'; import { ColumnConfig, RecordingsTable } from './RecordingsTable'; -import { AnalysisResult, CategorizedRuleEvaluations } from '@app/Shared/Services/Report.service'; -import { - clickableAutomatedAnalysisKey, - ClickableAutomatedAnalysisLabel, -} from '@app/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel'; -import { RedoIcon } from '@patternfly/react-icons'; export enum PanelContent { LABELS, @@ -218,8 +219,8 @@ export const ActiveRecordingsTable: React.FC<ActiveRecordingsTableProps> = (prop context.target .target() .pipe( - filter((target) => target !== NO_TARGET), - concatMap((target) => + filter((target) => !!target), + concatMap((target: Target) => context.api.doGet<ActiveRecording[]>(`targets/${encodeURIComponent(target.connectUrl)}/recordings`), ), first(), @@ -234,8 +235,8 @@ export const ActiveRecordingsTable: React.FC<ActiveRecordingsTableProps> = (prop React.useEffect(() => { addSubscription( context.target.target().subscribe((target) => { - setTargetConnectURL(target.connectUrl); - dispatch(recordingAddTargetIntent(target.connectUrl)); + setTargetConnectURL(target?.connectUrl || ''); + dispatch(recordingAddTargetIntent(target?.connectUrl || '')); refreshRecordingList(); }), ); @@ -250,7 +251,7 @@ export const ActiveRecordingsTable: React.FC<ActiveRecordingsTableProps> = (prop context.notificationChannel.messages(NotificationCategory.SnapshotCreated), ), ]).subscribe(([currentTarget, event]) => { - if (currentTarget.connectUrl != event.message.target) { + if (currentTarget?.connectUrl != event.message.target) { return; } setRecordings((old) => old.concat([event.message.recording])); @@ -267,7 +268,7 @@ export const ActiveRecordingsTable: React.FC<ActiveRecordingsTableProps> = (prop context.notificationChannel.messages(NotificationCategory.SnapshotDeleted), ), ]).subscribe(([currentTarget, event]) => { - if (currentTarget.connectUrl != event.message.target) { + if (currentTarget?.connectUrl != event.message.target) { return; } @@ -283,7 +284,7 @@ export const ActiveRecordingsTable: React.FC<ActiveRecordingsTableProps> = (prop context.target.target(), context.notificationChannel.messages(NotificationCategory.ActiveRecordingStopped), ]).subscribe(([currentTarget, event]) => { - if (currentTarget.connectUrl != event.message.target) { + if (currentTarget?.connectUrl != event.message.target) { return; } setRecordings((old) => { @@ -314,7 +315,7 @@ export const ActiveRecordingsTable: React.FC<ActiveRecordingsTableProps> = (prop context.target.target(), context.notificationChannel.messages(NotificationCategory.RecordingMetadataUpdated), ]).subscribe(([currentTarget, event]) => { - if (currentTarget.connectUrl != event.message.target) { + if (currentTarget?.connectUrl != event.message.target) { return; } setRecordings((old) => @@ -560,8 +561,8 @@ export const ActiveRecordingsTable: React.FC<ActiveRecordingsTableProps> = (prop > {filteredRecordings.map((r) => ( <ActiveRecordingRow - targetConnectUrl={targetConnectURL} key={r.name} + targetConnectUrl={targetConnectURL} recording={r} labelFilters={targetRecordingFilters.Label} index={r.id} @@ -628,7 +629,7 @@ const ActiveRecordingsToolbar: React.FC<ActiveRecordingsToolbarProps> = (props) return !anyRunning; }, [props.actionLoadings, props.checkedIndices, props.filteredRecordings]); - const actionLoadingProps = React.useMemo<Record<ActiveActions, LoadingPropsType>>( + const actionLoadingProps = React.useMemo<Record<ActiveActions, LoadingProps>>( () => ({ ARCHIVE: { spinnerAriaValueText: 'Archiving', @@ -850,6 +851,8 @@ export const ActiveRecordingRow: React.FC<ActiveRecordingRowProps> = ({ }) => { const [dayjs, datetimeContext] = useDayjs(); const context = React.useContext(ServiceContext); + const [loadingAnalysis, setLoadingAnalysis] = React.useState(false); + const [analyses, setAnalyses] = React.useState<CategorizedRuleEvaluations[]>([]); const expandedRowId = React.useMemo( () => `active-table-row-${recording.name}-${recording.startTime}-exp`, @@ -873,9 +876,6 @@ export const ActiveRecordingRow: React.FC<ActiveRecordingRowProps> = ({ [index, handleRowCheck], ); - const [loadingAnalysis, setLoadingAnalysis] = React.useState(false); - const [analyses, setAnalyses] = React.useState<CategorizedRuleEvaluations[]>([]); - const handleLoadAnalysis = React.useCallback(() => { setLoadingAnalysis(true); context.reports @@ -953,7 +953,7 @@ export const ActiveRecordingRow: React.FC<ActiveRecordingRowProps> = ({ {recording.state} </Td> <Td key={`active-table-row-${index}_6`} dataLabel={tableColumns[4].title}> - <LabelGroup isVertical> + <LabelGroup isVertical style={{ padding: '0.2em' }}> <Label color="blue" key="toDisk"> toDisk: {String(recording.toDisk)} </Label> @@ -1036,7 +1036,7 @@ export const ActiveRecordingRow: React.FC<ActiveRecordingRowProps> = ({ > {evaluations.map((evaluation) => { return ( - <ClickableAutomatedAnalysisLabel label={evaluation} key={clickableAutomatedAnalysisKey} /> + <ClickableAutomatedAnalysisLabel result={evaluation} key={clickableAutomatedAnalysisKey} /> ); })} </LabelGroup> diff --git a/src/app/Recordings/ArchivedRecordingsTable.tsx b/src/app/Recordings/ArchivedRecordingsTable.tsx index afdf45c88..8d53e16e6 100644 --- a/src/app/Recordings/ArchivedRecordingsTable.tsx +++ b/src/app/Recordings/ArchivedRecordingsTable.tsx @@ -15,10 +15,14 @@ */ import { ArchiveUploadModal } from '@app/Archives/ArchiveUploadModal'; +import { + ClickableAutomatedAnalysisLabel, + clickableAutomatedAnalysisKey, +} from '@app/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; -import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; -import { parseLabels } from '@app/RecordingMetadata/RecordingLabel'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; +import { DeleteOrDisableWarningType } from '@app/Modal/types'; +import { parseLabels } from '@app/RecordingMetadata/utils'; +import { LoadingProps } from '@app/Shared/Components/types'; import { UpdateFilterOptions } from '@app/Shared/Redux/Filters/Common'; import { emptyArchivedRecordingFilters, TargetRecordingFilters } from '@app/Shared/Redux/Filters/RecordingFilterSlice'; import { @@ -30,12 +34,19 @@ import { RootState, StateDispatch, } from '@app/Shared/Redux/ReduxStore'; -import { ArchivedRecording, RecordingDirectory, UPLOADS_SUBDIRECTORY } from '@app/Shared/Services/Api.service'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { + ArchivedRecording, + Target, + RecordingDirectory, + UPLOADS_SUBDIRECTORY, + NotificationCategory, + NullableTarget, + CategorizedRuleEvaluations, + AnalysisResult, +} from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NO_TARGET, Target } from '@app/Shared/Services/Target.service'; -import { useSort } from '@app/utils/useSort'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSort } from '@app/utils/hooks/useSort'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { formatBytes, hashCode, sortResources, TableColumn } from '@app/utils/utils'; import { Bullseye, @@ -73,11 +84,6 @@ import { RecordingActions } from './RecordingActions'; import { RecordingFiltersCategories, filterRecordings, RecordingFilters } from './RecordingFilters'; import { RecordingLabelsPanel } from './RecordingLabelsPanel'; import { ColumnConfig, RecordingsTable } from './RecordingsTable'; -import { AnalysisResult, CategorizedRuleEvaluations } from '@app/Shared/Services/Report.service'; -import { - clickableAutomatedAnalysisKey, - ClickableAutomatedAnalysisLabel, -} from '@app/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel'; const tableColumns: TableColumn[] = [ { @@ -100,7 +106,7 @@ const tableColumns: TableColumn[] = [ ]; export interface ArchivedRecordingsTableProps { - target: Observable<Target>; + target: Observable<NullableTarget>; isUploadsTable: boolean; isNestedTable: boolean; directory?: RecordingDirectory; @@ -242,9 +248,9 @@ export const ArchivedRecordingsTable: React.FC<ArchivedRecordingsTableProps> = ( addSubscription( propsTarget .pipe( - filter((target) => target !== NO_TARGET), + filter((target) => !!target), first(), - concatMap((target) => queryTargetRecordings(target.connectUrl)), + concatMap((target: Target) => queryTargetRecordings(target.connectUrl)), map((v) => v.data.archivedRecordings.data as ArchivedRecording[]), ) .subscribe({ @@ -288,8 +294,8 @@ export const ArchivedRecordingsTable: React.FC<ArchivedRecordingsTableProps> = ( React.useEffect(() => { addSubscription( propsTarget.subscribe((target) => { - setTargetConnectURL(target.connectUrl); - dispatch(recordingAddTargetIntent(target.connectUrl)); + setTargetConnectURL(target?.connectUrl || ''); + dispatch(recordingAddTargetIntent(target?.connectUrl || '')); refreshRecordingList(); }), ); @@ -304,7 +310,7 @@ export const ArchivedRecordingsTable: React.FC<ArchivedRecordingsTableProps> = ( context.notificationChannel.messages(NotificationCategory.ActiveRecordingSaved), ), ]).subscribe(([currentTarget, event]) => { - if (currentTarget.connectUrl != event.message.target) { + if (currentTarget?.connectUrl != event.message.target) { return; } setRecordings((old) => @@ -320,7 +326,7 @@ export const ArchivedRecordingsTable: React.FC<ArchivedRecordingsTableProps> = ( propsTarget, context.notificationChannel.messages(NotificationCategory.ArchivedRecordingDeleted), ]).subscribe(([currentTarget, event]) => { - if (currentTarget.connectUrl != event.message.target) { + if (currentTarget?.connectUrl != event.message.target) { return; } setRecordings((old) => old.filter((r) => r.name !== event.message.recording.name)); @@ -335,7 +341,7 @@ export const ArchivedRecordingsTable: React.FC<ArchivedRecordingsTableProps> = ( propsTarget, context.notificationChannel.messages(NotificationCategory.RecordingMetadataUpdated), ]).subscribe(([currentTarget, event]) => { - if (currentTarget.connectUrl != event.message.target) { + if (currentTarget?.connectUrl != event.message.target) { return; } setRecordings((old) => @@ -408,6 +414,9 @@ export const ArchivedRecordingsTable: React.FC<ArchivedRecordingsTableProps> = ( } else { addSubscription( propsTarget.subscribe((t) => { + if (!t) { + return; + } filteredRecordings.forEach((r: ArchivedRecording) => { if (checkedIndices.includes(hashCode(r.name))) { context.reports.delete(r); @@ -518,7 +527,7 @@ export const ArchivedRecordingsTable: React.FC<ArchivedRecordingsTableProps> = ( <DrawerContent panelContent={LabelsPanel} className="recordings-table-drawer-content"> <DrawerContentBody hasPadding> <RecordingsTable - tableTitle="Archived Flight Recordings" + tableTitle="Archived Recordings" toolbar={RecordingsToolbar} tableColumns={columnConfig} tableFooter={ @@ -617,13 +626,13 @@ const ArchivedRecordingsToolbar: React.FC<ArchivedRecordingsToolbarProps> = (pro ); }, [warningModalOpen, props.handleDeleteRecordings, handleWarningModalClose]); - const actionLoadingProps = React.useMemo<Record<ArchiveActions, LoadingPropsType>>( + const actionLoadingProps = React.useMemo<Record<ArchiveActions, LoadingProps>>( () => ({ DELETE: { spinnerAriaValueText: 'Deleting', spinnerAriaLabel: 'deleting-archived-recording', isLoading: props.actionLoadings['DELETE'], - } as LoadingPropsType, + } as LoadingProps, }), [props], ); @@ -737,7 +746,7 @@ export interface ArchivedRecordingRowProps { index: number; propsDirectory?: RecordingDirectory; currentSelectedTargetURL: string; - sourceTarget: Observable<Target>; + sourceTarget: Observable<NullableTarget>; expandedRows: string[]; checkedIndices: number[]; labelFilters: string[]; @@ -760,6 +769,8 @@ export const ArchivedRecordingRow: React.FC<ArchivedRecordingRowProps> = ({ updateFilters, }) => { const context = React.useContext(ServiceContext); + const [loadingAnalysis, setLoadingAnalysis] = React.useState(false); + const [analyses, setAnalyses] = React.useState<CategorizedRuleEvaluations[]>([]); const parsedLabels = React.useMemo(() => { return parseLabels(recording.metadata.labels); @@ -782,9 +793,6 @@ export const ArchivedRecordingRow: React.FC<ArchivedRecordingRowProps> = ({ [index, handleRowCheck], ); - const [loadingAnalysis, setLoadingAnalysis] = React.useState(false); - const [analyses, setAnalyses] = React.useState<CategorizedRuleEvaluations[]>([]); - React.useEffect(() => { if (!isExpanded) { return; @@ -903,7 +911,7 @@ export const ArchivedRecordingRow: React.FC<ArchivedRecordingRowProps> = ({ > {evaluations.map((evaluation) => { return ( - <ClickableAutomatedAnalysisLabel label={evaluation} key={clickableAutomatedAnalysisKey} /> + <ClickableAutomatedAnalysisLabel result={evaluation} key={clickableAutomatedAnalysisKey} /> ); })} </LabelGroup> @@ -916,7 +924,7 @@ export const ArchivedRecordingRow: React.FC<ArchivedRecordingRowProps> = ({ </Td> </Tr> ); - }, [index, isExpanded, loadingAnalysis, analyses]); + }, [index, isExpanded, analyses, loadingAnalysis]); return ( <Tbody key={index} isExpanded={isExpanded}> diff --git a/src/app/Recordings/Filters/DatetimeFilter.tsx b/src/app/Recordings/Filters/DatetimeFilter.tsx index 4065e910d..85b9c4817 100644 --- a/src/app/Recordings/Filters/DatetimeFilter.tsx +++ b/src/app/Recordings/Filters/DatetimeFilter.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { DateTimePicker } from '@app/DateTimePicker/DateTimePicker'; -import { useDayjs } from '@app/utils/useDayjs'; +import { useDayjs } from '@app/utils/hooks/useDayjs'; import { portalRoot } from '@app/utils/utils'; import { Timezone } from '@i18n/datetime'; import { diff --git a/src/app/Recordings/Filters/LabelFilter.tsx b/src/app/Recordings/Filters/LabelFilter.tsx index 10612a1af..c8dc5d617 100644 --- a/src/app/Recordings/Filters/LabelFilter.tsx +++ b/src/app/Recordings/Filters/LabelFilter.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import { parseLabels, RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; -import { Recording } from '@app/Shared/Services/Api.service'; +import { parseLabels, getLabelDisplay } from '@app/RecordingMetadata/utils'; +import { Recording } from '@app/Shared/Services/api.types'; import { Label, Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import React from 'react'; @@ -25,8 +25,6 @@ export interface LabelFilterProps { onSubmit: (inputLabel: string) => void; } -export const getLabelDisplay = (label: RecordingLabel) => `${label.key}:${label.value}`; - export const LabelFilter: React.FC<LabelFilterProps> = ({ recordings, filteredLabels, onSubmit }) => { const [isExpanded, setIsExpanded] = React.useState(false); diff --git a/src/app/Recordings/Filters/NameFilter.tsx b/src/app/Recordings/Filters/NameFilter.tsx index 432d9593b..18eb560a7 100644 --- a/src/app/Recordings/Filters/NameFilter.tsx +++ b/src/app/Recordings/Filters/NameFilter.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Recording } from '@app/Shared/Services/Api.service'; +import { Recording } from '@app/Shared/Services/api.types'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import React from 'react'; diff --git a/src/app/Recordings/Filters/RecordingStateFilter.tsx b/src/app/Recordings/Filters/RecordingStateFilter.tsx index 51c7d130c..b8a3ab359 100644 --- a/src/app/Recordings/Filters/RecordingStateFilter.tsx +++ b/src/app/Recordings/Filters/RecordingStateFilter.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { RecordingState } from '@app/Shared/Services/Api.service'; +import { RecordingState } from '@app/Shared/Services/api.types'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import React from 'react'; diff --git a/src/app/Recordings/RecordingActions.tsx b/src/app/Recordings/RecordingActions.tsx index 072faace6..25725e7a0 100644 --- a/src/app/Recordings/RecordingActions.tsx +++ b/src/app/Recordings/RecordingActions.tsx @@ -13,11 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotificationsContext } from '@app/Notifications/Notifications'; -import { Recording } from '@app/Shared/Services/Api.service'; +import { Recording, Target } from '@app/Shared/Services/api.types'; +import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { Dropdown, DropdownItem, KebabToggle } from '@patternfly/react-core'; import { Td } from '@patternfly/react-table'; import * as React from 'react'; diff --git a/src/app/Recordings/RecordingFilters.tsx b/src/app/Recordings/RecordingFilters.tsx index db2f49654..7af555fb0 100644 --- a/src/app/Recordings/RecordingFilters.tsx +++ b/src/app/Recordings/RecordingFilters.tsx @@ -20,8 +20,8 @@ import { allowedArchivedRecordingFilters, } from '@app/Shared/Redux/Filters/RecordingFilterSlice'; import { recordingUpdateCategoryIntent, RootState, StateDispatch } from '@app/Shared/Redux/ReduxStore'; -import { Recording, RecordingState } from '@app/Shared/Services/Api.service'; -import { useDayjs } from '@app/utils/useDayjs'; +import { Recording, RecordingState } from '@app/Shared/Services/api.types'; +import { useDayjs } from '@app/utils/hooks/useDayjs'; import dayjs from '@i18n/datetime'; import { Dropdown, diff --git a/src/app/Recordings/RecordingLabelsPanel.tsx b/src/app/Recordings/RecordingLabelsPanel.tsx index efdc4dcfe..b8d15a014 100644 --- a/src/app/Recordings/RecordingLabelsPanel.tsx +++ b/src/app/Recordings/RecordingLabelsPanel.tsx @@ -15,7 +15,7 @@ */ import { BulkEditLabels } from '@app/RecordingMetadata/BulkEditLabels'; -import { ArchivedRecording, RecordingDirectory } from '@app/Shared/Services/Api.service'; +import { RecordingDirectory, ArchivedRecording } from '@app/Shared/Services/api.types'; import { DrawerActions, DrawerCloseButton, diff --git a/src/app/Recordings/Recordings.tsx b/src/app/Recordings/Recordings.tsx index 72dcd4ab7..a6bbe1b7a 100644 --- a/src/app/Recordings/Recordings.tsx +++ b/src/app/Recordings/Recordings.tsx @@ -15,7 +15,7 @@ */ import { ServiceContext } from '@app/Shared/Services/Services'; import { TargetView } from '@app/TargetView/TargetView'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { getActiveTab, switchTab } from '@app/utils/utils'; import { Card, CardBody, CardTitle, Tab, Tabs, TabTitleText } from '@patternfly/react-core'; import * as React from 'react'; diff --git a/src/app/Recordings/RecordingsTable.tsx b/src/app/Recordings/RecordingsTable.tsx index d922a55a4..792c6f248 100644 --- a/src/app/Recordings/RecordingsTable.tsx +++ b/src/app/Recordings/RecordingsTable.tsx @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; -import { LoadingView } from '@app/LoadingView/LoadingView'; +import { ErrorView } from '@app/ErrorView/ErrorView'; +import { isAuthFail } from '@app/ErrorView/types'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; import { ServiceContext } from '@app/Shared/Services/Services'; import { Title, diff --git a/src/app/Rules/CreateRule.tsx b/src/app/Rules/CreateRule.tsx index 7ed1fe2f0..0033df2ac 100644 --- a/src/app/Rules/CreateRule.tsx +++ b/src/app/Rules/CreateRule.tsx @@ -13,18 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { BreadcrumbPage, BreadcrumbTrail } from '@app/BreadcrumbPage/BreadcrumbPage'; -import { EventTemplate } from '@app/CreateRecording/CreateRecording'; -import { NotificationsContext } from '@app/Notifications/Notifications'; -import { MatchExpressionHint } from '@app/Shared/MatchExpression/MatchExpressionHint'; -import { MatchExpressionVisualizer } from '@app/Shared/MatchExpression/MatchExpressionVisualizer'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; -import { SelectTemplateSelectorForm } from '@app/Shared/SelectTemplateSelectorForm'; -import { TemplateType } from '@app/Shared/Services/Api.service'; +import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; +import { BreadcrumbTrail } from '@app/BreadcrumbPage/types'; +import { EventTemplateIdentifier } from '@app/CreateRecording/types'; +import { MatchExpressionHint } from '@app/Shared/Components/MatchExpression/MatchExpressionHint'; +import { MatchExpressionVisualizer } from '@app/Shared/Components/MatchExpression/MatchExpressionVisualizer'; +import { SelectTemplateSelectorForm } from '@app/Shared/Components/SelectTemplateSelectorForm'; +import { LoadingProps } from '@app/Shared/Components/types'; +import { EventTemplate, Target, Rule } from '@app/Shared/Services/api.types'; +import { MatchExpressionService } from '@app/Shared/Services/MatchExpression.service'; +import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; +import { SearchExprServiceContext } from '@app/Shared/Services/service.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; -import { SearchExprService, SearchExprServiceContext, useExprSvc } from '@app/Topology/Shared/utils'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useMatchExpressionSvc } from '@app/utils/hooks/useMatchExpressionSvc'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { portalRoot } from '@app/utils/utils'; import { ActionGroup, @@ -51,121 +53,178 @@ import { import { HelpIcon } from '@patternfly/react-icons'; import _ from 'lodash'; import * as React from 'react'; -import { useHistory, withRouter } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { combineLatest, forkJoin, iif, of, Subject } from 'rxjs'; import { catchError, debounceTime, map, switchMap, tap } from 'rxjs/operators'; -import { Rule } from './Rules'; +import { RuleFormData } from './types'; +import { isRuleNameValid } from './utils'; -// FIXME check if this is correct/matches backend name validation -export const RuleNamePattern = /^[\w_]+$/; +export interface CreateRuleFormProps {} -interface CreateRuleFormProps {} - -const CreateRuleForm: React.FC<CreateRuleFormProps> = ({ ...props }) => { +export const CreateRuleForm: React.FC<CreateRuleFormProps> = (_props) => { const context = React.useContext(ServiceContext); const notifications = React.useContext(NotificationsContext); const history = useHistory(); - // Do not use useSearchExpression hook for display. - // This causes the cursor to jump to the end due to async updates. - const matchExprService = useExprSvc(); - // Use this for displaying match expression input - const [matchExpressionInput, setMatchExpressionInput] = React.useState(''); + // Do not use useSearchExpression for display. This causes the cursor to jump to the end due to async updates. + const matchExprService = useMatchExpressionSvc(); const addSubscription = useSubscriptions(); - const [name, setName] = React.useState(''); - const [nameValid, setNameValid] = React.useState(ValidatedOptions.default); - const [description, setDescription] = React.useState(''); - const [enabled, setEnabled] = React.useState(true); - const [matchExpressionValid, setMatchExpressionValid] = React.useState(ValidatedOptions.default); + const [formData, setFormData] = React.useState<RuleFormData>({ + name: '', + nameValid: ValidatedOptions.default, + enabled: true, + description: '', + matchExpression: '', // Use this for displaying match expression input + matchExpressionValid: ValidatedOptions.default, + maxAge: 0, + maxAgeUnit: 1, + maxSize: 0, + maxSizeUnit: 1, + archivalPeriod: 0, + archivalPeriodUnit: 1, + initialDelay: 0, + initialDelayUnit: 1, + preservedArchives: 0, + }); const [templates, setTemplates] = React.useState<EventTemplate[]>([]); - const [template, setTemplate] = React.useState<Pick<Partial<EventTemplate>, 'name' | 'type'>>({}); - const [maxAge, setMaxAge] = React.useState(0); - const [maxAgeUnits, setMaxAgeUnits] = React.useState(1); - const [maxSize, setMaxSize] = React.useState(0); - const [maxSizeUnits, setMaxSizeUnits] = React.useState(1); - const [archivalPeriod, setArchivalPeriod] = React.useState(0); - const [archivalPeriodUnits, setArchivalPeriodUnits] = React.useState(1); - const [initialDelay, setInitialDelay] = React.useState(0); - const [initialDelayUnits, setInitialDelayUnits] = React.useState(1); - const [preservedArchives, setPreservedArchives] = React.useState(0); const [loading, setLoading] = React.useState(false); const [evaluating, setEvaluating] = React.useState(false); const [sampleTarget, setSampleTarget] = React.useState<Target>(); const matchedTargetsRef = React.useRef(new Subject<Target[]>()); - const handleNameChange = React.useCallback( - (name) => { - setNameValid(RuleNamePattern.test(name) ? ValidatedOptions.success : ValidatedOptions.error); - setName(name); - }, - [setNameValid, setName], - ); - const eventSpecifierString = React.useMemo(() => { let str = ''; - const { name, type } = template; - if (name) { - str += `template=${name}`; + const { template } = formData; + if (template && template.name) { + str += `template=${template.name}`; } - if (type) { - str += `,type=${type}`; + if (template && template.type) { + str += `,type=${template.type}`; } return str; - }, [template]); + }, [formData]); - const handleTemplateChange = React.useCallback( - (templateName?: string, templateType?: TemplateType) => { - setTemplate({ - name: templateName, - type: templateType, - }); + const createButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Creating', + spinnerAriaLabel: 'creating-automated-rule', + isLoading: loading, + }) as LoadingProps, + [loading], + ); + + const selectedSpecifier = React.useMemo(() => { + const { template } = formData; + if (template && template.name && template.type) { + return `${template.name},${template.type}`; + } + return ''; + }, [formData]); + + const handleNameChange = React.useCallback( + (name: string) => + setFormData((old) => ({ + ...old, + name, + nameValid: !name + ? ValidatedOptions.default + : isRuleNameValid(name) + ? ValidatedOptions.success + : ValidatedOptions.error, + })), + [setFormData], + ); + + const handleDescriptionChange = React.useCallback( + (description: string) => setFormData((old) => ({ ...old, description })), + [setFormData], + ); + + const handleMatchExpressionChange = React.useCallback( + (matchExpression: string) => { + matchExprService.setSearchExpression(matchExpression); + setFormData((old) => ({ ...old, matchExpression })); }, - [setTemplate], + [setFormData, matchExprService], ); - const handleMaxAgeChange = React.useCallback((maxAge) => setMaxAge(Number(maxAge)), [setMaxAge]); + const handleTemplateChange = React.useCallback( + (template: EventTemplateIdentifier) => setFormData((old) => ({ ...old, template })), + [setFormData], + ); + + const handleEnabledChange = React.useCallback( + (enabled: boolean) => setFormData((old) => ({ ...old, enabled })), + [setFormData], + ); + + const handleMaxAgeChange = React.useCallback( + (maxAge: string) => setFormData((old) => ({ ...old, maxAge: Number(maxAge) })), + [setFormData], + ); const handleMaxAgeUnitChange = React.useCallback( - (maxAgeUnit) => setMaxAgeUnits(Number(maxAgeUnit)), - [setMaxAgeUnits], + (maxAgeUnit: string) => setFormData((old) => ({ ...old, maxAgeUnit: Number(maxAgeUnit) })), + [setFormData], ); - const handleMaxSizeChange = React.useCallback((maxSize) => setMaxSize(Number(maxSize)), [setMaxSize]); + const handleMaxSizeChange = React.useCallback( + (maxSize: string) => setFormData((old) => ({ ...old, maxSize: Number(maxSize) })), + [setFormData], + ); const handleMaxSizeUnitChange = React.useCallback( - (maxSizeUnit) => setMaxSizeUnits(Number(maxSizeUnit)), - [setMaxSizeUnits], + (maxSizeUnit: string) => setFormData((old) => ({ ...old, maxSizeUnit: Number(maxSizeUnit) })), + [setFormData], ); const handleArchivalPeriodChange = React.useCallback( - (archivalPeriod) => setArchivalPeriod(Number(archivalPeriod)), - [setArchivalPeriod], + (archivalPeriod: string) => setFormData((old) => ({ ...old, archivalPeriod: Number(archivalPeriod) })), + [setFormData], ); const handleArchivalPeriodUnitsChange = React.useCallback( - (evt) => setArchivalPeriodUnits(Number(evt)), - [setArchivalPeriodUnits], + (archivalPeriodUnit: string) => setFormData((old) => ({ ...old, archivalPeriodUnit: Number(archivalPeriodUnit) })), + [setFormData], ); const handleInitialDelayChange = React.useCallback( - (initialDelay) => setInitialDelay(Number(initialDelay)), - [setInitialDelay], + (initialDelay: string) => setFormData((old) => ({ ...old, initialDelay: Number(initialDelay) })), + [setFormData], ); const handleInitialDelayUnitsChanged = React.useCallback( - (initialDelayUnit) => setInitialDelayUnits(Number(initialDelayUnit)), - [setInitialDelayUnits], + (initialDelayUnit: string) => setFormData((old) => ({ ...old, initialDelayUnit: Number(initialDelayUnit) })), + [setFormData], ); const handlePreservedArchivesChange = React.useCallback( - (preservedArchives) => setPreservedArchives(Number(preservedArchives)), - [setPreservedArchives], + (preservedArchives: string) => setFormData((old) => ({ ...old, preservedArchives: Number(preservedArchives) })), + [setFormData], ); + const exitForm = React.useCallback(() => history.push('/rules'), [history]); + const handleSubmit = React.useCallback((): void => { - setLoading(true); const notificationMessages: string[] = []; + const { + name, + nameValid, + description, + enabled, + matchExpression, + preservedArchives, + archivalPeriod, + archivalPeriodUnit, + initialDelay, + initialDelayUnit, + maxAge, + maxAgeUnit, + maxSize, + maxSizeUnit, + } = formData; if (nameValid !== ValidatedOptions.success) { notificationMessages.push(`Rule name ${name} is invalid`); } @@ -178,44 +237,24 @@ const CreateRuleForm: React.FC<CreateRuleFormProps> = ({ ...props }) => { name, description, enabled, - matchExpression: matchExpressionInput, + matchExpression: matchExpression, eventSpecifier: eventSpecifierString, - archivalPeriodSeconds: archivalPeriod * archivalPeriodUnits, - initialDelaySeconds: initialDelay * initialDelayUnits, + archivalPeriodSeconds: archivalPeriod * archivalPeriodUnit, + initialDelaySeconds: initialDelay * initialDelayUnit, preservedArchives, - maxAgeSeconds: maxAge * maxAgeUnits, - maxSizeBytes: maxSize * maxSizeUnits, + maxAgeSeconds: maxAge * maxAgeUnit, + maxSizeBytes: maxSize * maxSizeUnit, }; + setLoading(true); addSubscription( context.api.createRule(rule).subscribe((success) => { setLoading(false); if (success) { - history.push('/rules'); + exitForm(); } }), ); - }, [ - setLoading, - addSubscription, - context.api, - history, - notifications, - name, - nameValid, - description, - enabled, - matchExpressionInput, - eventSpecifierString, - archivalPeriod, - archivalPeriodUnits, - initialDelay, - initialDelayUnits, - preservedArchives, - maxAge, - maxAgeUnits, - maxSize, - maxSizeUnits, - ]); + }, [setLoading, addSubscription, exitForm, context.api, notifications, formData, eventSpecifierString]); React.useEffect(() => { const matchedTargets = matchedTargetsRef.current; @@ -252,11 +291,11 @@ const CreateRuleForm: React.FC<CreateRuleFormProps> = ({ ...props }) => { ), ), ) - .subscribe((templates) => { + .subscribe((templates: EventTemplate[]) => { setTemplates(templates); - setTemplate((old) => { - const matched = templates.find((t) => t.name === old.name && t.type === old.type); - return matched ? { name: matched.name, type: matched.type } : {}; + setFormData((old) => { + const matched = templates.find((t) => t.name === old.template?.name && t.type === old.template?.type); + return { ...old, template: matched ? { name: matched.name, type: matched.type } : undefined }; }); }), ); @@ -269,7 +308,7 @@ const CreateRuleForm: React.FC<CreateRuleFormProps> = ({ ...props }) => { matchExprService.searchExpression({ immediateFn: () => { setEvaluating(true); - setMatchExpressionValid(ValidatedOptions.default); + setFormData((old) => ({ ...old, matchExpressionValid: ValidatedOptions.default })); }, }), context.targets.targets().pipe(tap((ts) => setSampleTarget(ts[0]))), @@ -286,48 +325,23 @@ const CreateRuleForm: React.FC<CreateRuleFormProps> = ({ ...props }) => { ) .subscribe(([ts, err]) => { setEvaluating(false); - setMatchExpressionValid( - err + setFormData((old) => ({ + ...old, + matchExpressionValid: err ? ValidatedOptions.error : !ts ? ValidatedOptions.default : ts.length ? ValidatedOptions.success : ValidatedOptions.warning, - ); + })); matchedTargets.next(ts || []); }), ); - }, [ - matchExprService, - context.api, - context.targets, - setSampleTarget, - setMatchExpressionValid, - setEvaluating, - addSubscription, - ]); - - const createButtonLoadingProps = React.useMemo( - () => - ({ - spinnerAriaValueText: 'Creating', - spinnerAriaLabel: 'creating-automated-rule', - isLoading: loading, - }) as LoadingPropsType, - [loading], - ); - - const selectedSpecifier = React.useMemo(() => { - const { name, type } = template; - if (name && type) { - return `${name},${type}`; - } - return ''; - }, [template]); + }, [matchExprService, context.api, context.targets, setSampleTarget, setFormData, setEvaluating, addSubscription]); return ( - <Form {...props}> + <Form> <Text component={TextVariants.small}> Automated Rules are configurations that instruct Cryostat to create JDK Flight Recordings on matching target JVM applications. Each Automated Rule specifies parameters for which Event Template to use, how much data should be @@ -340,18 +354,18 @@ const CreateRuleForm: React.FC<CreateRuleFormProps> = ({ ...props }) => { fieldId="rule-name" helperText="Enter a rule name." helperTextInvalid="A rule name can contain only letters, numbers, and underscores." - validated={nameValid} + validated={formData.nameValid} data-quickstart-id="rule-name" > <TextInput - value={name} + value={formData.name} isDisabled={loading} isRequired type="text" id="rule-name" aria-describedby="rule-name-helper" onChange={handleNameChange} - validated={nameValid} + validated={formData.nameValid} /> </FormGroup> <FormGroup @@ -361,14 +375,14 @@ const CreateRuleForm: React.FC<CreateRuleFormProps> = ({ ...props }) => { data-quickstart-id="rule-description" > <TextArea - value={description} + value={formData.description} isDisabled={loading} type="text" id="rule-description" aria-describedby="rule-description-helper" resizeOrientation="vertical" autoResize - onChange={setDescription} + onChange={handleDescriptionChange} /> </FormGroup> <FormGroup @@ -401,18 +415,18 @@ const CreateRuleForm: React.FC<CreateRuleFormProps> = ({ ...props }) => { helperText={ evaluating ? 'Evaluating match expression...' - : matchExpressionValid === ValidatedOptions.warning + : formData.matchExpressionValid === ValidatedOptions.warning ? `Warning: Match expression matches no targets.` : ` Enter a match expression. This is a Java-like code snippet that is evaluated against each target application to determine whether the rule should be applied.` } helperTextInvalid="The expression matching failed." - validated={matchExpressionValid} + validated={formData.matchExpressionValid} data-quickstart-id="rule-matchexpr" > <TextArea - value={matchExpressionInput} + value={formData.matchExpression} isDisabled={loading} isRequired type="text" @@ -420,11 +434,8 @@ const CreateRuleForm: React.FC<CreateRuleFormProps> = ({ ...props }) => { aria-describedby="rule-matchexpr-helper" resizeOrientation="vertical" autoResize - onChange={(value) => { - setMatchExpressionInput(value); - matchExprService.setSearchExpression(value); - }} - validated={matchExpressionValid} + onChange={handleMatchExpressionChange} + validated={formData.matchExpressionValid} /> </FormGroup> <FormGroup @@ -440,15 +451,15 @@ enabled in the future.`} id="rule-enabled" isDisabled={loading} aria-label="Apply this rule to matching targets" - isChecked={enabled} - onChange={setEnabled} + isChecked={formData.enabled} + onChange={handleEnabledChange} /> </FormGroup> <FormGroup label="Template" isRequired fieldId="recording-template" - validated={!template.name ? ValidatedOptions.default : ValidatedOptions.success} + validated={!formData.template?.name ? ValidatedOptions.default : ValidatedOptions.success} helperText="The Event Template to be applied by this Rule against matching target applications." helperTextInvalid="A Template must be selected" data-quickstart-id="rule-evt-template" @@ -456,7 +467,7 @@ enabled in the future.`} <SelectTemplateSelectorForm selected={selectedSpecifier} disabled={loading} - validated={!template.name ? ValidatedOptions.default : ValidatedOptions.success} + validated={!formData.template?.name ? ValidatedOptions.default : ValidatedOptions.success} templates={templates} onSelect={handleTemplateChange} /> @@ -470,7 +481,7 @@ enabled in the future.`} <Split hasGutter={true}> <SplitItem isFilled> <TextInput - value={maxSize} + value={formData.maxSize} isDisabled={loading} isRequired type="number" @@ -482,7 +493,7 @@ enabled in the future.`} </SplitItem> <SplitItem> <FormSelect - value={maxSizeUnits} + value={formData.maxSizeUnit} isDisabled={loading} onChange={handleMaxSizeUnitChange} aria-label="Max size units input" @@ -503,7 +514,7 @@ enabled in the future.`} <Split hasGutter={true}> <SplitItem isFilled> <TextInput - value={maxAge} + value={formData.maxAge} isDisabled={loading} isRequired type="number" @@ -515,7 +526,7 @@ enabled in the future.`} </SplitItem> <SplitItem> <FormSelect - value={maxAgeUnits} + value={formData.maxAgeUnit} isDisabled={loading} onChange={handleMaxAgeUnitChange} aria-label="Max Age units Input" @@ -536,7 +547,7 @@ enabled in the future.`} <Split hasGutter={true}> <SplitItem isFilled> <TextInput - value={archivalPeriod} + value={formData.archivalPeriod} isDisabled={loading} isRequired type="number" @@ -548,7 +559,7 @@ enabled in the future.`} </SplitItem> <SplitItem> <FormSelect - value={archivalPeriodUnits} + value={formData.archivalPeriodUnit} isDisabled={loading} onChange={handleArchivalPeriodUnitsChange} aria-label="archival period units input" @@ -569,7 +580,7 @@ enabled in the future.`} <Split hasGutter={true}> <SplitItem isFilled> <TextInput - value={initialDelay} + value={formData.initialDelay} isDisabled={loading} isRequired type="number" @@ -581,7 +592,7 @@ enabled in the future.`} </SplitItem> <SplitItem> <FormSelect - value={initialDelayUnits} + value={formData.initialDelayUnit} isDisabled={loading} onChange={handleInitialDelayUnitsChanged} aria-label="initial delay units input" @@ -600,7 +611,7 @@ enabled in the future.`} data-quickstart-id="rule-preserved-archives" > <TextInput - value={preservedArchives} + value={formData.preservedArchives} isDisabled={loading} isRequired type="number" @@ -616,10 +627,10 @@ enabled in the future.`} onClick={handleSubmit} isDisabled={ loading || - nameValid !== ValidatedOptions.success || - !template.name || - !template.type || - !matchExpressionInput + formData.nameValid !== ValidatedOptions.success || + !formData.template?.name || + !formData.template?.type || + !formData.matchExpression } data-quickstart-id="rule-create-btn" {...createButtonLoadingProps} @@ -634,8 +645,9 @@ enabled in the future.`} ); }; -const Comp: React.FC = () => { - const matchExpreRef = React.useRef(new SearchExprService()); +export const CreateRule: React.FC = () => { + const matchExpreRef = React.useRef(new MatchExpressionService()); + const breadcrumbs: BreadcrumbTrail[] = React.useMemo( () => [ { @@ -679,6 +691,4 @@ const Comp: React.FC = () => { ); }; -export const CreateRule = withRouter(Comp); - export default CreateRule; diff --git a/src/app/Rules/RuleDeleteWarningModal.tsx b/src/app/Rules/RuleDeleteWarningModal.tsx index 434d6f0fa..896fae1d1 100644 --- a/src/app/Rules/RuleDeleteWarningModal.tsx +++ b/src/app/Rules/RuleDeleteWarningModal.tsx @@ -19,12 +19,12 @@ import { Modal, ModalVariant, Button, Checkbox, Stack, Split } from '@patternfly import * as React from 'react'; import { useState } from 'react'; import { DeleteWarningProps } from '../Modal/DeleteWarningModal'; -import { getFromWarningMap } from '../Modal/DeleteWarningUtils'; +import { getFromWarningMap } from '../Modal/utils'; export interface RuleDeleteWarningProps extends DeleteWarningProps { ruleName?: string; clean: boolean; - setClean: React.Dispatch<React.SetStateAction<boolean>>; + setClean: (clean: boolean) => void; } export const RuleDeleteWarningModal = ({ diff --git a/src/app/Rules/Rules.tsx b/src/app/Rules/Rules.tsx index 4acd36d83..f22f5203f 100644 --- a/src/app/Rules/Rules.tsx +++ b/src/app/Rules/Rules.tsx @@ -14,11 +14,11 @@ * limitations under the License. */ import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; -import { LoadingView } from '@app/LoadingView/LoadingView'; -import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { DeleteOrDisableWarningType } from '@app/Modal/types'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { Rule, NotificationCategory } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { TableColumn, sortResources } from '@app/utils/utils'; import { Button, @@ -55,41 +55,7 @@ import { Link, useHistory, useRouteMatch } from 'react-router-dom'; import { first } from 'rxjs/operators'; import { RuleDeleteWarningModal } from './RuleDeleteWarningModal'; import { RuleUploadModal } from './RulesUploadModal'; - -export interface Rule { - name: string; - description: string; - matchExpression: string; - enabled: boolean; - eventSpecifier: string; - archivalPeriodSeconds: number; - initialDelaySeconds: number; - preservedArchives: number; - maxAgeSeconds: number; - maxSizeBytes: number; -} - -export const ruleObjKeys = [ - 'name', - 'description', - 'matchExpression', - 'enabled', - 'eventSpecifier', - 'archivalPeriodSeconds', - 'initialDelaySeconds', - 'preservedArchives', - 'maxAgeSeconds', - 'maxSizeBytes', -]; - -export const isRule = (obj: object): boolean => { - for (const key of ruleObjKeys) { - if (!Object.prototype.hasOwnProperty.call(obj, key)) { - return false; - } - } // Ignore unknown fields - return true; -}; +import { RuleToDeleteOrDisable } from './types'; const tableColumns: TableColumn[] = [ { @@ -148,14 +114,9 @@ const tableColumns: TableColumn[] = [ }, ]; -export interface RuleToDeleteOrDisable { - rule: Rule; - type: 'DELETE' | 'DISABLE'; -} - -export interface RulesProps {} +export interface RulesTableProps {} -export const Rules: React.FC<RulesProps> = (_) => { +export const RulesTable: React.FC<RulesTableProps> = (_) => { const context = React.useContext(ServiceContext); const routerHistory = useHistory(); const addSubscription = useSubscriptions(); @@ -505,4 +466,4 @@ export const Rules: React.FC<RulesProps> = (_) => { ); }; -export default Rules; +export default RulesTable; diff --git a/src/app/Rules/RulesUploadModal.tsx b/src/app/Rules/RulesUploadModal.tsx index 120d9d788..36f7d7698 100644 --- a/src/app/Rules/RulesUploadModal.tsx +++ b/src/app/Rules/RulesUploadModal.tsx @@ -13,17 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FUpload, MultiFileUpload, UploadCallbacks } from '@app/Shared/FileUploads'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; +import { FUpload, MultiFileUpload, UploadCallbacks } from '@app/Shared/Components/FileUploads'; +import { LoadingProps } from '@app/Shared/Components/types'; +import { Rule } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { portalRoot } from '@app/utils/utils'; import { ActionGroup, Button, Form, FormGroup, Modal, ModalVariant, Popover } from '@patternfly/react-core'; import { HelpIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { forkJoin, from, Observable, of } from 'rxjs'; import { catchError, concatMap, defaultIfEmpty, first, tap } from 'rxjs/operators'; -import { isRule, Rule } from './Rules'; +import { isRule } from './utils'; export interface RuleUploadModalProps { visible: boolean; @@ -123,7 +124,7 @@ export const RuleUploadModal: React.FC<RuleUploadModalProps> = ({ onClose, ...pr spinnerAriaValueText: 'Submitting', spinnerAriaLabel: 'submitting-automated-rule', isLoading: uploading, - }) as LoadingPropsType, + }) as LoadingProps, [uploading], ); diff --git a/src/app/Rules/types.ts b/src/app/Rules/types.ts new file mode 100644 index 000000000..d13551c68 --- /dev/null +++ b/src/app/Rules/types.ts @@ -0,0 +1,61 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventTemplateIdentifier } from '@app/CreateRecording/types'; +import { Rule } from '@app/Shared/Services/api.types'; +import { ValidatedOptions } from '@patternfly/react-core'; + +export interface RuleToDeleteOrDisable { + rule: Rule; + type: 'DELETE' | 'DISABLE'; +} + +interface _FormBaseData { + name: string; + enabled: boolean; + matchExpression: string; + description: string; + template?: EventTemplateIdentifier; + maxAge: number; + maxAgeUnit: number; + maxSize: number; + maxSizeUnit: number; + archivalPeriod: number; + archivalPeriodUnit: number; + initialDelay: number; + initialDelayUnit: number; + preservedArchives: number; +} + +interface _FormValidationData { + nameValid: ValidatedOptions; + matchExpressionValid: ValidatedOptions; +} + +export type RuleFormData = _FormBaseData & _FormValidationData; + +export const ruleObjKeys = [ + 'name', + 'description', + 'matchExpression', + 'enabled', + 'eventSpecifier', + 'archivalPeriodSeconds', + 'initialDelaySeconds', + 'preservedArchives', + 'maxAgeSeconds', + 'maxSizeBytes', +]; diff --git a/src/app/Rules/utils.ts b/src/app/Rules/utils.ts new file mode 100644 index 000000000..945408092 --- /dev/null +++ b/src/app/Rules/utils.ts @@ -0,0 +1,30 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ruleObjKeys } from './types'; + +export const isRule = (obj: object): boolean => { + for (const key of ruleObjKeys) { + if (!Object.prototype.hasOwnProperty.call(obj, key)) { + return false; + } + } // Ignore unknown fields + return true; +}; + +// FIXME check if this is correct/matches backend name validation +export const RuleNamePattern = /^[\w_]+$/; + +export const isRuleNameValid = (name: string) => RuleNamePattern.test(name); diff --git a/src/app/SecurityPanel/CertificateUploadModal.tsx b/src/app/SecurityPanel/CertificateUploadModal.tsx index 24f8f7687..7226d09e3 100644 --- a/src/app/SecurityPanel/CertificateUploadModal.tsx +++ b/src/app/SecurityPanel/CertificateUploadModal.tsx @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FUpload, MultiFileUpload, UploadCallbacks } from '@app/Shared/FileUploads'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; +import { FUpload, MultiFileUpload, UploadCallbacks } from '@app/Shared/Components/FileUploads'; +import { LoadingProps } from '@app/Shared/Components/types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { portalRoot } from '@app/utils/utils'; import { ActionGroup, Button, Form, FormGroup, Modal, ModalVariant } from '@patternfly/react-core'; import * as React from 'react'; @@ -109,7 +109,7 @@ export const CertificateUploadModal: React.FC<CertificateUploadModalProps> = ({ spinnerAriaValueText: 'Submitting', spinnerAriaLabel: 'submitting-ssl-certificates', isLoading: uploading, - }) as LoadingPropsType, + }) as LoadingProps, [uploading], ); diff --git a/src/app/SecurityPanel/Credentials/CreateCredentialModal.tsx b/src/app/SecurityPanel/Credentials/CreateCredentialModal.tsx index e18583313..10caedb2e 100644 --- a/src/app/SecurityPanel/Credentials/CreateCredentialModal.tsx +++ b/src/app/SecurityPanel/Credentials/CreateCredentialModal.tsx @@ -14,12 +14,14 @@ * limitations under the License. */ import { AuthCredential, CredentialAuthForm } from '@app/AppLayout/CredentialAuthForm'; -import { MatchExpressionHint } from '@app/Shared/MatchExpression/MatchExpressionHint'; -import { MatchExpressionVisualizer } from '@app/Shared/MatchExpression/MatchExpressionVisualizer'; +import { MatchExpressionHint } from '@app/Shared/Components/MatchExpression/MatchExpressionHint'; +import { MatchExpressionVisualizer } from '@app/Shared/Components/MatchExpression/MatchExpressionVisualizer'; +import { Target } from '@app/Shared/Services/api.types'; +import { MatchExpressionService } from '@app/Shared/Services/MatchExpression.service'; +import { SearchExprServiceContext } from '@app/Shared/Services/service.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; -import { SearchExprService, SearchExprServiceContext, useExprSvc } from '@app/Topology/Shared/utils'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useMatchExpressionSvc } from '@app/utils/hooks/useMatchExpressionSvc'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { portalRoot, StreamOf } from '@app/utils/utils'; import { Button, @@ -42,7 +44,8 @@ import { FlaskIcon, HelpIcon, TopologyIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { catchError, combineLatest, distinctUntilChanged, interval, map, of, switchMap, tap } from 'rxjs'; import { CredentialTestTable } from './CredentialTestTable'; -import { CredentialContext, TestPoolContext, TestRequest, useAuthCredential } from './utils'; +import { TestRequest } from './types'; +import { CredentialContext, TestPoolContext, useAuthCredential } from './utils'; export interface CreateCredentialModalProps { visible: boolean; @@ -56,7 +59,7 @@ export const CreateCredentialModal: React.FC<CreateCredentialModalProps> = ({ onPropsSave, ...props }) => { - const matchExpreRef = React.useRef(new SearchExprService()); + const matchExpreRef = React.useRef(new MatchExpressionService()); const loadingRef = React.useRef(new StreamOf(false)); const credentialRef = React.useRef(new StreamOf<AuthCredential>({ username: '', password: '' })); const testPoolRef = React.useRef(new Set<TestRequest>()); @@ -118,7 +121,7 @@ interface AuthFormProps extends Omit<CreateCredentialModalProps, 'visible'> { export const AuthForm: React.FC<AuthFormProps> = ({ onDismiss, onPropsSave, progressChange, ...props }) => { const context = React.useContext(ServiceContext); const addSubscription = useSubscriptions(); - const matchExprService = useExprSvc(); + const matchExprService = useMatchExpressionSvc(); const [matchExpressionInput, setMatchExpressionInput] = React.useState(''); const [matchExpressionValid, setMatchExpressionValid] = React.useState(ValidatedOptions.default); const [_, setCredential] = useAuthCredential(true); diff --git a/src/app/SecurityPanel/Credentials/CredentialTestTable.tsx b/src/app/SecurityPanel/Credentials/CredentialTestTable.tsx index 0248ac9a3..2419251c1 100644 --- a/src/app/SecurityPanel/Credentials/CredentialTestTable.tsx +++ b/src/app/SecurityPanel/Credentials/CredentialTestTable.tsx @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LoadingView } from '@app/LoadingView/LoadingView'; -import { LinearDotSpinner } from '@app/Shared/LinearDotSpinner'; +import { LinearDotSpinner } from '@app/Shared/Components/LinearDotSpinner'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { Target } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; -import { useExprSvc } from '@app/Topology/Shared/utils'; -import { useSort } from '@app/utils/useSort'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useMatchExpressionSvc } from '@app/utils/hooks/useMatchExpressionSvc'; +import { useSort } from '@app/utils/hooks/useSort'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { TableColumn, portalRoot, sortResources } from '@app/utils/utils'; import { Bullseye, @@ -80,7 +80,7 @@ export interface CredentialTestTableProps {} export const CredentialTestTable: React.FC<CredentialTestTableProps> = ({ ...props }) => { const addSubscription = useSubscriptions(); const context = React.useContext(ServiceContext); - const matchExprService = useExprSvc(); + const matchExprService = useMatchExpressionSvc(); const [sortBy, getSortParams] = useSort(); const [matchedExpr, setMatchExpr] = React.useState(''); diff --git a/src/app/SecurityPanel/Credentials/MatchedTargetsTable.tsx b/src/app/SecurityPanel/Credentials/MatchedTargetsTable.tsx index e93613fc2..5b65114ed 100644 --- a/src/app/SecurityPanel/Credentials/MatchedTargetsTable.tsx +++ b/src/app/SecurityPanel/Credentials/MatchedTargetsTable.tsx @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LoadingView } from '@app/LoadingView/LoadingView'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { TargetDiscoveryEvent, Target, NotificationCategory } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; -import { TargetDiscoveryEvent } from '@app/Shared/Services/Targets.service'; -import { useSort } from '@app/utils/useSort'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSort } from '@app/utils/hooks/useSort'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { TableColumn, sortResources } from '@app/utils/utils'; import { EmptyState, EmptyStateIcon, Title } from '@patternfly/react-core'; import { SearchIcon } from '@patternfly/react-icons'; diff --git a/src/app/SecurityPanel/Credentials/StoreCredentials.tsx b/src/app/SecurityPanel/Credentials/StoreCredentials.tsx index d79dc9e71..8d360978c 100644 --- a/src/app/SecurityPanel/Credentials/StoreCredentials.tsx +++ b/src/app/SecurityPanel/Credentials/StoreCredentials.tsx @@ -13,14 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LoadingView } from '@app/LoadingView/LoadingView'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; -import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; -import { StoredCredential } from '@app/Shared/Services/Api.service'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { DeleteOrDisableWarningType } from '@app/Modal/types'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { StoredCredential, NotificationCategory } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSort } from '@app/utils/useSort'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSort } from '@app/utils/hooks/useSort'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { TableColumn, sortResources } from '@app/utils/utils'; import { Badge, @@ -43,7 +42,7 @@ import { ExpandableRowContent, TableComposable, Tbody, Td, Th, Thead, Tr } from import * as React from 'react'; import { Link } from 'react-router-dom'; import { forkJoin } from 'rxjs'; -import { SecurityCard } from '../SecurityPanel'; +import { SecurityCard } from '../types'; import { CreateCredentialModal } from './CreateCredentialModal'; import { MatchedTargetsTable } from './MatchedTargetsTable'; diff --git a/src/app/SecurityPanel/Credentials/types.ts b/src/app/SecurityPanel/Credentials/types.ts new file mode 100644 index 000000000..89715981a --- /dev/null +++ b/src/app/SecurityPanel/Credentials/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface TestRequest { + id: string; + targetUrl: string; + data?: unknown; +} diff --git a/src/app/SecurityPanel/Credentials/utils.tsx b/src/app/SecurityPanel/Credentials/utils.tsx index 32c266ed9..622bba638 100644 --- a/src/app/SecurityPanel/Credentials/utils.tsx +++ b/src/app/SecurityPanel/Credentials/utils.tsx @@ -17,15 +17,10 @@ import { AuthCredential } from '@app/AppLayout/CredentialAuthForm'; import { StreamOf } from '@app/utils/utils'; import * as React from 'react'; import { debounceTime, Subscription } from 'rxjs'; +import { TestRequest } from './types'; export const CredentialContext = React.createContext(new StreamOf<AuthCredential>({ username: '', password: '' })); -export interface TestRequest { - id: string; - targetUrl: string; - data?: unknown; -} - // Each test request registers itself to test pool when initiated. When completed, remove itself from pool. // Auth form will poll this pool for a set time to determine if form should is disabled. export const TestPoolContext = React.createContext(new Set<TestRequest>()); diff --git a/src/app/SecurityPanel/ImportCertificate.tsx b/src/app/SecurityPanel/ImportCertificate.tsx index 3394af8c7..f608a40fa 100644 --- a/src/app/SecurityPanel/ImportCertificate.tsx +++ b/src/app/SecurityPanel/ImportCertificate.tsx @@ -17,9 +17,9 @@ import { Button } from '@patternfly/react-core'; import * as React from 'react'; import { CertificateUploadModal } from './CertificateUploadModal'; -import { SecurityCard } from './SecurityPanel'; +import { SecurityCard } from './types'; -const Component = () => { +export const CertificateImport: React.FC = () => { const [showModal, setShowModal] = React.useState(false); const handleModalClose = () => { @@ -39,5 +39,5 @@ const Component = () => { export const ImportCertificate: SecurityCard = { title: 'Import SSL Certificates', description: 'The Cryostat server must be restarted in order to reload the certificate store.', - content: Component, + content: CertificateImport, }; diff --git a/src/app/SecurityPanel/SecurityPanel.tsx b/src/app/SecurityPanel/SecurityPanel.tsx index 169e0b486..877979960 100644 --- a/src/app/SecurityPanel/SecurityPanel.tsx +++ b/src/app/SecurityPanel/SecurityPanel.tsx @@ -44,9 +44,3 @@ export const SecurityPanel: React.FC<SecurityPanelProps> = (_) => { }; export default SecurityPanel; - -export interface SecurityCard { - title: string; - description: JSX.Element | string; - content: React.FC; -} diff --git a/src/app/SecurityPanel/types.ts b/src/app/SecurityPanel/types.ts new file mode 100644 index 000000000..2a83da277 --- /dev/null +++ b/src/app/SecurityPanel/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface SecurityCard { + title: string; + description: JSX.Element | string; + content: React.FC; +} diff --git a/src/app/Settings/AutoRefresh.tsx b/src/app/Settings/Config/AutoRefresh.tsx similarity index 98% rename from src/app/Settings/AutoRefresh.tsx rename to src/app/Settings/Config/AutoRefresh.tsx index 422254a10..f03574579 100644 --- a/src/app/Settings/AutoRefresh.tsx +++ b/src/app/Settings/Config/AutoRefresh.tsx @@ -19,7 +19,7 @@ import { ServiceContext } from '@app/Shared/Services/Services'; import { Checkbox } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { SettingTab, UserSetting } from './SettingsUtils'; +import { SettingTab, UserSetting } from '../types'; const defaultPreferences = { autoRefreshEnabled: true, diff --git a/src/app/Settings/AutomatedAnalysisConfig.tsx b/src/app/Settings/Config/AutomatedAnalysis.tsx similarity index 90% rename from src/app/Settings/AutomatedAnalysisConfig.tsx rename to src/app/Settings/Config/AutomatedAnalysis.tsx index 59256a910..6cda412ac 100644 --- a/src/app/Settings/AutomatedAnalysisConfig.tsx +++ b/src/app/Settings/Config/AutomatedAnalysis.tsx @@ -16,13 +16,13 @@ import { AutomatedAnalysisConfigForm } from '@app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm'; import * as React from 'react'; -import { SettingTab, UserSetting } from './SettingsUtils'; +import { SettingTab, UserSetting } from '../types'; const Component = () => { return <AutomatedAnalysisConfigForm inlineForm />; }; -export const AutomatedAnalysisConfig: UserSetting = { +export const AutomatedAnalysis: UserSetting = { titleKey: 'SETTINGS.AUTOMATED_ANALYSIS_CONFIG.TITLE', descConstruct: 'SETTINGS.AUTOMATED_ANALYSIS_CONFIG.DESCRIPTION', content: Component, diff --git a/src/app/Settings/ChartCardsConfig.tsx b/src/app/Settings/Config/ChartCards.tsx similarity index 95% rename from src/app/Settings/ChartCardsConfig.tsx rename to src/app/Settings/Config/ChartCards.tsx index fbb24c111..91190728d 100644 --- a/src/app/Settings/ChartCardsConfig.tsx +++ b/src/app/Settings/Config/ChartCards.tsx @@ -18,7 +18,7 @@ import { ServiceContext } from '@app/Shared/Services/Services'; import { FormGroup, HelperText, HelperTextItem, NumberInput, Stack, StackItem } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { SettingTab, UserSetting } from './SettingsUtils'; +import { SettingTab, UserSetting } from '../types'; const min = 1; @@ -73,7 +73,7 @@ const Component = () => { ); }; -export const ChartCardsConfig: UserSetting = { +export const ChartCards: UserSetting = { titleKey: 'SETTINGS.CHARTS_CONFIG.TITLE', descConstruct: 'SETTINGS.CHARTS_CONFIG.DESCRIPTION', content: Component, diff --git a/src/app/Settings/CredentialsStorage.tsx b/src/app/Settings/Config/CredentialsStorage.tsx similarity index 98% rename from src/app/Settings/CredentialsStorage.tsx rename to src/app/Settings/Config/CredentialsStorage.tsx index 258e3a7fc..b3fefd223 100644 --- a/src/app/Settings/CredentialsStorage.tsx +++ b/src/app/Settings/Config/CredentialsStorage.tsx @@ -19,7 +19,7 @@ import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { SettingTab, UserSetting } from './SettingsUtils'; +import { SettingTab, UserSetting } from '../types'; export interface Location { key: string; diff --git a/src/app/Settings/DatetimeControl.tsx b/src/app/Settings/Config/DatetimeControl.tsx similarity index 97% rename from src/app/Settings/DatetimeControl.tsx rename to src/app/Settings/Config/DatetimeControl.tsx index 4bacfea47..a4fd434aa 100644 --- a/src/app/Settings/DatetimeControl.tsx +++ b/src/app/Settings/Config/DatetimeControl.tsx @@ -15,12 +15,12 @@ */ import { TimezonePicker } from '@app/DateTimePicker/TimezonePicker'; import { ServiceContext } from '@app/Shared/Services/Services'; -import useDayjs from '@app/utils/useDayjs'; +import useDayjs from '@app/utils/hooks/useDayjs'; import { locales, Timezone } from '@i18n/datetime'; import { FormGroup, HelperText, HelperTextItem, Select, SelectOption, Stack, StackItem } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { SettingTab, UserSetting } from './SettingsUtils'; +import { SettingTab, UserSetting } from '../types'; const Component = () => { const [t] = useTranslation(); diff --git a/src/app/Settings/DeletionDialogControl.tsx b/src/app/Settings/Config/DeletionDialogControl.tsx similarity index 95% rename from src/app/Settings/DeletionDialogControl.tsx rename to src/app/Settings/Config/DeletionDialogControl.tsx index 5ab2f523b..39a4a42aa 100644 --- a/src/app/Settings/DeletionDialogControl.tsx +++ b/src/app/Settings/Config/DeletionDialogControl.tsx @@ -14,7 +14,8 @@ * limitations under the License. */ -import { DeleteOrDisableWarningType, getFromWarningMap } from '@app/Modal/DeleteWarningUtils'; +import { DeleteOrDisableWarningType } from '@app/Modal/types'; +import { getFromWarningMap } from '@app/Modal/utils'; import { ServiceContext } from '@app/Shared/Services/Services'; import { ExpandableSection, @@ -27,7 +28,7 @@ import { } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { SettingTab, UserSetting } from './SettingsUtils'; +import { SettingTab, UserSetting } from '../types'; const Component = () => { const [t] = useTranslation(); diff --git a/src/app/Settings/FeatureLevels.tsx b/src/app/Settings/Config/FeatureLevels.tsx similarity index 93% rename from src/app/Settings/FeatureLevels.tsx rename to src/app/Settings/Config/FeatureLevels.tsx index 37b0a2be3..efa33df10 100644 --- a/src/app/Settings/FeatureLevels.tsx +++ b/src/app/Settings/Config/FeatureLevels.tsx @@ -14,13 +14,13 @@ * limitations under the License. */ +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { Select, SelectOption } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { SettingTab, UserSetting } from './SettingsUtils'; +import { SettingTab, UserSetting } from '../types'; const Component = () => { const [t] = useTranslation(); diff --git a/src/app/Settings/Language.tsx b/src/app/Settings/Config/Language.tsx similarity index 94% rename from src/app/Settings/Language.tsx rename to src/app/Settings/Config/Language.tsx index c06fc3bf8..030010a00 100644 --- a/src/app/Settings/Language.tsx +++ b/src/app/Settings/Config/Language.tsx @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { i18nLanguages, i18nResources } from '@i18n/config'; import { localeReadable } from '@i18n/i18nextUtil'; import { Select, SelectOption } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { SettingTab, UserSetting } from './SettingsUtils'; +import { SettingTab, UserSetting } from '../types'; const Component = () => { const [t, i18n] = useTranslation(); diff --git a/src/app/Settings/NotificationControl.tsx b/src/app/Settings/Config/NotificationControl.tsx similarity index 95% rename from src/app/Settings/NotificationControl.tsx rename to src/app/Settings/Config/NotificationControl.tsx index 5acd3aec6..c9dcc6d08 100644 --- a/src/app/Settings/NotificationControl.tsx +++ b/src/app/Settings/Config/NotificationControl.tsx @@ -13,9 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotificationCategory, messageKeys } from '@app/Shared/Services/NotificationChannel.service'; + +import { NotificationCategory } from '@app/Shared/Services/api.types'; +import { messageKeys } from '@app/Shared/Services/api.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { ExpandableSection, Switch, @@ -28,7 +30,7 @@ import { } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { SettingTab, UserSetting } from './SettingsUtils'; +import { SettingTab, UserSetting } from '../types'; const min = 0; const max = 10; diff --git a/src/app/Settings/Theme.tsx b/src/app/Settings/Config/Theme.tsx similarity index 94% rename from src/app/Settings/Theme.tsx rename to src/app/Settings/Config/Theme.tsx index cee090a84..fb535109e 100644 --- a/src/app/Settings/Theme.tsx +++ b/src/app/Settings/Config/Theme.tsx @@ -14,11 +14,11 @@ * limitations under the License. */ import { ServiceContext } from '@app/Shared/Services/Services'; -import { useTheme } from '@app/utils/useTheme'; +import { useTheme } from '@app/utils/hooks/useTheme'; import { Select, SelectOption } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { SettingTab, ThemeSetting, UserSetting } from './SettingsUtils'; +import { SettingTab, ThemeSetting, UserSetting } from '../types'; const Component = () => { const { t } = useTranslation(); diff --git a/src/app/Settings/WebSocketDebounce.tsx b/src/app/Settings/Config/WebSocketDebounce.tsx similarity index 98% rename from src/app/Settings/WebSocketDebounce.tsx rename to src/app/Settings/Config/WebSocketDebounce.tsx index efb95238a..44227525e 100644 --- a/src/app/Settings/WebSocketDebounce.tsx +++ b/src/app/Settings/Config/WebSocketDebounce.tsx @@ -17,7 +17,7 @@ import { ServiceContext } from '@app/Shared/Services/Services'; import { NumberInput } from '@patternfly/react-core'; import * as React from 'react'; -import { SettingTab, UserSetting } from './SettingsUtils'; +import { SettingTab, UserSetting } from '../types'; const defaultPreferences = { webSocketDebounceMs: 100, diff --git a/src/app/Settings/Settings.tsx b/src/app/Settings/Settings.tsx index ac422d5cb..92f75f91e 100644 --- a/src/app/Settings/Settings.tsx +++ b/src/app/Settings/Settings.tsx @@ -15,9 +15,9 @@ */ import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; -import { FeatureFlag } from '@app/Shared/FeatureFlag/FeatureFlag'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { useLogin } from '@app/utils/useLogin'; +import { FeatureFlag } from '@app/Shared/Components/FeatureFlag'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; +import { useLogin } from '@app/utils/hooks/useLogin'; import { cleanDataId, getActiveTab, hashCode, switchTab } from '@app/utils/utils'; import { Card, @@ -38,47 +38,19 @@ import { import * as React from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useHistory, useLocation } from 'react-router-dom'; -import { AutomatedAnalysisConfig } from './AutomatedAnalysisConfig'; -import { AutoRefresh } from './AutoRefresh'; -import { ChartCardsConfig } from './ChartCardsConfig'; -import { CredentialsStorage } from './CredentialsStorage'; -import { DatetimeControl } from './DatetimeControl'; -import { DeletionDialogControl } from './DeletionDialogControl'; -import { FeatureLevels } from './FeatureLevels'; -import { Language } from './Language'; -import { NotificationControl } from './NotificationControl'; -import { paramAsTab, SettingTab, tabAsParam, _TransformedUserSetting } from './SettingsUtils'; -import { Theme } from './Theme'; -import { WebSocketDebounce } from './WebSocketDebounce'; - -export const allSettings = [ - NotificationControl, - AutomatedAnalysisConfig, - ChartCardsConfig, - CredentialsStorage, - DeletionDialogControl, - WebSocketDebounce, - AutoRefresh, - FeatureLevels, - Language, - DatetimeControl, - Theme, -]; - -interface SettingGroup { - groupLabel: SettingTab; - groupKey: string; - featureLevel: FeatureLevel; - disabled?: boolean; - settings: _TransformedUserSetting[]; -} - -const _getGroupFeatureLevel = (settings: _TransformedUserSetting[]): FeatureLevel => { - if (!settings.length) { - return FeatureLevel.DEVELOPMENT; - } - return settings.slice().sort((a, b) => b.featureLevel - a.featureLevel)[0].featureLevel; -}; +import { AutomatedAnalysis } from './Config/AutomatedAnalysis'; +import { AutoRefresh } from './Config/AutoRefresh'; +import { ChartCards } from './Config/ChartCards'; +import { CredentialsStorage } from './Config/CredentialsStorage'; +import { DatetimeControl } from './Config/DatetimeControl'; +import { DeletionDialogControl } from './Config/DeletionDialogControl'; +import { FeatureLevels } from './Config/FeatureLevels'; +import { Language } from './Config/Language'; +import { NotificationControl } from './Config/NotificationControl'; +import { Theme } from './Config/Theme'; +import { WebSocketDebounce } from './Config/WebSocketDebounce'; +import { SettingGroup, SettingTab, _TransformedUserSetting } from './types'; +import { paramAsTab, tabAsParam, getGroupFeatureLevel } from './utils'; export interface SettingsProps {} @@ -88,7 +60,19 @@ export const Settings: React.FC<SettingsProps> = (_) => { const settings = React.useMemo( () => - allSettings + [ + NotificationControl, + AutomatedAnalysis, + ChartCards, + CredentialsStorage, + DeletionDialogControl, + WebSocketDebounce, + AutoRefresh, + FeatureLevels, + Language, + DatetimeControl, + Theme, + ] .filter((s) => !s.authenticated || loggedIn) .map( (c) => @@ -139,7 +123,7 @@ export const Settings: React.FC<SettingsProps> = (_) => { groupLabel: t(cat), groupKey: cat, settings: panels, - featureLevel: _getGroupFeatureLevel(panels), + featureLevel: getGroupFeatureLevel(panels), }; }) as SettingGroup[]; }, [settings, t]); diff --git a/src/app/Settings/SettingsUtils.ts b/src/app/Settings/types.ts similarity index 69% rename from src/app/Settings/SettingsUtils.ts rename to src/app/Settings/types.ts index 48d65db94..53c873cb5 100644 --- a/src/app/Settings/SettingsUtils.ts +++ b/src/app/Settings/types.ts @@ -13,8 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { hashCode } from '@app/utils/utils'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; export interface _TransformedUserSetting extends Omit<UserSetting, 'content'> { title: string; @@ -32,15 +31,6 @@ export enum SettingTab { ADVANCED = 'SETTINGS.CATEGORIES.ADVANCED', } -export const tabAsParam = (key: SettingTab) => { - const parts = key.split('.'); - return parts[parts.length - 1].toLowerCase().replace(/[_]/g, '-'); -}; - -export const paramAsTab = (param: string) => { - return `SETTINGS.CATEGORIES.${param.toUpperCase().replace(/[-]/g, '_')}`; -}; - export interface UserSetting { titleKey: string; disabled?: boolean; @@ -59,18 +49,6 @@ export interface UserSetting { authenticated?: boolean; } -export const selectTab = (tabKey: SettingTab) => { - const tab = document.getElementById(`pf-tab-${tabKey}-${hashCode(tabKey)}`); - tab && tab.click(); -}; - -export const getDefaultTheme = (): ThemeSetting => { - if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - return ThemeSetting.DARK; - } - return ThemeSetting.LIGHT; -}; - export enum ThemeSetting { AUTO = 'auto', DARK = 'dark', @@ -78,3 +56,11 @@ export enum ThemeSetting { } export type ThemeType = Exclude<ThemeSetting, ThemeSetting.AUTO>; + +export interface SettingGroup { + groupLabel: SettingTab; + groupKey: string; + featureLevel: FeatureLevel; + disabled?: boolean; + settings: _TransformedUserSetting[]; +} diff --git a/src/app/Settings/utils.ts b/src/app/Settings/utils.ts new file mode 100644 index 000000000..bc85fcd03 --- /dev/null +++ b/src/app/Settings/utils.ts @@ -0,0 +1,47 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FeatureLevel } from '@app/Shared/Services/service.types'; +import { hashCode } from '@app/utils/utils'; +import { SettingTab, ThemeSetting, _TransformedUserSetting } from './types'; + +export const tabAsParam = (key: SettingTab) => { + const parts = key.split('.'); + return parts[parts.length - 1].toLowerCase().replace(/[_]/g, '-'); +}; + +export const paramAsTab = (param: string) => { + return `SETTINGS.CATEGORIES.${param.toUpperCase().replace(/[-]/g, '_')}`; +}; + +export const selectTab = (tabKey: SettingTab) => { + const tab = document.getElementById(`pf-tab-${tabKey}-${hashCode(tabKey)}`); + tab && tab.click(); +}; + +export const getDefaultTheme = (): ThemeSetting => { + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return ThemeSetting.DARK; + } + return ThemeSetting.LIGHT; +}; + +export const getGroupFeatureLevel = (settings: _TransformedUserSetting[]): FeatureLevel => { + if (!settings.length) { + return FeatureLevel.DEVELOPMENT; + } + return settings.slice().sort((a, b) => b.featureLevel - a.featureLevel)[0].featureLevel; +}; diff --git a/src/app/Topology/Shared/EmptyText.tsx b/src/app/Shared/Components/EmptyText.tsx similarity index 100% rename from src/app/Topology/Shared/EmptyText.tsx rename to src/app/Shared/Components/EmptyText.tsx diff --git a/src/app/Shared/ErrorBoundary.tsx b/src/app/Shared/Components/ErrorBoundary.tsx similarity index 96% rename from src/app/Shared/ErrorBoundary.tsx rename to src/app/Shared/Components/ErrorBoundary.tsx index 9d2db6c4e..a9e44d721 100644 --- a/src/app/Shared/ErrorBoundary.tsx +++ b/src/app/Shared/Components/ErrorBoundary.tsx @@ -15,7 +15,7 @@ */ import build from '@app/build.json'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { Bullseye, EmptyState, @@ -29,7 +29,7 @@ import { import { ExclamationCircleIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import { ServiceContext } from './Services/Services'; +import { ServiceContext } from '../Services/Services'; export interface ErrorBoundaryProps { renderFallback: (error: Error) => React.ReactNode; diff --git a/src/app/Shared/FeatureFlag/FeatureFlag.tsx b/src/app/Shared/Components/FeatureFlag.tsx similarity index 86% rename from src/app/Shared/FeatureFlag/FeatureFlag.tsx rename to src/app/Shared/Components/FeatureFlag.tsx index 1ac2febdb..ff383e91b 100644 --- a/src/app/Shared/FeatureFlag/FeatureFlag.tsx +++ b/src/app/Shared/Components/FeatureFlag.tsx @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import * as React from 'react'; export interface DynamicFeatureFlagProps { @@ -54,11 +54,7 @@ export const FeatureFlag: React.FC<FeatureFlagProps> = ({ level, strict, childre () => (strict ? [level] : [...Array.from({ length: level + 1 }, (_, i) => i)]), [strict, level], ); - const component = React.useCallback((_) => <>{children}</>, [children]); + const component = React.useCallback((_: FeatureLevel) => <>{children}</>, [children]); - return ( - <> - <DynamicFeatureFlag levels={levels} component={component} /> - </> - ); + return <DynamicFeatureFlag levels={levels} component={component} />; }; diff --git a/src/app/Shared/FileUploads.tsx b/src/app/Shared/Components/FileUploads.tsx similarity index 99% rename from src/app/Shared/FileUploads.tsx rename to src/app/Shared/Components/FileUploads.tsx index 5eaca7d17..b42600251 100644 --- a/src/app/Shared/FileUploads.tsx +++ b/src/app/Shared/Components/FileUploads.tsx @@ -15,7 +15,7 @@ */ import { CancelUploadModal } from '@app/Modal/CancelUploadModal'; -import { NotificationsContext } from '@app/Notifications/Notifications'; +import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; import { MultipleFileUpload, MultipleFileUploadMain, diff --git a/src/app/Shared/LinearDotSpinner.tsx b/src/app/Shared/Components/LinearDotSpinner.tsx similarity index 100% rename from src/app/Shared/LinearDotSpinner.tsx rename to src/app/Shared/Components/LinearDotSpinner.tsx diff --git a/src/app/LoadingView/LoadingView.tsx b/src/app/Shared/Components/LoadingView.tsx similarity index 90% rename from src/app/LoadingView/LoadingView.tsx rename to src/app/Shared/Components/LoadingView.tsx index 827095fed..37d989526 100644 --- a/src/app/LoadingView/LoadingView.tsx +++ b/src/app/Shared/Components/LoadingView.tsx @@ -20,14 +20,14 @@ export interface LoadingViewProps { title?: string; } -export const LoadingView: React.FC<LoadingViewProps> = (props) => { +export const LoadingView: React.FC<LoadingViewProps> = ({ title = 'Loading' }) => { return ( <> <Bullseye> <EmptyState> <EmptyStateIcon variant="container" component={Spinner} /> <Title size="lg" headingLevel="h2"> - {props.title || 'Loading'} + {title} diff --git a/src/app/Shared/MatchExpression/MatchExpressionHint.tsx b/src/app/Shared/Components/MatchExpression/MatchExpressionHint.tsx similarity index 97% rename from src/app/Shared/MatchExpression/MatchExpressionHint.tsx rename to src/app/Shared/Components/MatchExpression/MatchExpressionHint.tsx index 56fd5f154..5efff8e5b 100644 --- a/src/app/Shared/MatchExpression/MatchExpressionHint.tsx +++ b/src/app/Shared/Components/MatchExpression/MatchExpressionHint.tsx @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { Target } from '@app/Shared/Services/api.types'; import { ClipboardCopyButton, CodeBlock, CodeBlockAction, CodeBlockCode } from '@patternfly/react-core'; import * as React from 'react'; -import { Target } from '../Services/Target.service'; export interface MatchExpressionHintProps { target?: Target; diff --git a/src/app/Shared/MatchExpression/MatchExpressionVisualizer.tsx b/src/app/Shared/Components/MatchExpression/MatchExpressionVisualizer.tsx similarity index 94% rename from src/app/Shared/MatchExpression/MatchExpressionVisualizer.tsx rename to src/app/Shared/Components/MatchExpression/MatchExpressionVisualizer.tsx index 63f4a4ddb..128be53f4 100644 --- a/src/app/Shared/MatchExpression/MatchExpressionVisualizer.tsx +++ b/src/app/Shared/Components/MatchExpression/MatchExpressionVisualizer.tsx @@ -13,16 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LoadingView } from '@app/LoadingView/LoadingView'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { Target, NodeType } from '@app/Shared/Services/api.types'; +import { MatchedTargetsServiceContext } from '@app/Shared/Services/service.utils'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import EntityDetails, { AlertOptions } from '@app/Topology/Entity/EntityDetails'; import { TopologyControlBar } from '@app/Topology/GraphView/TopologyControlBar'; import { SavedGraphPosition, SavedNodePosition } from '@app/Topology/GraphView/TopologyGraphView'; -import { getNodeById } from '@app/Topology/GraphView/UtilsFactory'; -import EntityDetails, { AlertOptions } from '@app/Topology/Shared/Entity/EntityDetails'; -import { MatchedTargetsServiceContext, useExprSvc, useMatchedTargetsSvcSource } from '@app/Topology/Shared/utils'; +import { getNodeById } from '@app/Topology/GraphView/utils'; import { TopologySideBar } from '@app/Topology/SideBar/TopologySideBar'; -import { NodeType } from '@app/Topology/typings'; +import { useMatchedTargetsSvcSource } from '@app/utils/hooks/useMatchedTargetsSvcSource'; +import { useMatchExpressionSvc } from '@app/utils/hooks/useMatchExpressionSvc'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { getFromLocalStorage, saveToLocalStorage } from '@app/utils/LocalStorage'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; import { hashCode } from '@app/utils/utils'; import { Bullseye, @@ -63,8 +66,6 @@ import { import _ from 'lodash'; import * as React from 'react'; import { catchError, combineLatest, of, switchMap, tap } from 'rxjs'; -import { ServiceContext } from '../Services/Services'; -import { Target } from '../Services/Target.service'; import { componentFactory, createTargetNode, layoutFactory, transformData } from './utils'; export interface MatchExpressionVisualizerProps { @@ -299,7 +300,7 @@ const GraphView: React.FC<{ alertOptions?: AlertOptions }> = ({ alertOptions, .. const ListView: React.FC<{ alertOptions?: AlertOptions }> = ({ alertOptions, ...props }) => { const addSubscription = useSubscriptions(); const context = React.useContext(ServiceContext); - const matchExprService = useExprSvc(); + const matchExprService = useMatchExpressionSvc(); const [matchedExpr, setMatchExpr] = React.useState(''); const [matchedTargets, setMatchedTargets] = React.useState([]); diff --git a/src/app/Shared/MatchExpression/utils.tsx b/src/app/Shared/Components/MatchExpression/utils.tsx similarity index 96% rename from src/app/Shared/MatchExpression/utils.tsx rename to src/app/Shared/Components/MatchExpression/utils.tsx index dc06febc1..0ff572697 100644 --- a/src/app/Shared/MatchExpression/utils.tsx +++ b/src/app/Shared/Components/MatchExpression/utils.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ +import { Target, TargetNode, NodeType } from '@app/Shared/Services/api.types'; import CustomNode from '@app/Topology/GraphView/CustomNode'; -import { NodeType, TargetNode } from '@app/Topology/typings'; import { hashCode } from '@app/utils/utils'; import { ComponentFactory, @@ -35,7 +35,6 @@ import { withPanZoom, withSelection, } from '@patternfly/react-topology'; -import { Target } from '../Services/Target.service'; export const layoutFactory: LayoutFactory = (type: string, graph: Graph): Layout | undefined => { switch (type) { diff --git a/src/app/Topology/Shared/QuickSearchIcon.tsx b/src/app/Shared/Components/QuickSearchIcon.tsx similarity index 100% rename from src/app/Topology/Shared/QuickSearchIcon.tsx rename to src/app/Shared/Components/QuickSearchIcon.tsx diff --git a/src/app/Shared/SelectTemplateSelectorForm.tsx b/src/app/Shared/Components/SelectTemplateSelectorForm.tsx similarity index 90% rename from src/app/Shared/SelectTemplateSelectorForm.tsx rename to src/app/Shared/Components/SelectTemplateSelectorForm.tsx index 2fd06de03..ac69afee6 100644 --- a/src/app/Shared/SelectTemplateSelectorForm.tsx +++ b/src/app/Shared/Components/SelectTemplateSelectorForm.tsx @@ -14,10 +14,11 @@ * limitations under the License. */ // -import { EventTemplate } from '@app/CreateRecording/CreateRecording'; -import { TemplateType } from '@app/Shared/Services/Api.service'; + +import { EventTemplateIdentifier } from '@app/CreateRecording/types'; import { FormSelect, FormSelectOption, FormSelectOptionGroup, ValidatedOptions } from '@patternfly/react-core'; import * as React from 'react'; +import { EventTemplate, TemplateType } from '../Services/api.types'; export interface TemplateSelectionGroup { groupLabel: string; @@ -34,7 +35,7 @@ export interface SelectTemplateSelectorFormProps { templates: EventTemplate[]; disabled?: boolean; validated?: ValidatedOptions; - onSelect: (template?: string, templateType?: TemplateType) => void; + onSelect: (template?: EventTemplateIdentifier) => void; } export const SelectTemplateSelectorForm: React.FC = ({ @@ -72,10 +73,13 @@ export const SelectTemplateSelectorForm: React.FC { if (!selected.length) { - onSelect(undefined, undefined); + onSelect(undefined); } else { const str = selected.split(','); - onSelect(str[0], str[1] as TemplateType); + onSelect({ + name: str[0], + type: str[1] as TemplateType, + }); } }, [onSelect], diff --git a/src/app/Shared/ProgressIndicator.tsx b/src/app/Shared/Components/types.ts similarity index 96% rename from src/app/Shared/ProgressIndicator.tsx rename to src/app/Shared/Components/types.ts index 7d6c2aa59..98052db17 100644 --- a/src/app/Shared/ProgressIndicator.tsx +++ b/src/app/Shared/Components/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export interface LoadingPropsType { +export interface LoadingProps { spinnerAriaValueText?: string; // Text describing that current loading status or progress spinnerAriaLabelledBy?: string; // Id of element which describes what is being loaded spinnerAriaLabel?: string; // Accessible label for the spinner to describe what is loading diff --git a/src/app/Shared/Redux/Configurations/DashboardConfigSlice.tsx b/src/app/Shared/Redux/Configurations/DashboardConfigSlice.tsx index 663f33cfc..f33379442 100644 --- a/src/app/Shared/Redux/Configurations/DashboardConfigSlice.tsx +++ b/src/app/Shared/Redux/Configurations/DashboardConfigSlice.tsx @@ -15,12 +15,7 @@ */ import { MBeanMetricsChartCardDescriptor } from '@app/Dashboard/Charts/mbean/MBeanMetricsChartCard'; -import { - DashboardLayout, - LayoutTemplate, - LayoutTemplateRecord, - LayoutTemplateVendor, -} from '@app/Dashboard/dashboard-utils'; +import { DashboardLayout, LayoutTemplate, LayoutTemplateRecord, LayoutTemplateVendor } from '@app/Dashboard/types'; import { move, swap } from '@app/utils/utils'; import { gridSpans } from '@patternfly/react-core'; import { createAction, createReducer } from '@reduxjs/toolkit'; diff --git a/src/app/Shared/Redux/Filters/TopologyFilterSlice.tsx b/src/app/Shared/Redux/Filters/TopologyFilterSlice.tsx index 79fc739db..f6907a4ed 100644 --- a/src/app/Shared/Redux/Filters/TopologyFilterSlice.tsx +++ b/src/app/Shared/Redux/Filters/TopologyFilterSlice.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { EnvironmentNode, NodeType, TargetNode } from '@app/Topology/typings'; +import { NodeType, EnvironmentNode, TargetNode } from '@app/Shared/Services/api.types'; import { createAction, createReducer } from '@reduxjs/toolkit'; import { ReducerWithInitialState } from '@reduxjs/toolkit/dist/createReducer'; import { getPersistedState } from '../utils'; diff --git a/src/app/Shared/Redux/Middlewares/PersistMiddleware.tsx b/src/app/Shared/Redux/Middlewares/PersistMiddleware.tsx index 9853a5978..95644dcc4 100644 --- a/src/app/Shared/Redux/Middlewares/PersistMiddleware.tsx +++ b/src/app/Shared/Redux/Middlewares/PersistMiddleware.tsx @@ -20,7 +20,7 @@ import { enumValues as TopologyConfigActions } from '../Configurations/TopologyC import { enumValues as AutomatedAnalysisFilterActions } from '../Filters/AutomatedAnalysisFilterSlice'; import { enumValues as RecordingFilterActions } from '../Filters/RecordingFilterSlice'; import { enumValues as TopologyFilterActions } from '../Filters/TopologyFilterSlice'; -import { RootState } from '../ReduxStore'; +import type { RootState } from '../ReduxStore'; /* eslint-disable-next-line @typescript-eslint/ban-types*/ export const persistMiddleware: Middleware<{}, RootState> = diff --git a/src/app/Shared/Redux/utils.ts b/src/app/Shared/Redux/utils.ts index 79ebedf5f..6ab2b3803 100644 --- a/src/app/Shared/Redux/utils.ts +++ b/src/app/Shared/Redux/utils.ts @@ -17,8 +17,8 @@ import { getFromLocalStorage, LocalStorageKeyStrings } from '@app/utils/LocalStorage'; export const getPersistedState = (key: LocalStorageKeyStrings, _version: string, defaultConfig: T): T => { - const persisted = getFromLocalStorage(key, undefined); - if (!persisted || persisted._version !== _version) { + const persisted = getFromLocalStorage(key, defaultConfig); + if (!persisted || persisted['_version'] !== _version) { return { ...defaultConfig, _version, diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index a8833ef81..6d9580f54 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -14,59 +14,59 @@ * limitations under the License. */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { LayoutTemplate, SerialLayoutTemplate } from '@app/Dashboard/dashboard-utils'; -import { EventType } from '@app/Events/EventTypes'; -import { Notifications } from '@app/Notifications/Notifications'; -import { RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; -import { Rule } from '@app/Rules/Rules'; -import { EnvironmentNode } from '@app/Topology/typings'; +import { LayoutTemplate, SerialLayoutTemplate } from '@app/Dashboard/types'; +import { RecordingLabel } from '@app/RecordingMetadata/types'; import { createBlobURL, jvmIdToSubdirectoryName } from '@app/utils/utils'; import { ValidatedOptions } from '@patternfly/react-core'; import _ from 'lodash'; import { EMPTY, forkJoin, from, Observable, ObservableInput, of, ReplaySubject, shareReplay, throwError } from 'rxjs'; import { fromFetch } from 'rxjs/fetch'; import { catchError, concatMap, filter, first, map, mergeMap, tap } from 'rxjs/operators'; -import { AuthMethod, LoginService, SessionState } from './Login.service'; -import { NotificationCategory } from './NotificationChannel.service'; -import { NO_TARGET, Target, TargetService, includesTarget } from './Target.service'; - -type ApiVersion = 'v1' | 'v2' | 'v2.1' | 'v2.2' | 'beta'; - -export class HttpError extends Error { - readonly httpResponse: Response; - - constructor(httpResponse: Response) { - super(httpResponse.statusText); - this.httpResponse = httpResponse; - } -} - -export class XMLHttpError extends Error { - readonly xmlHttpResponse: XMLHttpResponse; - - constructor(xmlHttpResponse: XMLHttpResponse) { - super(xmlHttpResponse.statusText); - this.xmlHttpResponse = xmlHttpResponse; - } -} - -export const isHttpError = (err: unknown): err is HttpError => { - if (!(err instanceof Error)) { - return false; - } - return (err as HttpError).httpResponse !== undefined; -}; - -export const isXMLHttpError = (err: unknown): err is XMLHttpError => { - if (!(err instanceof Error)) { - return false; - } - return (err as XMLHttpError).xmlHttpResponse !== undefined; -}; - -export const isHttpOk = (statusCode: number) => { - return statusCode >= 200 && statusCode < 300; -}; +import { + GrafanaDatasourceUrlGetResponse, + GrafanaDashboardUrlGetResponse, + HealthGetResponse, + Target, + Rule, + RecordingAttributes, + ActiveRecording, + RecordingResponse, + ApiVersion, + ProbeTemplate, + ProbeTemplateResponse, + EventProbe, + EventProbesResponse, + Recording, + AssetJwtResponse, + EventTemplate, + RuleResponse, + ArchivedRecording, + UPLOADS_SUBDIRECTORY, + MatchedCredential, + CredentialResponse, + StoredCredential, + CredentialsResponse, + RulesResponse, + EnvironmentNode, + DiscoveryResponse, + ActiveRecordingFilterInput, + RecordingCountResponse, + MBeanMetrics, + MBeanMetricsResponse, + EventType, + NotificationCategory, + NullableTarget, + HttpError, + SimpleResponse, + XMLHttpError, + XMLHttpRequestConfig, + XMLHttpResponse, +} from './api.types'; +import { isHttpError, isActiveRecording, includesTarget, isHttpOk, isXMLHttpError } from './api.utils'; +import { LoginService } from './Login.service'; +import { NotificationService } from './Notifications.service'; +import { SessionState, AuthMethod } from './service.types'; +import { TargetService } from './Target.service'; export class ApiService { private readonly archiveEnabled = new ReplaySubject(1); @@ -76,7 +76,7 @@ export class ApiService { constructor( private readonly target: TargetService, - private readonly notifications: Notifications, + private readonly notifications: NotificationService, private readonly login: LoginService, ) { // show recording archives when recordings available @@ -293,46 +293,52 @@ export class ApiService { ); } - createRecording(recordingAttributes: RecordingAttributes): Observable { + createRecording({ + name, + events, + duration, + restart, + archiveOnStop, + metadata, + advancedOptions, + }: RecordingAttributes): Observable { const form = new window.FormData(); - form.append('recordingName', recordingAttributes.name); - form.append('events', recordingAttributes.events); - if (!!recordingAttributes.duration && recordingAttributes.duration > 0) { - form.append('duration', String(recordingAttributes.duration)); + form.append('recordingName', name); + form.append('events', events); + if (duration && duration > 0) { + form.append('duration', String(duration)); } - if (recordingAttributes.archiveOnStop != null) { - form.append('archiveOnStop', String(recordingAttributes.archiveOnStop)); + if (archiveOnStop != undefined) { + form.append('archiveOnStop', String(archiveOnStop)); } - if (recordingAttributes.options) { - if (recordingAttributes.options.restart) { - form.append('restart', String(recordingAttributes.options.restart)); - } - if (recordingAttributes.options.toDisk != null) { - form.append('toDisk', String(recordingAttributes.options.toDisk)); + if (metadata) { + form.append('metadata', JSON.stringify(metadata)); + } + if (restart != undefined) { + form.append('restart', String(restart)); + } + if (advancedOptions) { + if (advancedOptions.toDisk != undefined) { + form.append('toDisk', String(advancedOptions.toDisk)); } - if (!!recordingAttributes.options.maxAge && recordingAttributes.options.maxAge >= 0) { - form.append('maxAge', String(recordingAttributes.options.maxAge)); + if (advancedOptions.maxAge && advancedOptions.maxAge >= 0) { + form.append('maxAge', String(advancedOptions.maxAge)); } - if (!!recordingAttributes.options.maxSize && recordingAttributes.options.maxSize >= 0) { - form.append('maxSize', String(recordingAttributes.options.maxSize)); + if (advancedOptions.maxSize && advancedOptions.maxSize >= 0) { + form.append('maxSize', String(advancedOptions.maxSize)); } } - if (recordingAttributes.metadata) { - form.append('metadata', JSON.stringify(recordingAttributes.metadata)); - } return this.target.target().pipe( concatMap((target) => - this.sendRequest('v1', `targets/${encodeURIComponent(target.connectUrl)}/recordings`, { + this.sendRequest('v1', `targets/${encodeURIComponent(target?.connectUrl || '')}/recordings`, { method: 'POST', body: form, }).pipe( - map((resp) => { - return { - ok: resp.ok, - status: resp.status, - }; - }), + map((resp) => ({ + ok: resp.ok, + status: resp.status, + })), catchError((err) => { if (isHttpError(err)) { return of({ @@ -352,7 +358,7 @@ export class ApiService { createSnapshot(): Observable { return this.target.target().pipe( concatMap((target) => - this.sendRequest('v1', `targets/${encodeURIComponent(target.connectUrl)}/snapshot`, { + this.sendRequest('v1', `targets/${encodeURIComponent(target?.connectUrl || '')}/snapshot`, { method: 'POST', }).pipe( tap((resp) => { @@ -374,7 +380,7 @@ export class ApiService { createSnapshotV2(): Observable { return this.target.target().pipe( concatMap((target) => - this.sendRequest('v2', `targets/${encodeURIComponent(target.connectUrl)}/snapshot`, { + this.sendRequest('v2', `targets/${encodeURIComponent(target?.connectUrl || '')}/snapshot`, { method: 'POST', }).pipe( concatMap((resp) => resp.json() as Promise), @@ -395,7 +401,7 @@ export class ApiService { concatMap((target) => this.sendRequest( 'v1', - `targets/${encodeURIComponent(target.connectUrl)}/recordings/${encodeURIComponent(recordingName)}`, + `targets/${encodeURIComponent(target?.connectUrl || '')}/recordings/${encodeURIComponent(recordingName)}`, { method: 'PATCH', body: 'SAVE', @@ -413,7 +419,7 @@ export class ApiService { concatMap((target) => this.sendRequest( 'v1', - `targets/${encodeURIComponent(target.connectUrl)}/recordings/${encodeURIComponent(recordingName)}`, + `targets/${encodeURIComponent(target?.connectUrl || '')}/recordings/${encodeURIComponent(recordingName)}`, { method: 'PATCH', body: 'STOP', @@ -431,7 +437,7 @@ export class ApiService { concatMap((target) => this.sendRequest( 'v1', - `targets/${encodeURIComponent(target.connectUrl)}/recordings/${encodeURIComponent(recordingName)}`, + `targets/${encodeURIComponent(target?.connectUrl || '')}/recordings/${encodeURIComponent(recordingName)}`, { method: 'DELETE', }, @@ -461,7 +467,9 @@ export class ApiService { concatMap((target) => this.sendRequest( 'v1', - `targets/${encodeURIComponent(target.connectUrl)}/recordings/${encodeURIComponent(recordingName)}/upload`, + `targets/${encodeURIComponent(target?.connectUrl || '')}/recordings/${encodeURIComponent( + recordingName, + )}/upload`, { method: 'POST', }, @@ -473,12 +481,15 @@ export class ApiService { ); } - uploadArchivedRecordingToGrafana(sourceTarget: Observable, recordingName: string): Observable { + uploadArchivedRecordingToGrafana( + sourceTarget: Observable, + recordingName: string, + ): Observable { return sourceTarget.pipe( concatMap((target) => this.sendRequest( 'beta', - `recordings/${encodeURIComponent(target.connectUrl)}/${encodeURIComponent(recordingName)}/upload`, + `recordings/${encodeURIComponent(target?.connectUrl || '')}/${encodeURIComponent(recordingName)}/upload`, { method: 'POST', }, @@ -500,6 +511,7 @@ export class ApiService { first(), ); } + deleteArchivedRecordingFromPath(jvmId: string, recordingName: string): Observable { const subdirectoryName = jvmIdToSubdirectoryName(jvmId); return this.sendRequest('beta', `fs/recordings/${subdirectoryName}/${encodeURIComponent(recordingName)}`, { @@ -583,7 +595,7 @@ export class ApiService { removeProbes(): Observable { return this.target.target().pipe( concatMap((target) => - this.sendRequest('v2', `targets/${encodeURIComponent(target.connectUrl)}/probes`, { + this.sendRequest('v2', `targets/${encodeURIComponent(target?.connectUrl || '')}/probes`, { method: 'DELETE', }).pipe( map((resp) => resp.ok), @@ -599,7 +611,7 @@ export class ApiService { concatMap((target) => this.sendRequest( 'v2', - `targets/${encodeURIComponent(target.connectUrl)}/probes/${encodeURIComponent(templateName)}`, + `targets/${encodeURIComponent(target?.connectUrl || '')}/probes/${encodeURIComponent(templateName)}`, { method: 'POST', }, @@ -698,7 +710,7 @@ export class ApiService { concatMap((target) => this.sendRequest( 'v2', - `targets/${encodeURIComponent(target.connectUrl)}/probes`, + `targets/${encodeURIComponent(target?.connectUrl || '')}/probes`, { method: 'GET', }, @@ -815,7 +827,7 @@ export class ApiService { map( (target) => `${this.login.authority}/api/v2.1/targets/${encodeURIComponent( - target.connectUrl, + target?.connectUrl || '', )}/templates/${encodeURIComponent(template.name)}/type/${encodeURIComponent(template.type)}`, ), ) @@ -919,7 +931,7 @@ export class ApiService { postRecordingMetadata(recordingName: string, labels: RecordingLabel[]): Observable { return this.target.target().pipe( - filter((target) => target !== NO_TARGET), + filter((target: Target) => !!target), first(), concatMap((target) => this.graphql( @@ -966,9 +978,9 @@ export class ApiService { postTargetRecordingMetadata(recordingName: string, labels: RecordingLabel[]): Observable { return this.target.target().pipe( - filter((target) => target !== NO_TARGET), + filter((target) => !!target), first(), - concatMap((target) => + concatMap((target: Target) => this.graphql( ` query PostActiveRecordingMetadata($connectUrl: String, $recordingName: String, $labels: String) { @@ -1513,328 +1525,3 @@ export class ApiService { throw error; } } - -export type SimpleResponse = Pick; - -export interface ApiV2Response { - meta: { - status: string; - type: string; - }; - data: object; -} - -interface AssetJwtResponse extends ApiV2Response { - data: { - result: { - resourceUrl: string; - }; - }; -} - -interface RecordingResponse extends ApiV2Response { - data: { - result: ActiveRecording; - }; -} - -interface CredentialResponse extends ApiV2Response { - data: { - result: MatchedCredential; - }; -} - -interface ProbeTemplateResponse extends ApiV2Response { - data: { - result: ProbeTemplate[]; - }; -} - -interface EventProbesResponse extends ApiV2Response { - data: { - result: EventProbe[]; - }; -} - -interface CredentialsResponse extends ApiV2Response { - data: { - result: StoredCredential[]; - }; -} - -interface RuleResponse extends ApiV2Response { - data: { - result: Rule; - }; -} - -interface RulesResponse extends ApiV2Response { - data: { - result: Rule[]; - }; -} - -interface DiscoveryResponse extends ApiV2Response { - data: { - result: EnvironmentNode; - }; -} - -interface RecordingCountResponse { - data: { - targetNodes: { - recordings: { - active: { - aggregate: { - count: number; - }; - }; - }; - }[]; - }; -} - -interface XMLHttpResponse { - body: any; - headers: object; - respType: XMLHttpRequestResponseType; - status: number; - statusText: string; - ok: boolean; - text: () => Promise; -} - -interface XMLHttpRequestConfig { - body?: XMLHttpRequestBodyInit; - headers: object; - method: string; - listeners?: { - onUploadProgress?: (e: ProgressEvent) => void; - }; - abortSignal?: Observable; -} - -interface GrafanaDashboardUrlGetResponse { - grafanaDashboardUrl: string; -} - -interface GrafanaDatasourceUrlGetResponse { - grafanaDatasourceUrl: string; -} - -interface HealthGetResponse { - // TODO: update HTTP_API.md v1/HealthGetHandler to include cryostatVersion - cryostatVersion: string; - datasourceConfigured: boolean; - datasourceAvailable: boolean; - dashboardConfigured: boolean; - dashboardAvailable: boolean; - reportsConfigured: boolean; - reportsAvailable: boolean; -} - -export interface MemoryUsage { - init: number; - used: number; - committed: number; - max: number; -} - -export interface MBeanMetrics { - thread?: { - threadCount?: number; - daemonThreadCount?: number; - }; - os?: { - name?: string; - arch?: string; - availableProcessors?: number; - version?: string; - systemCpuLoad?: number; - systemLoadAverage?: number; - processCpuLoad?: number; - totalPhysicalMemorySize?: number; - freePhysicalMemorySize?: number; - totalSwapSpaceSize?: number; - }; - memory?: { - heapMemoryUsage?: MemoryUsage; - nonHeapMemoryUsage?: MemoryUsage; - heapMemoryUsagePercent?: number; - }; - runtime?: { - bootClassPath?: string; - classPath?: string; - inputArguments?: string[]; - libraryPath?: string; - managementSpecVersion?: string; - name?: string; - specName?: string; - specVendor?: string; - startTime?: number; - systemProperties?: object; - uptime?: number; - vmName?: string; - vmVendor?: string; - vmVersion?: string; - bootClassPathSupported?: boolean; - }; -} - -export interface MBeanMetricsResponse { - data: { - targetNodes: { - mbeanMetrics: MBeanMetrics; - }[]; - }; -} - -export interface RecordingDirectory { - connectUrl: string; - jvmId: string; - recordings: ArchivedRecording[]; -} - -export interface ArchivedRecording { - name: string; - downloadUrl: string; - reportUrl: string; - metadata: Metadata; - size: number; - archivedTime: number; -} - -export interface ActiveRecording extends Omit { - id: number; - state: RecordingState; - duration: number; - startTime: number; - continuous: boolean; - toDisk: boolean; - maxSize: number; - maxAge: number; -} - -export enum RecordingState { - STOPPED = 'STOPPED', - STARTING = 'STARTING', - RUNNING = 'RUNNING', - STOPPING = 'STOPPING', -} - -export type Recording = ActiveRecording | ArchivedRecording; - -export const isActiveRecording = (toCheck: Recording): toCheck is ActiveRecording => { - return (toCheck as ActiveRecording).state !== undefined; -}; - -export const isGraphQLAuthError = (resp: any): boolean => { - if (resp.errors !== undefined) { - if (resp.errors[0].message.includes('Authentication failed!')) { - return true; - } - } - return false; -}; - -export type TemplateType = 'TARGET' | 'CUSTOM'; - -export interface EventTemplate { - name: string; - description: string; - provider: string; - type: TemplateType; -} - -export interface RecordingOptions { - restart?: boolean; - toDisk?: boolean; - maxSize?: number; - maxAge?: number; -} - -export interface RecordingAttributes { - name: string; - events: string; - duration?: number; - archiveOnStop?: boolean; - options?: RecordingOptions; - metadata?: Metadata; -} - -export interface Metadata { - labels: object; -} - -export interface StoredCredential { - id: number; - matchExpression: string; - numMatchingTargets: number; -} - -export interface ProbeTemplate { - name: string; - xml: string; -} - -export interface EventProbe { - id: string; - name: string; - clazz: string; - description: string; - path: string; - recordStackTrace: boolean; - useRethrow: boolean; - methodName: string; - methodDescriptor: string; - location: string; - returnValue: string; - parameters: string; - fields: string; -} - -export interface MatchedCredential { - matchExpression: string; - targets: Target[]; -} - -export interface ActiveRecordingFilterInput { - name?: string; - state?: string; - continuous?: boolean; - toDisk?: boolean; - durationMsGreaterThanEqual?: number; - durationMsLessThanEqual?: number; - startTimeMsBeforeEqual?: number; - startTimeMsAfterEqual?: number; - labels?: string[] | string; -} - -export const automatedAnalysisRecordingName = 'automated-analysis'; - -export interface AutomatedAnalysisRecordingConfig { - template: Pick; - maxSize: number; - maxAge: number; -} - -export const defaultAutomatedAnalysisRecordingConfig: AutomatedAnalysisRecordingConfig = { - template: { - name: 'Continuous', - type: 'TARGET', - }, - maxSize: 1048576, - maxAge: 0, -}; - -export interface ChartControllerConfig { - minRefresh: number; -} - -export const defaultChartControllerConfig: ChartControllerConfig = { - minRefresh: 10, -}; - -// New target specific archived recording apis now enforce a non-empty target field -// The placeholder targetId for uploaded (non-target) recordings is "uploads" -export const UPLOADS_SUBDIRECTORY = 'uploads'; diff --git a/src/app/Shared/Services/AuthCredentials.service.tsx b/src/app/Shared/Services/AuthCredentials.service.tsx index 341c825e5..95accf910 100644 --- a/src/app/Shared/Services/AuthCredentials.service.tsx +++ b/src/app/Shared/Services/AuthCredentials.service.tsx @@ -13,15 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Locations } from '@app/Settings/CredentialsStorage'; +import { Locations } from '@app/Settings/Config/CredentialsStorage'; import { getFromLocalStorage } from '@app/utils/LocalStorage'; import { Observable, of } from 'rxjs'; -import { ApiService } from './Api.service'; - -export interface Credential { - username: string; - password: string; -} +import type { ApiService } from './Api.service'; +import { Credential } from './service.types'; export class AuthCredentials { // TODO replace with Redux? diff --git a/src/app/Shared/Services/Login.service.tsx b/src/app/Shared/Services/Login.service.tsx index 54c4ed017..b629acafc 100644 --- a/src/app/Shared/Services/Login.service.tsx +++ b/src/app/Shared/Services/Login.service.tsx @@ -17,24 +17,12 @@ import { Base64 } from 'js-base64'; import { combineLatest, Observable, ObservableInput, of, ReplaySubject } from 'rxjs'; import { fromFetch } from 'rxjs/fetch'; import { catchError, concatMap, debounceTime, distinctUntilChanged, first, map, tap } from 'rxjs/operators'; -import { ApiV2Response } from './Api.service'; -import { Credential, AuthCredentials } from './AuthCredentials.service'; -import { isQuotaExceededError } from './Report.service'; -import { SettingsService } from './Settings.service'; -import { TargetService } from './Target.service'; - -export enum SessionState { - NO_USER_SESSION, - CREATING_USER_SESSION, - USER_SESSION, -} - -export enum AuthMethod { - BASIC = 'Basic', - BEARER = 'Bearer', - NONE = 'None', - UNKNOWN = '', -} +import { AuthV2Response } from './api.types'; +import { isQuotaExceededError } from './api.utils'; +import type { AuthCredentials } from './AuthCredentials.service'; +import { AuthMethod, Credential, SessionState } from './service.types'; +import type { SettingsService } from './Settings.service'; +import type { TargetService } from './Target.service'; export class LoginService { private readonly TOKEN_KEY: string = 'token'; @@ -135,7 +123,7 @@ export class LoginService { this.getToken(), this.getAuthMethod(), this.target.target().pipe( - map((target) => target.connectUrl), + map((target) => target?.connectUrl || ''), concatMap((connect) => this.authCredentials.getCredential(connect)), ), ]).pipe( @@ -347,11 +335,3 @@ export class LoginService { sessionStorage.removeItem(key); } } - -interface AuthV2Response extends ApiV2Response { - data: { - result: { - username: string; - }; - }; -} diff --git a/src/app/Shared/Services/MatchExpression.service.tsx b/src/app/Shared/Services/MatchExpression.service.tsx new file mode 100644 index 000000000..4561a4622 --- /dev/null +++ b/src/app/Shared/Services/MatchExpression.service.tsx @@ -0,0 +1,33 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BehaviorSubject, Observable, tap, debounceTime } from 'rxjs'; + +export class MatchExpressionService { + private readonly _state$ = new BehaviorSubject(''); + + searchExpression({ + debounceMs = 300, // ms + immediateFn = (_: string) => { + /* do nothing */ + }, + } = {}): Observable { + return this._state$.asObservable().pipe(tap(immediateFn), debounceTime(debounceMs)); + } + + setSearchExpression(expr: string): void { + this._state$.next(expr); + } +} diff --git a/src/app/Shared/Services/NotificationChannel.service.tsx b/src/app/Shared/Services/NotificationChannel.service.tsx index 68f7d594e..4bafe5c49 100644 --- a/src/app/Shared/Services/NotificationChannel.service.tsx +++ b/src/app/Shared/Services/NotificationChannel.service.tsx @@ -13,292 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Notifications } from '@app/Notifications/Notifications'; import { AlertVariant } from '@patternfly/react-core'; import _ from 'lodash'; import { BehaviorSubject, combineLatest, Observable, Subject, timer } from 'rxjs'; import { fromFetch } from 'rxjs/fetch'; import { concatMap, distinctUntilChanged, filter } from 'rxjs/operators'; import { webSocket, WebSocketSubject } from 'rxjs/webSocket'; -import { AuthMethod, LoginService, SessionState } from './Login.service'; -import { Target } from './Target.service'; -import { TargetDiscoveryEvent } from './Targets.service'; - -export enum NotificationCategory { - WsClientActivity = 'WsClientActivity', - TargetJvmDiscovery = 'TargetJvmDiscovery', - ActiveRecordingCreated = 'ActiveRecordingCreated', - ActiveRecordingStopped = 'ActiveRecordingStopped', - ActiveRecordingSaved = 'ActiveRecordingSaved', - ActiveRecordingDeleted = 'ActiveRecordingDeleted', - SnapshotCreated = 'SnapshotCreated', - SnapshotDeleted = 'SnapshotDeleted', - ArchivedRecordingCreated = 'ArchivedRecordingCreated', - ArchivedRecordingDeleted = 'ArchivedRecordingDeleted', - TemplateUploaded = 'TemplateUploaded', - TemplateDeleted = 'TemplateDeleted', - ProbeTemplateUploaded = 'ProbeTemplateUploaded', - ProbeTemplateDeleted = 'ProbeTemplateDeleted', - ProbeTemplateApplied = 'ProbeTemplateApplied', - ProbesRemoved = 'ProbesRemoved', - RuleCreated = 'RuleCreated', - RuleUpdated = 'RuleUpdated', - RuleDeleted = 'RuleDeleted', - RecordingMetadataUpdated = 'RecordingMetadataUpdated', - GrafanaConfiguration = 'GrafanaConfiguration', // generated client-side - LayoutTemplateCreated = 'LayoutTemplateCreated', // generated client-side - TargetCredentialsStored = 'TargetCredentialsStored', - TargetCredentialsDeleted = 'TargetCredentialsDeleted', - CredentialsStored = 'CredentialsStored', - CredentialsDeleted = 'CredentialsDeleted', -} - -export enum CloseStatus { - LOGGED_OUT = 1000, - PROTOCOL_FAILURE = 1002, - INTERNAL_ERROR = 1011, - UNKNOWN = -1, -} - -interface ReadyState { - ready: boolean; - code?: CloseStatus; -} - -export const messageKeys = new Map([ - [ - // explicitly configure this category with a null message body mapper. - // This is a special case because this is generated client-side, - // not sent by the backend - NotificationCategory.GrafanaConfiguration, - { - title: 'Grafana Configuration', - }, - ], - [ - NotificationCategory.LayoutTemplateCreated, - { - title: 'Layout Template Created', - }, - ], - [ - NotificationCategory.TargetJvmDiscovery, - { - variant: AlertVariant.info, - title: 'Target JVM Discovery', - body: (v) => { - const evt: TargetDiscoveryEvent = v.message.event; - const target: Target = evt.serviceRef; - switch (evt.kind) { - case 'FOUND': - return `Target "${target.alias}" appeared (${target.connectUrl})"`; - case 'LOST': - return `Target "${target.alias}" disappeared (${target.connectUrl})"`; - case 'MODIFIED': - return `Target "${target.alias}" was modified (${target.connectUrl})"`; - default: - return `Received a notification with category ${NotificationCategory.TargetJvmDiscovery} and unrecognized kind ${evt.kind}`; - } - }, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.WsClientActivity, - { - variant: AlertVariant.info, - title: 'WebSocket Client Activity', - body: (evt) => { - const addr = Object.keys(evt.message)[0]; - const status = evt.message[addr]; - return `Client at ${addr} ${status}`; - }, - hidden: true, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.ActiveRecordingCreated, - { - variant: AlertVariant.success, - title: 'Recording Created', - body: (evt) => `${evt.message.recording.name} created in target: ${evt.message.target}`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.ActiveRecordingStopped, - { - variant: AlertVariant.success, - title: 'Recording Stopped', - body: (evt) => `${evt.message.recording.name} was stopped`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.ActiveRecordingSaved, - { - variant: AlertVariant.success, - title: 'Recording Saved', - body: (evt) => `${evt.message.recording.name} was archived`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.ActiveRecordingDeleted, - { - variant: AlertVariant.success, - title: 'Recording Deleted', - body: (evt) => `${evt.message.recording.name} was deleted`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.SnapshotCreated, - { - variant: AlertVariant.success, - title: 'Snapshot Created', - body: (evt) => `${evt.message.recording.name} was created in target: ${evt.message.target}`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.SnapshotDeleted, - { - variant: AlertVariant.success, - title: 'Snapshot Deleted', - body: (evt) => `${evt.message.recording.name} was deleted`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.ArchivedRecordingCreated, - { - variant: AlertVariant.success, - title: 'Archived Recording Uploaded', - body: (evt) => `${evt.message.recording.name} was uploaded into archives`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.ArchivedRecordingDeleted, - { - variant: AlertVariant.success, - title: 'Archived Recording Deleted', - body: (evt) => `${evt.message.recording.name} was deleted`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.TemplateUploaded, - { - variant: AlertVariant.success, - title: 'Template Created', - body: (evt) => `${evt.message.template.name} was created`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.ProbeTemplateUploaded, - { - variant: AlertVariant.success, - title: 'Probe Template Created', - body: (evt) => `${evt.message.probeTemplate} was created`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.ProbeTemplateApplied, - { - variant: AlertVariant.success, - title: 'Probe Template Applied', - body: (evt) => `${evt.message.probeTemplate} was inserted`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.TemplateDeleted, - { - variant: AlertVariant.success, - title: 'Template Deleted', - body: (evt) => `${evt.message.template.name} was deleted`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.ProbeTemplateDeleted, - { - variant: AlertVariant.success, - title: 'Probe Template Deleted', - body: (evt) => `${evt.message.probeTemplate} was deleted`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.ProbesRemoved, - { - variant: AlertVariant.success, - title: 'Probes Removed from Target', - body: (evt) => `Probes successfully removed from ${evt.message.target}`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.RuleCreated, - { - variant: AlertVariant.success, - title: 'Automated Rule Created', - body: (evt) => `${evt.message.name} was created`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.RuleUpdated, - { - variant: AlertVariant.success, - title: 'Automated Rule Updated', - body: (evt) => `${evt.message.name} was ` + (evt.message.enabled ? 'enabled' : 'disabled'), - } as NotificationMessageMapper, - ], - [ - NotificationCategory.RuleDeleted, - { - variant: AlertVariant.success, - title: 'Automated Rule Deleted', - body: (evt) => `${evt.message.name} was deleted`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.RecordingMetadataUpdated, - { - variant: AlertVariant.success, - title: 'Recording Metadata Updated', - body: (evt) => `${evt.message.recordingName} metadata was updated`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.TargetCredentialsStored, - { - variant: AlertVariant.success, - title: 'Target Credentials Stored', - body: (evt) => `Credentials stored for target: ${evt.message.target}`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.TargetCredentialsDeleted, - { - variant: AlertVariant.success, - title: 'Target Credentials Deleted', - body: (evt) => `Credentials deleted for target: ${evt.message.target}`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.CredentialsStored, - { - variant: AlertVariant.success, - title: 'Credentials Stored', - body: (evt) => `Credentials stored for: ${evt.message.matchExpression}`, - } as NotificationMessageMapper, - ], - [ - NotificationCategory.CredentialsDeleted, - { - variant: AlertVariant.success, - title: 'Credentials Deleted', - body: (evt) => `Credentials deleted for: ${evt.message.matchExpression}`, - } as NotificationMessageMapper, - ], -]); - -interface NotificationMessageMapper { - title: string; - body?: (evt: NotificationMessage) => string; - variant?: AlertVariant; - hidden?: boolean; -} +import { + NotificationMessage, + ReadyState, + CloseStatus, + NotificationCategory, + NotificationsUrlGetResponse, +} from './api.types'; +import { messageKeys } from './api.utils'; +import { LoginService } from './Login.service'; +import { NotificationService } from './Notifications.service'; +import { SessionState, AuthMethod } from './service.types'; export class NotificationChannel { private ws: WebSocketSubject | null = null; @@ -306,7 +37,7 @@ export class NotificationChannel { private readonly _ready = new BehaviorSubject({ ready: false }); constructor( - private readonly notifications: Notifications, + private readonly notifications: NotificationService, private readonly login: LoginService, ) { messageKeys.forEach((value, key) => { @@ -468,24 +199,3 @@ export class NotificationChannel { this.notifications.danger(title, JSON.stringify(err.message)); } } - -interface NotificationsUrlGetResponse { - notificationsUrl: string; -} - -export interface NotificationMessage { - meta: MessageMeta; - // Should a message be any type? Try T? - message: any; // eslint-disable-line @typescript-eslint/no-explicit-any - serverTime: number; -} - -export interface MessageMeta { - category: string; - type: MessageType; -} - -export interface MessageType { - type: string; - subtype: string; -} diff --git a/src/app/Notifications/Notifications.tsx b/src/app/Shared/Services/Notifications.service.tsx similarity index 90% rename from src/app/Notifications/Notifications.tsx rename to src/app/Shared/Services/Notifications.service.tsx index f6105d506..9a61e65fe 100644 --- a/src/app/Notifications/Notifications.tsx +++ b/src/app/Shared/Services/Notifications.service.tsx @@ -13,25 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; + import { AlertVariant } from '@patternfly/react-core'; import { nanoid } from 'nanoid'; import * as React from 'react'; import { BehaviorSubject, Observable } from 'rxjs'; import { concatMap, filter, first, map } from 'rxjs/operators'; - -export interface Notification { - hidden?: boolean; - read?: boolean; - key?: string; - title: string; - message?: string | Error; - category?: string; - variant: AlertVariant; - timestamp?: number; -} - -export class Notifications { +import { Notification, NotificationCategory } from './api.types'; +export class NotificationService { private readonly _notifications$: BehaviorSubject = new BehaviorSubject([]); private readonly _drawerState$: BehaviorSubject = new BehaviorSubject(false); @@ -111,14 +100,15 @@ export class Notifications { return this.notifications().pipe( map((a) => a.filter( - (n) => (this.isWsClientActivity(n) || this.isJvmDiscovery(n)) && !Notifications.isProblemNotification(n), + (n) => + (this.isWsClientActivity(n) || this.isJvmDiscovery(n)) && !NotificationService.isProblemNotification(n), ), ), ); } problemsNotifications(): Observable { - return this.notifications().pipe(map((a) => a.filter(Notifications.isProblemNotification))); + return this.notifications().pipe(map((a) => a.filter(NotificationService.isProblemNotification))); } setHidden(key?: string, hidden = true): void { @@ -163,7 +153,7 @@ export class Notifications { } private isActionNotification(n: Notification): boolean { - return !this.isWsClientActivity(n) && !this.isJvmDiscovery(n) && !Notifications.isProblemNotification(n); + return !this.isWsClientActivity(n) && !this.isJvmDiscovery(n) && !NotificationService.isProblemNotification(n); } private isWsClientActivity(n: Notification): boolean { @@ -179,7 +169,7 @@ export class Notifications { } } -const NotificationsInstance = new Notifications(); +const NotificationsInstance = new NotificationService(); const NotificationsContext = React.createContext(NotificationsInstance); diff --git a/src/app/Shared/Services/Report.service.tsx b/src/app/Shared/Services/Report.service.tsx index 56b11fcba..d45806562 100644 --- a/src/app/Shared/Services/Report.service.tsx +++ b/src/app/Shared/Services/Report.service.tsx @@ -13,18 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Notifications } from '@app/Notifications/Notifications'; import { Base64 } from 'js-base64'; import { Observable, from, throwError } from 'rxjs'; import { fromFetch } from 'rxjs/fetch'; import { concatMap, first, tap } from 'rxjs/operators'; -import { isActiveRecording, Recording } from './Api.service'; -import { LoginService } from './Login.service'; +import { Recording, CachedReportValue, GenerationError, AnalysisResult } from './api.types'; +import { isActiveRecording, isQuotaExceededError, isGenerationError } from './api.utils'; +import type { LoginService } from './Login.service'; +import type { NotificationService } from './Notifications.service'; export class ReportService { constructor( private login: LoginService, - private notifications: Notifications, + private notifications: NotificationService, ) {} reportJson(recording: Recording, connectUrl: string): Observable { @@ -127,73 +128,3 @@ export class ReportService { return Base64.encode(`${connectUrl}.latestReportTimestamp`); } } - -export interface CachedReportValue { - report: AnalysisResult[]; - timestamp: number; -} - -// [topic, { ruleName, score, description, ... }}] -export type CategorizedRuleEvaluations = [string, AnalysisResult[]]; - -export type GenerationError = Error & { - status: number; - messageDetail: Observable; -}; - -export const isGenerationError = (err: unknown): err is GenerationError => { - if ((err as GenerationError).name === undefined) { - return false; - } - if ((err as GenerationError).message === undefined) { - return false; - } - if ((err as GenerationError).messageDetail === undefined) { - return false; - } - if ((err as GenerationError).status === undefined) { - return false; - } - return true; -}; - -export const isQuotaExceededError = (err: unknown): err is DOMException => { - return ( - err instanceof DOMException && - (err.name === 'QuotaExceededError' || - // Firefox - err.name === 'NS_ERROR_DOM_QUOTA_REACHED') - ); -}; - -export interface AnalysisResult { - name: string; - topic: string; - score: number; - evaluation: Evaluation; -} - -export interface Evaluation { - summary: string; - explanation: string; - solution: string; - suggestions: Suggestion[]; -} - -export interface Suggestion { - setting: string; - name: string; - value: string; -} - -export enum AutomatedAnalysisScore { - NA_SCORE = -1, - ORANGE_SCORE_THRESHOLD = 25, - RED_SCORE_THRESHOLD = 75, -} - -export const FAILED_REPORT_MESSAGE = - 'Failed to load the report from recording because the requested entity is too large.'; -export const NO_RECORDINGS_MESSAGE = 'No active or archived recordings available. Create a new recording for analysis.'; -export const RECORDING_FAILURE_MESSAGE = 'Failed to start recording for analysis.'; -export const TEMPLATE_UNSUPPORTED_MESSAGE = 'The template type used in this recording is not supported on this JVM.'; diff --git a/src/app/Shared/Services/Services.tsx b/src/app/Shared/Services/Services.tsx index 63590efc8..5bae9a105 100644 --- a/src/app/Shared/Services/Services.tsx +++ b/src/app/Shared/Services/Services.tsx @@ -13,15 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotificationsInstance } from '@app/Notifications/Notifications'; import * as React from 'react'; import { ApiService } from './Api.service'; import { AuthCredentials } from './AuthCredentials.service'; import { LoginService } from './Login.service'; import { NotificationChannel } from './NotificationChannel.service'; +import { NotificationsInstance } from './Notifications.service'; import { ReportService } from './Report.service'; import { SettingsService } from './Settings.service'; -import { TargetService, TargetInstance } from './Target.service'; +import { TargetService } from './Target.service'; import { TargetsService } from './Targets.service'; export interface Services { @@ -35,16 +35,17 @@ export interface Services { login: LoginService; } +const target = new TargetService(); const settings = new SettingsService(); const authCredentials = new AuthCredentials(() => api); -const login = new LoginService(TargetInstance, authCredentials, settings); -const api = new ApiService(TargetInstance, NotificationsInstance, login); +const login = new LoginService(target, authCredentials, settings); +const api = new ApiService(target, NotificationsInstance, login); const notificationChannel = new NotificationChannel(NotificationsInstance, login); const reports = new ReportService(login, NotificationsInstance); const targets = new TargetsService(api, NotificationsInstance, login, notificationChannel); const defaultServices: Services = { - target: TargetInstance, + target, targets, api, authCredentials, diff --git a/src/app/Shared/Services/Settings.service.tsx b/src/app/Shared/Services/Settings.service.tsx index b8d3997d5..8cfcd89f6 100644 --- a/src/app/Shared/Services/Settings.service.tsx +++ b/src/app/Shared/Services/Settings.service.tsx @@ -14,47 +14,20 @@ * limitations under the License. */ -import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; -import { ThemeSetting } from '@app/Settings/SettingsUtils'; +import { DeleteOrDisableWarningType } from '@app/Modal/types'; +import { ThemeSetting } from '@app/Settings/types'; import { getFromLocalStorage, saveToLocalStorage } from '@app/utils/LocalStorage'; import { DatetimeFormat, defaultDatetimeFormat } from '@i18n/datetime'; import { BehaviorSubject, fromEvent, Observable, startWith } from 'rxjs'; +import { NotificationCategory } from './api.types'; import { AutomatedAnalysisRecordingConfig, - automatedAnalysisRecordingName, - ChartControllerConfig, + FeatureLevel, defaultAutomatedAnalysisRecordingConfig, - defaultChartControllerConfig, - RecordingAttributes, -} from './Api.service'; -import { NotificationCategory } from './NotificationChannel.service'; - -export enum FeatureLevel { - DEVELOPMENT = 0, - BETA = 1, - PRODUCTION = 2, -} + ChartControllerConfig, +} from './service.types'; +import { defaultChartControllerConfig } from './service.utils'; -export const automatedAnalysisConfigToRecordingAttributes = ( - config: AutomatedAnalysisRecordingConfig, -): RecordingAttributes => { - return { - name: automatedAnalysisRecordingName, - events: `template=${config.template.name},type=${config.template.type}`, - duration: undefined, - archiveOnStop: false, - options: { - toDisk: true, - maxAge: config.maxAge, - maxSize: config.maxSize, - }, - metadata: { - labels: { - origin: automatedAnalysisRecordingName, - }, - }, - } as RecordingAttributes; -}; export class SettingsService { private readonly _featureLevel$ = new BehaviorSubject( getFromLocalStorage('FEATURE_LEVEL', FeatureLevel.PRODUCTION), @@ -109,7 +82,7 @@ export class SettingsService { } autoRefreshEnabled(): boolean { - return getFromLocalStorage('AUTO_REFRESH_ENABLED', 'false') === 'true'; + return getFromLocalStorage('AUTO_REFRESH_ENABLED', 'false') === 'true'; } setAutoRefreshEnabled(enabled: boolean): void { @@ -202,7 +175,8 @@ export class SettingsService { } notificationsEnabled(): Map { - const value = getFromLocalStorage('NOTIFICATIONS_ENABLED', undefined); + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const value = getFromLocalStorage('NOTIFICATIONS_ENABLED', undefined); if (typeof value === 'object') { const res = new Map(); value.forEach((v: [NotificationCategory, boolean]) => { diff --git a/src/app/Shared/Services/Target.service.tsx b/src/app/Shared/Services/Target.service.tsx index c7a8fd533..7782ffb9e 100644 --- a/src/app/Shared/Services/Target.service.tsx +++ b/src/app/Shared/Services/Target.service.tsx @@ -14,58 +14,23 @@ * limitations under the License. */ import { Observable, Subject, BehaviorSubject } from 'rxjs'; - -export const NO_TARGET = {} as Target; - -export const includesTarget = (arr: Target[], target: Target): boolean => { - return arr.some((t) => t.connectUrl === target.connectUrl); -}; - -export const isEqualTarget = (a: Target, b: Target): boolean => { - return a.connectUrl === b.connectUrl; -}; - -export const indexOfTarget = (arr: Target[], target: Target): number => { - let index = -1; - arr.forEach((t, idx) => { - if (t.connectUrl === target.connectUrl) { - index = idx; - } - }); - return index; -}; - -export const getTargetRepresentation = (t: Target) => - !t.alias || t.alias === t.connectUrl ? `${t.connectUrl}` : `${t.alias} (${t.connectUrl})`; - -export const isTargetAgentHttp = (t: Target) => t.connectUrl.startsWith('http'); - -export interface Target { - jvmId?: string; // present in responses, but we do not need to provide it in requests - connectUrl: string; - alias: string; - labels?: object; - annotations?: { - cryostat: object; - platform: object; - }; -} +import { NullableTarget } from './api.types'; class TargetService { - private readonly _target: Subject = new BehaviorSubject(NO_TARGET); + private readonly _target: Subject = new BehaviorSubject(undefined); private readonly _authFailure: Subject = new Subject(); private readonly _authRetry: Subject = new Subject(); private readonly _sslFailure: Subject = new Subject(); - setTarget(target: Target): void { - if (target === NO_TARGET || !!target.connectUrl) { + setTarget(target?: NullableTarget): void { + if (!target || target.connectUrl !== '') { this._target.next(target); } else { throw new Error('Malformed target'); } } - target(): Observable { + target(): Observable { return this._target.asObservable(); } diff --git a/src/app/Shared/Services/Targets.service.tsx b/src/app/Shared/Services/Targets.service.tsx index 5ce661d93..e5134319d 100644 --- a/src/app/Shared/Services/Targets.service.tsx +++ b/src/app/Shared/Services/Targets.service.tsx @@ -14,26 +14,22 @@ * limitations under the License. */ -import { Notifications } from '@app/Notifications/Notifications'; -import * as _ from 'lodash'; +import _ from 'lodash'; import { Observable, BehaviorSubject, of, EMPTY } from 'rxjs'; import { catchError, concatMap, first, map, tap } from 'rxjs/operators'; import { ApiService } from './Api.service'; -import { LoginService, SessionState } from './Login.service'; -import { NotificationCategory, NotificationChannel } from './NotificationChannel.service'; -import { Target } from './Target.service'; - -export interface TargetDiscoveryEvent { - kind: 'LOST' | 'FOUND' | 'MODIFIED'; - serviceRef: Target; -} +import { Target, NotificationCategory, TargetDiscoveryEvent } from './api.types'; +import { LoginService } from './Login.service'; +import { NotificationChannel } from './NotificationChannel.service'; +import { NotificationService } from './Notifications.service'; +import { SessionState } from './service.types'; export class TargetsService { - private readonly _targets$: BehaviorSubject = new BehaviorSubject([] as Target[]); + private readonly _targets$: BehaviorSubject = new BehaviorSubject([]); constructor( private readonly api: ApiService, - private readonly notifications: Notifications, + private readonly notifications: NotificationService, login: LoginService, notificationChannel: NotificationChannel, ) { diff --git a/src/app/Shared/Services/api.types.ts b/src/app/Shared/Services/api.types.ts new file mode 100644 index 000000000..6a80e5083 --- /dev/null +++ b/src/app/Shared/Services/api.types.ts @@ -0,0 +1,589 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AlertVariant } from '@patternfly/react-core'; +import { Observable } from 'rxjs'; + +export type ApiVersion = 'v1' | 'v2' | 'v2.1' | 'v2.2' | 'v2.3' | 'v2.4' | 'beta'; + +// ====================================== +// Common Resources +// ====================================== +export interface KeyValue { + readonly [key: string]: string; +} + +export interface Metadata { + labels: KeyValue; + annotations?: KeyValue; +} + +export interface ApiV2Response { + meta: { + status: string; + type: string; + }; + data: unknown; +} + +export interface AssetJwtResponse extends ApiV2Response { + data: { + result: { + resourceUrl: string; + }; + }; +} + +export type SimpleResponse = Pick; + +export interface XMLHttpResponse { + body: unknown; + headers: object; + respType: XMLHttpRequestResponseType; + status: number; + statusText: string; + ok: boolean; + text: () => Promise; +} + +export interface XMLHttpRequestConfig { + body?: XMLHttpRequestBodyInit; + headers: object; + method: string; + listeners?: { + onUploadProgress?: (e: ProgressEvent) => void; + }; + abortSignal?: Observable; +} + +export class HttpError extends Error { + readonly httpResponse: Response; + + constructor(httpResponse: Response) { + super(httpResponse.statusText); + this.httpResponse = httpResponse; + } +} + +export class XMLHttpError extends Error { + readonly xmlHttpResponse: XMLHttpResponse; + + constructor(xmlHttpResponse: XMLHttpResponse) { + super(xmlHttpResponse.statusText); + this.xmlHttpResponse = xmlHttpResponse; + } +} + +// ====================================== +// Health Resources +// ====================================== +export interface GrafanaDashboardUrlGetResponse { + grafanaDashboardUrl: string; +} + +export interface GrafanaDatasourceUrlGetResponse { + grafanaDatasourceUrl: string; +} + +export interface HealthGetResponse { + cryostatVersion: string; + datasourceConfigured: boolean; + datasourceAvailable: boolean; + dashboardConfigured: boolean; + dashboardAvailable: boolean; + reportsConfigured: boolean; + reportsAvailable: boolean; +} + +// ====================================== +// Auth Resources +// ====================================== +export interface AuthV2Response extends ApiV2Response { + data: { + result: { + username: string; + }; + }; +} + +// ====================================== +// MBean metric resources +// ====================================== +export interface MemoryUsage { + init: number; + used: number; + committed: number; + max: number; +} + +export interface MBeanMetrics { + thread?: { + threadCount?: number; + daemonThreadCount?: number; + }; + os?: { + name?: string; + arch?: string; + availableProcessors?: number; + version?: string; + systemCpuLoad?: number; + systemLoadAverage?: number; + processCpuLoad?: number; + totalPhysicalMemorySize?: number; + freePhysicalMemorySize?: number; + totalSwapSpaceSize?: number; + }; + memory?: { + heapMemoryUsage?: MemoryUsage; + nonHeapMemoryUsage?: MemoryUsage; + heapMemoryUsagePercent?: number; + }; + runtime?: { + bootClassPath?: string; + classPath?: string; + inputArguments?: string[]; + libraryPath?: string; + managementSpecVersion?: string; + name?: string; + specName?: string; + specVendor?: string; + startTime?: number; + systemProperties?: object; + uptime?: number; + vmName?: string; + vmVendor?: string; + vmVersion?: string; + bootClassPathSupported?: boolean; + }; +} + +export interface MBeanMetricsResponse { + data: { + targetNodes: { + mbeanMetrics: MBeanMetrics; + }[]; + }; +} + +// ====================================== +// Recording resources +// ====================================== +export interface RecordingDirectory { + connectUrl: string; + jvmId: string; + recordings: ArchivedRecording[]; +} + +export enum RecordingState { + STOPPED = 'STOPPED', + STARTING = 'STARTING', + RUNNING = 'RUNNING', + STOPPING = 'STOPPING', +} + +export interface AdvancedRecordingOptions { + toDisk?: boolean; + maxSize?: number; + maxAge?: number; +} + +export interface RecordingAttributes { + name: string; + events: string; + duration?: number; + archiveOnStop?: boolean; + restart?: boolean; + advancedOptions?: AdvancedRecordingOptions; + metadata?: Metadata; +} + +export interface Recording { + name: string; + downloadUrl: string; + reportUrl: string; + metadata: Metadata; +} + +export interface ArchivedRecording extends Recording { + archivedTime: number; + size: number; +} + +export interface ActiveRecording extends Recording { + id: number; + state: RecordingState; + duration: number; + startTime: number; + continuous: boolean; + toDisk: boolean; + maxSize: number; + maxAge: number; +} + +export interface ActiveRecordingFilterInput { + name?: string; + state?: string; + continuous?: boolean; + toDisk?: boolean; + durationMsGreaterThanEqual?: number; + durationMsLessThanEqual?: number; + startTimeMsBeforeEqual?: number; + startTimeMsAfterEqual?: number; + labels?: string[] | string; +} + +/** + * New target specific archived recording apis now enforce a non-empty target field + * The placeholder targetId for uploaded (non-target) recordings is "uploads" + */ +export const UPLOADS_SUBDIRECTORY = 'uploads'; + +export interface RecordingResponse extends ApiV2Response { + data: { + result: ActiveRecording; + }; +} + +export interface RecordingCountResponse { + data: { + targetNodes: { + recordings: { + active: { + aggregate: { + count: number; + }; + }; + }; + }[]; + }; +} + +// ====================================== +// Credential resources +// ====================================== +export interface StoredCredential { + id: number; + matchExpression: string; + numMatchingTargets: number; +} + +export interface MatchedCredential { + matchExpression: string; + targets: Target[]; +} + +export interface CredentialResponse extends ApiV2Response { + data: { + result: MatchedCredential; + }; +} + +export interface CredentialsResponse extends ApiV2Response { + data: { + result: StoredCredential[]; + }; +} + +// ====================================== +// Agent-related resources +// ====================================== +export interface ProbeTemplate { + name: string; + xml: string; +} + +export interface EventProbe { + id: string; + name: string; + clazz: string; + description: string; + path: string; + recordStackTrace: boolean; + useRethrow: boolean; + methodName: string; + methodDescriptor: string; + location: string; + returnValue: string; + parameters: string; + fields: string; +} + +export interface ProbeTemplateResponse extends ApiV2Response { + data: { + result: ProbeTemplate[]; + }; +} + +export interface EventProbesResponse extends ApiV2Response { + data: { + result: EventProbe[]; + }; +} + +// ====================================== +// Rule resources +// ====================================== +export interface Rule { + name: string; + description: string; + matchExpression: string; + enabled: boolean; + eventSpecifier: string; + archivalPeriodSeconds: number; + initialDelaySeconds: number; + preservedArchives: number; + maxAgeSeconds: number; + maxSizeBytes: number; +} + +export interface RulesResponse extends ApiV2Response { + data: { + result: Rule[]; + }; +} + +export interface RuleResponse extends ApiV2Response { + data: { + result: Rule; + }; +} + +// ====================================== +// Template resources +// ====================================== +export interface OptionDescriptor { + name: string; + description: string; + defaultValue: string; +} + +export interface EventType { + name: string; + typeId: string; + description: string; + category: string[]; + options: { [key: string]: OptionDescriptor }[]; +} + +export type TemplateType = 'TARGET' | 'CUSTOM'; + +export interface EventTemplate { + name: string; + description: string; + provider: string; + type: TemplateType; +} + +// ====================================== +// Report resources +// ====================================== +export const automatedAnalysisRecordingName = 'automated-analysis'; + +export interface CachedReportValue { + report: AnalysisResult[]; + timestamp: number; +} + +// [topic, { ruleName, score, description, ... }}] +export type CategorizedRuleEvaluations = [string, AnalysisResult[]]; + +export type GenerationError = Error & { + status: number; + messageDetail: Observable; +}; + +export interface AnalysisResult { + name: string; + topic: string; + score: number; + evaluation: Evaluation; +} + +export interface Evaluation { + summary: string; + explanation: string; + solution: string; + suggestions: Suggestion[]; +} + +export interface Suggestion { + setting: string; + name: string; + value: string; +} + +export enum AutomatedAnalysisScore { + NA_SCORE = -1, + ORANGE_SCORE_THRESHOLD = 25, + RED_SCORE_THRESHOLD = 75, +} + +export const FAILED_REPORT_MESSAGE = + 'Failed to load the report from recording because the requested entity is too large.'; +export const NO_RECORDINGS_MESSAGE = 'No active or archived recordings available. Create a new recording for analysis.'; +export const RECORDING_FAILURE_MESSAGE = 'Failed to start recording for analysis.'; +export const TEMPLATE_UNSUPPORTED_MESSAGE = 'The template type used in this recording is not supported on this JVM.'; + +// ====================================== +// Discovery/Target resources +// ====================================== +export interface Target { + jvmId?: string; // present in responses, but we do not need to provide it in requests + connectUrl: string; + alias: string; + labels?: KeyValue; + annotations?: { + cryostat: KeyValue; + platform: KeyValue; + }; +} + +export type NullableTarget = Target | undefined; + +export enum NodeType { + // The entire deployment scenario Cryostat finds itself in. + UNIVERSE = 'Universe', + // A division of the deployment scenario (i.e. Kubernetes, JDP, Custom Target, CryostatAgent) + REALM = 'Realm', + // A plain target JVM, connectable over JMX. + JVM = 'JVM', + // A target JVM using the Cryostat Agent, *not* connectable over JMX. Agent instances + // that do publish a JMX Service URL should publish themselves with the JVM NodeType. + AGENT = 'CryostatAgent', + // Custom target defined via Custom Target Creation Form. + CUSTOM_TARGET = 'CustomTarget', + // Kubernetes platform. + NAMESPACE = 'Namespace', + STATEFULSET = 'StatefulSet', + DAEMONSET = 'DaemonSet', + DEPLOYMENT = 'Deployment', + DEPLOYMENTCONFIG = 'DeploymentConfig', // OpenShift specific + REPLICASET = 'ReplicaSet', + REPLICATIONCONTROLLER = 'ReplicationController', + POD = 'Pod', + ENDPOINT = 'Endpoint', + // Standalone targets + TARGET = 'Target', +} + +interface _AbstractNode { + readonly id: number; + readonly name: string; + readonly nodeType: NodeType; + readonly labels: KeyValue; +} + +export interface EnvironmentNode extends _AbstractNode { + readonly children: (EnvironmentNode | TargetNode)[]; +} + +export interface TargetNode extends _AbstractNode { + readonly target: Target; +} + +export interface DiscoveryResponse extends ApiV2Response { + data: { + result: EnvironmentNode; + }; +} + +// ====================================== +// Notification resources +// ====================================== +export interface NotificationsUrlGetResponse { + notificationsUrl: string; +} + +export interface NotificationMessage { + meta: MessageMeta; + // Should a message be any type? Try T? + message: any; // eslint-disable-line @typescript-eslint/no-explicit-any + serverTime: number; +} + +export interface MessageMeta { + category: string; + type: MessageType; +} + +export interface MessageType { + type: string; + subtype: string; +} + +export interface TargetDiscoveryEvent { + kind: 'LOST' | 'FOUND' | 'MODIFIED'; + serviceRef: Target; +} + +export interface Notification { + hidden?: boolean; + read?: boolean; + key?: string; + title: string; + message?: string | Error; + category?: string; + variant: AlertVariant; + timestamp?: number; +} + +export enum NotificationCategory { + WsClientActivity = 'WsClientActivity', + TargetJvmDiscovery = 'TargetJvmDiscovery', + ActiveRecordingCreated = 'ActiveRecordingCreated', + ActiveRecordingStopped = 'ActiveRecordingStopped', + ActiveRecordingSaved = 'ActiveRecordingSaved', + ActiveRecordingDeleted = 'ActiveRecordingDeleted', + SnapshotCreated = 'SnapshotCreated', + SnapshotDeleted = 'SnapshotDeleted', + ArchivedRecordingCreated = 'ArchivedRecordingCreated', + ArchivedRecordingDeleted = 'ArchivedRecordingDeleted', + TemplateUploaded = 'TemplateUploaded', + TemplateDeleted = 'TemplateDeleted', + ProbeTemplateUploaded = 'ProbeTemplateUploaded', + ProbeTemplateDeleted = 'ProbeTemplateDeleted', + ProbeTemplateApplied = 'ProbeTemplateApplied', + ProbesRemoved = 'ProbesRemoved', + RuleCreated = 'RuleCreated', + RuleUpdated = 'RuleUpdated', + RuleDeleted = 'RuleDeleted', + RecordingMetadataUpdated = 'RecordingMetadataUpdated', + GrafanaConfiguration = 'GrafanaConfiguration', // generated client-side + LayoutTemplateCreated = 'LayoutTemplateCreated', // generated client-side + TargetCredentialsStored = 'TargetCredentialsStored', + TargetCredentialsDeleted = 'TargetCredentialsDeleted', + CredentialsStored = 'CredentialsStored', + CredentialsDeleted = 'CredentialsDeleted', +} + +export enum CloseStatus { + LOGGED_OUT = 1000, + PROTOCOL_FAILURE = 1002, + INTERNAL_ERROR = 1011, + UNKNOWN = -1, +} + +export interface ReadyState { + ready: boolean; + code?: CloseStatus; +} + +export interface NotificationMessageMapper { + title: string; + body?: (evt: NotificationMessage) => string; + variant?: AlertVariant; + hidden?: boolean; +} diff --git a/src/app/Shared/Services/api.utils.ts b/src/app/Shared/Services/api.utils.ts new file mode 100644 index 000000000..5eb3caf73 --- /dev/null +++ b/src/app/Shared/Services/api.utils.ts @@ -0,0 +1,414 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AlertVariant } from '@patternfly/react-core'; +import { + ActiveRecording, + EnvironmentNode, + EventType, + GenerationError, + HttpError, + NodeType, + NotificationCategory, + NotificationMessageMapper, + Recording, + Target, + TargetDiscoveryEvent, + TargetNode, + XMLHttpError, +} from './api.types'; + +// ====================================== +// Common utils +// ====================================== +export const isHttpError = (err: unknown): err is HttpError => { + if (!(err instanceof Error)) { + return false; + } + return (err as HttpError).httpResponse !== undefined; +}; + +export const isXMLHttpError = (err: unknown): err is XMLHttpError => { + if (!(err instanceof Error)) { + return false; + } + return (err as XMLHttpError).xmlHttpResponse !== undefined; +}; + +export const isHttpOk = (statusCode: number) => { + return statusCode >= 200 && statusCode < 300; +}; + +// ====================================== +// Recording utils +// ====================================== +export const isActiveRecording = (toCheck: Recording): toCheck is ActiveRecording => { + return (toCheck as ActiveRecording).state !== undefined; +}; + +/* eslint @typescript-eslint/no-explicit-any: 0 */ +export const isGraphQLAuthError = (resp: any): boolean => { + if (resp.errors !== undefined) { + if (resp.errors[0].message.includes('Authentication failed!')) { + return true; + } + } + return false; +}; + +// ====================================== +// Template utils +// ====================================== +export const getCategoryString = (eventType: EventType): string => { + return eventType.category.join(', ').trim(); +}; + +// ====================================== +// Report utils +// ====================================== + +export const isGenerationError = (err: unknown): err is GenerationError => { + if ((err as GenerationError).name === undefined) { + return false; + } + if ((err as GenerationError).message === undefined) { + return false; + } + if ((err as GenerationError).messageDetail === undefined) { + return false; + } + if ((err as GenerationError).status === undefined) { + return false; + } + return true; +}; + +export const isQuotaExceededError = (err: unknown): err is DOMException => { + return ( + err instanceof DOMException && + (err.name === 'QuotaExceededError' || + // Firefox + err.name === 'NS_ERROR_DOM_QUOTA_REACHED') + ); +}; + +// ====================================== +// Discovery utils +// ====================================== +export const DEFAULT_EMPTY_UNIVERSE: EnvironmentNode = { + id: 0, + name: 'Universe', + nodeType: NodeType.UNIVERSE, + labels: {}, + children: [], +}; + +export const includesTarget = (arr: Target[], target: Target): boolean => { + return arr.some((t) => t.connectUrl === target.connectUrl); +}; + +export const isEqualTarget = (a?: Target, b?: Target): boolean => { + return a?.connectUrl === b?.connectUrl; +}; + +export const indexOfTarget = (arr: Target[], target: Target): number => { + let index = -1; + arr.forEach((t, idx) => { + if (t.connectUrl === target.connectUrl) { + index = idx; + } + }); + return index; +}; + +export const getTargetRepresentation = (t: Target) => + !t.alias || t.alias === t.connectUrl ? `${t.connectUrl}` : `${t.alias} (${t.connectUrl})`; + +export const isTargetAgentHttp = (t: Target) => t.connectUrl.startsWith('http'); + +export const isTargetNode = (node: EnvironmentNode | TargetNode): node is TargetNode => { + return node['target'] !== undefined && node['children'] === undefined; +}; + +export const getAllLeaves = (root: EnvironmentNode | TargetNode): TargetNode[] => { + if (isTargetNode(root)) { + return [root]; + } + const INIT: TargetNode[] = []; + return root.children.reduce((prev, curr) => prev.concat(getAllLeaves(curr)), INIT); +}; + +export const flattenTree = ( + node: EnvironmentNode | TargetNode, + includeUniverse?: boolean, +): (EnvironmentNode | TargetNode)[] => { + if (isTargetNode(node)) { + return [node]; + } + + const INIT: (EnvironmentNode | TargetNode)[] = []; + const allChildren = node.children.reduce((prev, curr) => prev.concat(flattenTree(curr)), INIT); + + if (node.nodeType === NodeType.UNIVERSE && !includeUniverse) { + return [...allChildren]; + } + + return [node, ...allChildren]; +}; + +export const getUniqueNodeTypes = (nodes: (EnvironmentNode | TargetNode)[]): NodeType[] => { + return Array.from(new Set(nodes.map((n) => n.nodeType))); +}; + +export const getUniqueGroupId = (group: EnvironmentNode) => { + return `${group.id}`; +}; + +export const getUniqueTargetId = (target: TargetNode) => { + return `${target.id}`; +}; + +// ====================================== +// Notifications utils +// ====================================== + +export const messageKeys = new Map([ + [ + // explicitly configure this category with a null message body mapper. + // This is a special case because this is generated client-side, + // not sent by the backend + NotificationCategory.GrafanaConfiguration, + { + title: 'Grafana Configuration', + }, + ], + [ + NotificationCategory.LayoutTemplateCreated, + { + title: 'Layout Template Created', + }, + ], + [ + NotificationCategory.TargetJvmDiscovery, + { + variant: AlertVariant.info, + title: 'Target JVM Discovery', + body: (v) => { + const evt: TargetDiscoveryEvent = v.message.event; + const target: Target = evt.serviceRef; + switch (evt.kind) { + case 'FOUND': + return `Target "${target.alias}" appeared (${target.connectUrl})"`; + case 'LOST': + return `Target "${target.alias}" disappeared (${target.connectUrl})"`; + case 'MODIFIED': + return `Target "${target.alias}" was modified (${target.connectUrl})"`; + default: + return `Received a notification with category ${NotificationCategory.TargetJvmDiscovery} and unrecognized kind ${evt.kind}`; + } + }, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.WsClientActivity, + { + variant: AlertVariant.info, + title: 'WebSocket Client Activity', + body: (evt) => { + const addr = Object.keys(evt.message)[0]; + const status = evt.message[addr]; + return `Client at ${addr} ${status}`; + }, + hidden: true, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.ActiveRecordingCreated, + { + variant: AlertVariant.success, + title: 'Recording Created', + body: (evt) => `${evt.message.recording.name} created in target: ${evt.message.target}`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.ActiveRecordingStopped, + { + variant: AlertVariant.success, + title: 'Recording Stopped', + body: (evt) => `${evt.message.recording.name} was stopped`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.ActiveRecordingSaved, + { + variant: AlertVariant.success, + title: 'Recording Saved', + body: (evt) => `${evt.message.recording.name} was archived`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.ActiveRecordingDeleted, + { + variant: AlertVariant.success, + title: 'Recording Deleted', + body: (evt) => `${evt.message.recording.name} was deleted`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.SnapshotCreated, + { + variant: AlertVariant.success, + title: 'Snapshot Created', + body: (evt) => `${evt.message.recording.name} was created in target: ${evt.message.target}`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.SnapshotDeleted, + { + variant: AlertVariant.success, + title: 'Snapshot Deleted', + body: (evt) => `${evt.message.recording.name} was deleted`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.ArchivedRecordingCreated, + { + variant: AlertVariant.success, + title: 'Archived Recording Uploaded', + body: (evt) => `${evt.message.recording.name} was uploaded into archives`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.ArchivedRecordingDeleted, + { + variant: AlertVariant.success, + title: 'Archived Recording Deleted', + body: (evt) => `${evt.message.recording.name} was deleted`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.TemplateUploaded, + { + variant: AlertVariant.success, + title: 'Template Created', + body: (evt) => `${evt.message.template.name} was created`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.ProbeTemplateUploaded, + { + variant: AlertVariant.success, + title: 'Probe Template Created', + body: (evt) => `${evt.message.probeTemplate} was created`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.ProbeTemplateApplied, + { + variant: AlertVariant.success, + title: 'Probe Template Applied', + body: (evt) => `${evt.message.probeTemplate} was inserted`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.TemplateDeleted, + { + variant: AlertVariant.success, + title: 'Template Deleted', + body: (evt) => `${evt.message.template.name} was deleted`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.ProbeTemplateDeleted, + { + variant: AlertVariant.success, + title: 'Probe Template Deleted', + body: (evt) => `${evt.message.probeTemplate} was deleted`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.ProbesRemoved, + { + variant: AlertVariant.success, + title: 'Probes Removed from Target', + body: (evt) => `Probes successfully removed from ${evt.message.target}`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.RuleCreated, + { + variant: AlertVariant.success, + title: 'Automated Rule Created', + body: (evt) => `${evt.message.name} was created`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.RuleUpdated, + { + variant: AlertVariant.success, + title: 'Automated Rule Updated', + body: (evt) => `${evt.message.name} was ` + (evt.message.enabled ? 'enabled' : 'disabled'), + } as NotificationMessageMapper, + ], + [ + NotificationCategory.RuleDeleted, + { + variant: AlertVariant.success, + title: 'Automated Rule Deleted', + body: (evt) => `${evt.message.name} was deleted`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.RecordingMetadataUpdated, + { + variant: AlertVariant.success, + title: 'Recording Metadata Updated', + body: (evt) => `${evt.message.recordingName} metadata was updated`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.TargetCredentialsStored, + { + variant: AlertVariant.success, + title: 'Target Credentials Stored', + body: (evt) => `Credentials stored for target: ${evt.message.target}`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.TargetCredentialsDeleted, + { + variant: AlertVariant.success, + title: 'Target Credentials Deleted', + body: (evt) => `Credentials deleted for target: ${evt.message.target}`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.CredentialsStored, + { + variant: AlertVariant.success, + title: 'Credentials Stored', + body: (evt) => `Credentials stored for: ${evt.message.matchExpression}`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.CredentialsDeleted, + { + variant: AlertVariant.success, + title: 'Credentials Deleted', + body: (evt) => `Credentials deleted for: ${evt.message.matchExpression}`, + } as NotificationMessageMapper, + ], +]); diff --git a/src/app/Shared/Services/service.types.ts b/src/app/Shared/Services/service.types.ts new file mode 100644 index 000000000..6835da6da --- /dev/null +++ b/src/app/Shared/Services/service.types.ts @@ -0,0 +1,68 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EventTemplate } from './api.types'; + +// ====================================== +// Credential +// ====================================== +export interface Credential { + username: string; + password: string; +} + +// ====================================== +// Setting +// ====================================== +export enum FeatureLevel { + DEVELOPMENT = 0, + BETA = 1, + PRODUCTION = 2, +} + +export interface AutomatedAnalysisRecordingConfig { + template: Pick; + maxSize: number; + maxAge: number; +} + +export const defaultAutomatedAnalysisRecordingConfig: AutomatedAnalysisRecordingConfig = { + template: { + name: 'Continuous', + type: 'TARGET', + }, + maxSize: 1048576, + maxAge: 0, +}; + +export interface ChartControllerConfig { + minRefresh: number; +} + +// ====================================== +// Login +// ====================================== +export enum SessionState { + NO_USER_SESSION, + CREATING_USER_SESSION, + USER_SESSION, +} + +export enum AuthMethod { + BASIC = 'Basic', + BEARER = 'Bearer', + NONE = 'None', + UNKNOWN = '', +} diff --git a/src/app/Shared/Services/service.utils.ts b/src/app/Shared/Services/service.utils.ts new file mode 100644 index 000000000..a43f0171d --- /dev/null +++ b/src/app/Shared/Services/service.utils.ts @@ -0,0 +1,56 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { automatedAnalysisRecordingName, RecordingAttributes, Target } from './api.types'; +import { MatchExpressionService } from './MatchExpression.service'; +import { AutomatedAnalysisRecordingConfig, ChartControllerConfig } from './service.types'; + +// ====================================== +// Match Expression +// ====================================== +export const SearchExprServiceContext = React.createContext(new MatchExpressionService()); + +export const MatchedTargetsServiceContext = React.createContext(new BehaviorSubject(undefined)); + +// ====================================== +// Setting +// ====================================== +export const defaultChartControllerConfig: ChartControllerConfig = { + minRefresh: 10, +}; + +export const automatedAnalysisConfigToRecordingAttributes = ( + config: AutomatedAnalysisRecordingConfig, +): RecordingAttributes => { + return { + name: automatedAnalysisRecordingName, + events: `template=${config.template.name},type=${config.template.type}`, + duration: undefined, + archiveOnStop: false, + advancedOptions: { + toDisk: true, + maxAge: config.maxAge, + maxSize: config.maxSize, + }, + metadata: { + labels: { + origin: automatedAnalysisRecordingName, + }, + }, + }; +}; diff --git a/src/app/Shared/SerializedTarget.tsx b/src/app/TargetView/SerializedTarget.tsx similarity index 83% rename from src/app/Shared/SerializedTarget.tsx rename to src/app/TargetView/SerializedTarget.tsx index e673280d9..beea17b3d 100644 --- a/src/app/Shared/SerializedTarget.tsx +++ b/src/app/TargetView/SerializedTarget.tsx @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Target } from '@app/Shared/Services/Target.service'; + +import { Target } from '@app/Shared/Services/api.types'; import { NoTargetSelected } from '@app/TargetView/NoTargetSelected'; import { CodeBlock, CodeBlockCode } from '@patternfly/react-core'; import * as React from 'react'; @@ -23,14 +24,14 @@ export interface SerializedTargetProps { indentLevel?: number; } -export const SerializedTarget: React.FC = (props) => { +export const SerializedTarget: React.FC = ({ target, indentLevel }) => { return ( <> - {!props.target ? ( + {!target ? ( ) : ( - {JSON.stringify(props.target, null, props.indentLevel || 2)} + {JSON.stringify(target, null, indentLevel || 2)} )} diff --git a/src/app/TargetView/TargetContextSelector.tsx b/src/app/TargetView/TargetContextSelector.tsx index 7265c705f..4ab01089c 100644 --- a/src/app/TargetView/TargetContextSelector.tsx +++ b/src/app/TargetView/TargetContextSelector.tsx @@ -13,21 +13,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LinearDotSpinner } from '@app/Shared/LinearDotSpinner'; +import { LinearDotSpinner } from '@app/Shared/Components/LinearDotSpinner'; +import { Target } from '@app/Shared/Services/api.types'; +import { isEqualTarget, getTargetRepresentation } from '@app/Shared/Services/api.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { getTargetRepresentation, isEqualTarget, NO_TARGET, Target } from '@app/Shared/Services/Target.service'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { getFromLocalStorage, removeFromLocalStorage, saveToLocalStorage } from '@app/utils/LocalStorage'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; import { Button, Divider, Select, SelectGroup, SelectOption, SelectVariant } from '@patternfly/react-core'; import * as React from 'react'; import { Link } from 'react-router-dom'; -export const TargetContextSelector: React.FC<{ className?: string }> = ({ className, ...props }) => { +export interface TargetContextSelectorProps { + className?: string; +} + +export const TargetContextSelector: React.FC = ({ className, ...props }) => { const context = React.useContext(ServiceContext); const addSubscription = useSubscriptions(); const [targets, setTargets] = React.useState([]); - const [selectedTarget, setSelectedTarget] = React.useState(NO_TARGET); + const [selectedTarget, setSelectedTarget] = React.useState(); const [favorites, setFavorites] = React.useState(getFromLocalStorage('TARGET_FAVORITES', [])); const [isTargetOpen, setIsTargetOpen] = React.useState(false); const [isLoading, setLoading] = React.useState(false); @@ -37,7 +42,7 @@ export const TargetContextSelector: React.FC<{ className?: string }> = ({ classN const handleTargetSelect = React.useCallback( (_, { target }, isPlaceholder) => { setIsTargetOpen(false); - const toSelect: Target = isPlaceholder ? NO_TARGET : target; + const toSelect: Target = isPlaceholder ? undefined : target; if (!isEqualTarget(toSelect, selectedTarget)) { context.target.setTarget(toSelect); } @@ -49,7 +54,7 @@ export const TargetContextSelector: React.FC<{ className?: string }> = ({ classN addSubscription( context.target.target().subscribe((target) => { setSelectedTarget(target); - if (target !== NO_TARGET) { + if (target) { // Only save to local storage when target is valid // NO_TARGET will clear storage saveToLocalStorage('TARGET', target.connectUrl); @@ -66,13 +71,13 @@ export const TargetContextSelector: React.FC<{ className?: string }> = ({ classN if (!targets.length) { return; } - const cachedTargetUrl = getFromLocalStorage('TARGET', NO_TARGET); + const cachedTargetUrl = getFromLocalStorage('TARGET', ''); const matchedTarget = targets.find((t) => t.connectUrl === cachedTargetUrl); if (matchedTarget) { context.target.setTarget(matchedTarget); } else { - context.target.setTarget(NO_TARGET); + context.target.setTarget(undefined); removeFromLocalStorage('TARGET'); } setFavorites((old) => old.filter((f) => targets.some((t) => t.connectUrl === f))); @@ -192,7 +197,7 @@ export const TargetContextSelector: React.FC<{ className?: string }> = ({ classN ); const selectionPrefix = React.useMemo( - () => (selectedTarget !== NO_TARGET ? Target: : undefined), + () => (!selectedTarget ? undefined : Target:), [selectedTarget], ); @@ -229,13 +234,13 @@ export const TargetContextSelector: React.FC<{ className?: string }> = ({ classN onFilter={handleTargetFilter} isGrouped={!noOptions} selections={ - selectedTarget !== NO_TARGET - ? { + !selectedTarget + ? undefined + : { toString: () => getTargetRepresentation(selectedTarget), compareTo: (other) => other.target.connectUrl === selectedTarget.connectUrl, ...{ target: selectedTarget }, } - : undefined } footer={selectFooter} favorites={favorites} diff --git a/src/app/Shared/TargetSelect.tsx b/src/app/TargetView/TargetSelect.tsx similarity index 84% rename from src/app/Shared/TargetSelect.tsx rename to src/app/TargetView/TargetSelect.tsx index a22f6b1fa..1a4d40bbe 100644 --- a/src/app/Shared/TargetSelect.tsx +++ b/src/app/TargetView/TargetSelect.tsx @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LoadingView } from '@app/LoadingView/LoadingView'; -import { SerializedTarget } from '@app/Shared/SerializedTarget'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { Target } from '@app/Shared/Services/api.types'; +import { includesTarget } from '@app/Shared/Services/api.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { includesTarget, NO_TARGET, Target } from '@app/Shared/Services/Target.service'; import { NoTargetSelected } from '@app/TargetView/NoTargetSelected'; +import { SerializedTarget } from '@app/TargetView/SerializedTarget'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { getFromLocalStorage } from '@app/utils/LocalStorage'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; import { Card, CardBody, @@ -35,10 +36,8 @@ import { ContainerNodeIcon } from '@patternfly/react-icons'; import * as React from 'react'; export interface TargetSelectProps { - // display a simple, non-expandable component. set this if the view elsewhere - // contains a or other repeated components - simple?: boolean; - onSelect?: (target: Target) => void; + simple?: boolean; // Display a simple, non-expandable component + onSelect?: (target?: Target) => void; } export const TargetSelect: React.FC = ({ onSelect, simple, ...props }) => { @@ -47,8 +46,8 @@ export const TargetSelect: React.FC = ({ onSelect, simple, .. const firstLoadRef = React.useRef(false); const [isExpanded, setExpanded] = React.useState(false); - const [selected, setSelected] = React.useState(NO_TARGET); - const [targets, setTargets] = React.useState([] as Target[]); + const [selected, setSelected] = React.useState(); + const [targets, setTargets] = React.useState([]); const [isDropdownOpen, setDropdownOpen] = React.useState(false); const [isLoading, setLoading] = React.useState(false); @@ -69,7 +68,7 @@ export const TargetSelect: React.FC = ({ onSelect, simple, .. const handleSelect = React.useCallback( (_, selection, isPlaceholder) => { setDropdownOpen(false); - const toSelect: Target = isPlaceholder ? NO_TARGET : selection; + const toSelect: Target = isPlaceholder ? undefined : selection; onSelect && onSelect(toSelect); setSelected(toSelect); }, @@ -92,7 +91,7 @@ export const TargetSelect: React.FC = ({ onSelect, simple, .. }, [context.settings, _refreshTargetList]); React.useEffect(() => { - if (selected !== NO_TARGET && !includesTarget(targets, selected)) { + if (!!selected && !includesTarget(targets, selected)) { handleSelect(undefined, undefined, true); } if (targets.length && !firstLoadRef.current) { @@ -140,7 +139,7 @@ export const TargetSelect: React.FC = ({ onSelect, simple, .. .map((grp) => React.cloneElement(grp, { children: grp.props.children.filter( - (option) => matchExp.test(option.props.value.connectUrl) || matchExp.test(option.props.value.alias), + (child) => matchExp.test(child.props.value.connectUrl) || matchExp.test(child.props.value.alias), ), }), ) @@ -185,7 +184,7 @@ export const TargetSelect: React.FC = ({ onSelect, simple, .. onFilter={handleTargetFilter} onSelect={handleSelect} onToggle={setDropdownOpen} - selections={selected.alias || selected.connectUrl} + selections={selected?.alias || selected?.connectUrl} isFlipEnabled={true} menuAppendTo="parent" maxHeight="20em" @@ -196,9 +195,7 @@ export const TargetSelect: React.FC = ({ onSelect, simple, ..
- - {selected === NO_TARGET ? : } - + {!selected ? : } )} diff --git a/src/app/TargetView/TargetView.tsx b/src/app/TargetView/TargetView.tsx index cbe1bcf71..4813e3a98 100644 --- a/src/app/TargetView/TargetView.tsx +++ b/src/app/TargetView/TargetView.tsx @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { BreadcrumbPage, BreadcrumbTrail } from '@app/BreadcrumbPage/BreadcrumbPage'; +import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; +import { BreadcrumbTrail } from '@app/BreadcrumbPage/types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NO_TARGET } from '@app/Shared/Services/Target.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { NoTargetSelected } from '@app/TargetView/NoTargetSelected'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import * as React from 'react'; import { distinctUntilChanged, map } from 'rxjs/operators'; -import { NoTargetSelected } from './NoTargetSelected'; import { TargetContextSelector } from './TargetContextSelector'; interface TargetViewProps { @@ -29,7 +29,7 @@ interface TargetViewProps { children: React.ReactNode; } -export const TargetView: React.FC = (props) => { +export const TargetView: React.FC = ({ attachments, pageTitle, breadcrumbs, children }) => { const context = React.useContext(ServiceContext); const [hasSelection, setHasSelection] = React.useState(false); const addSubscription = useSubscriptions(); @@ -39,7 +39,7 @@ export const TargetView: React.FC = (props) => { context.target .target() .pipe( - map((target) => target !== NO_TARGET), + map((target) => !!target), distinctUntilChanged(), ) .subscribe(setHasSelection), @@ -49,9 +49,9 @@ export const TargetView: React.FC = (props) => { return ( <> - {props.attachments} - - {hasSelection ? props.children : } + {attachments} + + {hasSelection ? children : } ); diff --git a/src/app/Topology/Actions/CreateTarget.tsx b/src/app/Topology/Actions/CreateTarget.tsx index 67b80a82a..f281f0c17 100644 --- a/src/app/Topology/Actions/CreateTarget.tsx +++ b/src/app/Topology/Actions/CreateTarget.tsx @@ -16,15 +16,15 @@ import openjdkSvg from '@app/assets/openjdk.svg'; import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; -import { Locations } from '@app/Settings/CredentialsStorage'; -import { LinearDotSpinner } from '@app/Shared/LinearDotSpinner'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; -import { isHttpOk } from '@app/Shared/Services/Api.service'; +import { Locations } from '@app/Settings/Config/CredentialsStorage'; +import { LinearDotSpinner } from '@app/Shared/Components/LinearDotSpinner'; +import { LoadingProps } from '@app/Shared/Components/types'; +import { Target } from '@app/Shared/Services/api.types'; +import { isHttpOk } from '@app/Shared/Services/api.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; import '@app/Topology/styles/base.css'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { getFromLocalStorage } from '@app/utils/LocalStorage'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; import { portalRoot } from '@app/utils/utils'; import { Accordion, @@ -71,7 +71,7 @@ export interface CreateTargetProps { }; } -export const CreateTarget: React.FC = ({ prefilled, ..._props }) => { +export const CreateTarget: React.FC = ({ prefilled }) => { const addSubscription = useSubscriptions(); const context = React.useContext(ServiceContext); const history = useHistory(); @@ -103,7 +103,7 @@ export const CreateTarget: React.FC = ({ prefilled, ..._props spinnerAriaValueText: 'Creating', spinnerAriaLabel: 'creating-custom-target', isLoading: loading, - }) as LoadingPropsType, + }) as LoadingProps, [loading], ); diff --git a/src/app/Topology/Actions/NodeActions.tsx b/src/app/Topology/Actions/NodeActions.tsx index 847dff569..86acb0d31 100644 --- a/src/app/Topology/Actions/NodeActions.tsx +++ b/src/app/Topology/Actions/NodeActions.tsx @@ -14,27 +14,17 @@ * limitations under the License. */ -import { Notifications, NotificationsContext } from '@app/Notifications/Notifications'; -import { ActiveRecording } from '@app/Shared/Services/Api.service'; -import { NotificationCategory, NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; +import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { Dropdown, DropdownItem, DropdownItemProps, DropdownProps, DropdownToggle } from '@patternfly/react-core'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; +import { Dropdown, DropdownItem, DropdownProps, DropdownToggle } from '@patternfly/react-core'; import { css } from '@patternfly/react-styles'; -import { ContextMenuItem as PFContextMenuItem, GraphElement } from '@patternfly/react-topology'; +import { ContextMenuItem as PFContextMenuItem } from '@patternfly/react-topology'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { Observable, Subject, switchMap, map, merge, filter, debounceTime } from 'rxjs'; -import { getConnectUrlFromEvent } from '../Shared/Entity/utils'; -import { getAllLeaves, ListElement } from '../Shared/utils'; -import { EnvironmentNode, NodeType, TargetNode } from '../typings'; -import { ActionUtils } from './utils'; - -export type NodeActionFunction = (element: GraphElement | ListElement, actionUtils: ActionUtils) => void; - -export type MenuItemVariant = 'dropdownItem' | 'contextMenuItem'; - -export type MenuItemComponent = React.FC | React.FC>; +import { Observable, Subject, switchMap } from 'rxjs'; +import { GraphElement, ListElement } from '../Shared/types'; +import { ActionUtils, MenuItemComponent, MenuItemVariant, NodeActionFunction } from './types'; export interface ContextMenuItemProps extends Omit< @@ -110,338 +100,6 @@ export const ContextMenuItem: React.FC = ({ ); }; -export type NodeActionKey = - | 'VIEW_DASHBOARD' - | 'VIEW_RECORDINGS' - | 'CREATE_RECORDINGS' - | 'CREATE_RULES' - | 'DELETE_TARGET' - | 'GROUP_START_RECORDING' - | 'GROUP_STOP_RECORDING' - | 'GROUP_DELETE_RECORDING' - | 'GROUP_ARCHIVE_RECORDING' - | ''; - -export const QUICK_RECORDING_NAME = 'cryostat_topology_action'; - -export const QUICK_RECORDING_LABEL_KEY = 'cryostat.io.topology-group'; - -const isQuickRecording = (recording: ActiveRecording) => { - return recording.name === QUICK_RECORDING_NAME; -}; - -const isQuickRecordingExist = (group: EnvironmentNode, { services }: ActionUtils) => { - const svcUrls = new Set(getAllLeaves(group).map((tn) => tn.target.connectUrl)); - const filterFn = (e: NotificationMessage) => { - const targetId = getConnectUrlFromEvent(e); - const recording = e.message.recording; - return targetId !== undefined && svcUrls.has(targetId) && isQuickRecording(recording); - }; - - return merge( - services.api.groupHasRecording(group, { name: QUICK_RECORDING_NAME }), - services.notificationChannel.messages(NotificationCategory.ActiveRecordingCreated).pipe( - filter(filterFn), - map((_) => true), - ), - services.notificationChannel.messages(NotificationCategory.ActiveRecordingDeleted).pipe( - filter(filterFn), - debounceTime(500), - map((_) => services.api.groupHasRecording(group, { name: QUICK_RECORDING_NAME })), - ), - ); -}; - -export interface NodeAction { - readonly key: NodeActionKey; - readonly isGroup?: boolean; - readonly action?: NodeActionFunction; - readonly title?: React.ReactNode; - readonly isSeparator?: boolean; - readonly isDisabled?: (element: GraphElement | ListElement, actionUtils: ActionUtils) => Observable; - readonly includeList?: NodeType[]; // Empty means all - readonly blockList?: NodeType[]; // Empty means none -} - -export const nodeActions: NodeAction[] = [ - { - key: 'VIEW_DASHBOARD', - action: (element, { history, services }) => { - const targetNode: TargetNode = element.getData(); - - services.target.setTarget(targetNode.target); - history.push('/'); - }, - title: 'View Dashboard', - }, - { - key: 'VIEW_RECORDINGS', - action: (element, { history, services }) => { - const targetNode: TargetNode = element.getData(); - - services.target.setTarget(targetNode.target); - history.push('/recordings'); - }, - title: 'View Recordings', - }, - { key: '', isSeparator: true }, - { - key: 'CREATE_RECORDINGS', - action: (element, { history, services }) => { - const targetNode: TargetNode = element.getData(); - - services.target.setTarget(targetNode.target); - history.push('/recordings/create'); - }, - title: 'Create Recordings', - }, - { - key: 'CREATE_RULES', - action: (element, { history, services }) => { - const targetNode: TargetNode = element.getData(); - - services.target.setTarget(targetNode.target); - history.push('/rules/create'); - }, - title: 'Create Automated Rules', - }, - { key: '', isSeparator: true }, - { - key: 'DELETE_TARGET', - action: (element, { services }) => { - const targetNode: TargetNode = element.getData(); - services.api.deleteTarget(targetNode.target).subscribe(() => undefined); - }, - title: 'Delete Target', - includeList: [NodeType.CUSTOM_TARGET], - }, - { - key: 'GROUP_START_RECORDING', - title: 'Start recording', - isGroup: true, - action: (element, { services, notifications }) => { - const group: EnvironmentNode = element.getData(); - services.api - .graphql( - ` - query StartRecordingForGroup($filter: EnvironmentNodeFilterInput, $recordingName: String!, $labels: String) { - environmentNodes(filter: $filter) { - name - descendantTargets { - name - doStartRecording(recording: { - name: $recordingName, - template: "Continuous", - templateType: "TARGET", - duration: 0, - restart: true, - metadata: { - labels: $labels - }, - }) { - name - state - } - } - } - } - `, - { - filter: { id: group.id }, - recordingName: QUICK_RECORDING_NAME, - labels: services.api.stringifyRecordingLabels([ - { - key: QUICK_RECORDING_LABEL_KEY, - value: group.name.replace(/[\s+-]/g, '_'), - }, - ]), - }, - false, - true, - ) - .subscribe((body) => { - notifyGroupActionErrors('GROUP_START_RECORDING', group, body, notifications); - }); - }, - }, - { - key: 'GROUP_ARCHIVE_RECORDING', - title: 'Archive recording', - isGroup: true, - action: (element, { services, notifications }) => { - const group: EnvironmentNode = element.getData(); - services.api - .graphql( - ` - query DeleteRecordingForGroup ($groupFilter: EnvironmentNodeFilterInput, $recordingFilter: ActiveRecordingFilterInput){ - environmentNodes(filter: $groupFilter) { - name - descendantTargets { - name - recordings { - active(filter: $recordingFilter) { - data { - doArchive { - name - } - } - } - } - } - } - } - `, - { - groupFilter: { id: group.id }, - recordingFilter: { name: QUICK_RECORDING_NAME }, - }, - false, - true, - ) - .subscribe((body) => { - notifyGroupActionErrors('GROUP_ARCHIVE_RECORDING', group, body, notifications); - }); - }, - isDisabled: (element, utils) => { - return isQuickRecordingExist(element.getData(), utils).pipe(map((exist) => !exist)); - }, - }, - { - key: 'GROUP_STOP_RECORDING', - title: 'Stop recording', - isGroup: true, - action: (element, { services, notifications }) => { - const group: EnvironmentNode = element.getData(); - services.api - .graphql( - ` - query StopRecordingForGroup ($groupFilter: EnvironmentNodeFilterInput, $recordingFilter: ActiveRecordingFilterInput){ - environmentNodes(filter: $groupFilter) { - name - descendantTargets { - name - recordings { - active(filter: $recordingFilter) { - data { - doStop { - name - state - } - } - } - } - } - } - } - `, - { - groupFilter: { id: group.id }, - recordingFilter: { name: QUICK_RECORDING_NAME }, - }, - false, - true, - ) - .subscribe((body) => { - notifyGroupActionErrors('GROUP_STOP_RECORDING', group, body, notifications); - }); - }, - isDisabled: (element, utils) => { - return isQuickRecordingExist(element.getData(), utils).pipe(map((exist) => !exist)); - }, - }, - { key: '', isSeparator: true, isGroup: true }, - { - key: 'GROUP_DELETE_RECORDING', - title: 'Delete recording', - isGroup: true, - action: (element, { services, notifications }) => { - const group: EnvironmentNode = element.getData(); - services.api - .graphql( - ` - query DeleteRecordingForGroup ($groupFilter: EnvironmentNodeFilterInput, $recordingFilter: ActiveRecordingFilterInput){ - environmentNodes(filter: $groupFilter) { - name - descendantTargets { - name - recordings { - active(filter: $recordingFilter) { - data { - doDelete { - name - state - } - } - } - } - } - } - } - `, - { - groupFilter: { id: group.id }, - recordingFilter: { name: QUICK_RECORDING_NAME }, - }, - false, - true, - ) - .subscribe((body) => { - notifyGroupActionErrors('GROUP_DELETE_RECORDING', group, body, notifications); - }); - }, - isDisabled: (element, utils) => { - return isQuickRecordingExist(element.getData(), utils).pipe(map((exist) => !exist)); - }, - }, -]; - -type GroupActionResponse = { - errors?: { - message: string; - path: (string | number)[]; - }[]; - data: { - environmentNodes: { - descendantTargets: { name: string }[]; // graphql query specifies name - }[]; - }; -}; - -export const notifyGroupActionErrors = ( - type: Extract< - 'GROUP_START_RECORDING' | 'GROUP_ARCHIVE_RECORDING' | 'GROUP_STOP_RECORDING' | 'GROUP_DELETE_RECORDING', - NodeActionKey - >, - group: EnvironmentNode, - { errors, data }: GroupActionResponse, - notifications: Notifications, -): void => { - if (errors) { - const actionVerb = type - .split('_') - .slice(1) - .map((str) => str.toLowerCase()) - .join(' '); - const groupDisplay = `${group.nodeType} ${group.name}`; - errors.forEach((err) => { - // Location of failed target node - const searchIndex = Number(err.path[err.path.indexOf('descendantTargets') + 1]); - if (searchIndex == undefined) { - notifications.danger(`Could not ${actionVerb} for a Target in ${groupDisplay}`, err.message); - } - - // Get the name of failed target node - const name: string | undefined = data.environmentNodes[0]?.descendantTargets[searchIndex]?.name; - - if (name) { - notifications.danger(`Could not ${actionVerb} for Target ${name} in ${groupDisplay}`, err.message); - } else { - notifications.danger(`Could not ${actionVerb} for a Target in ${groupDisplay}`, err.message); - } - }); - } -}; - export interface ActionDropdownProps extends Omit { actions: JSX.Element[]; } diff --git a/src/app/Topology/Actions/QuickSearchPanel.tsx b/src/app/Topology/Actions/QuickSearchPanel.tsx index 39a816d41..55c401d2b 100644 --- a/src/app/Topology/Actions/QuickSearchPanel.tsx +++ b/src/app/Topology/Actions/QuickSearchPanel.tsx @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotificationsContext } from '@app/Notifications/Notifications'; +import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { useFeatureLevel } from '@app/utils/useFeatureLevel'; +import { useFeatureLevel } from '@app/utils/hooks/useFeatureLevel'; import { portalRoot } from '@app/utils/utils'; import { Bullseye, @@ -49,9 +49,9 @@ import { css } from '@patternfly/react-styles'; import { useHover } from '@patternfly/react-topology'; import React from 'react'; import { Link, useHistory } from 'react-router-dom'; -import QuickSearchIcon from '../Shared/QuickSearchIcon'; +import QuickSearchIcon from '../../Shared/Components/QuickSearchIcon'; import quickSearches, { QuickSearchId, quickSearchIds } from './quicksearches/all-quick-searches'; -import { QuickSearchItem } from './utils'; +import { QuickSearchItem } from './types'; export const QuickSearchTabContent: React.FC<{ item?: QuickSearchItem }> = ({ item, ...props }) => { const history = useHistory(); diff --git a/src/app/Topology/Actions/WarningResolver.tsx b/src/app/Topology/Actions/WarningResolver.tsx index eb40d2991..f65e60675 100644 --- a/src/app/Topology/Actions/WarningResolver.tsx +++ b/src/app/Topology/Actions/WarningResolver.tsx @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotificationsContext } from '@app/Notifications/Notifications'; import { CreateCredentialModal } from '@app/SecurityPanel/Credentials/CreateCredentialModal'; +import { TargetNode } from '@app/Shared/Services/api.types'; +import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; import { ServiceContext } from '@app/Shared/Services/Services'; import { Button, ButtonProps } from '@patternfly/react-core'; import * as React from 'react'; import { Link, useHistory } from 'react-router-dom'; -import { TargetNode } from '../typings'; -import { ActionUtils } from './utils'; +import { ActionUtils } from './types'; export interface WarningResolverAsLinkProps extends React.ComponentProps {} diff --git a/src/app/Topology/Actions/quicksearches/custom-target.tsx b/src/app/Topology/Actions/quicksearches/custom-target.tsx index 59b4ffd02..ae01f7228 100644 --- a/src/app/Topology/Actions/quicksearches/custom-target.tsx +++ b/src/app/Topology/Actions/quicksearches/custom-target.tsx @@ -14,9 +14,9 @@ * limitations under the License. */ import openjdkSvg from '@app/assets/openjdk.svg'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import * as React from 'react'; -import { QuickSearchItem } from '../utils'; +import { QuickSearchItem } from '../types'; const _CustomTargetSearchItem: QuickSearchItem = { id: 'custom-target', diff --git a/src/app/Topology/Actions/quicksearches/dev-sample.tsx b/src/app/Topology/Actions/quicksearches/dev-sample.tsx index ad95882ae..cca074336 100644 --- a/src/app/Topology/Actions/quicksearches/dev-sample.tsx +++ b/src/app/Topology/Actions/quicksearches/dev-sample.tsx @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ContainerNodeIcon } from '@patternfly/react-icons'; import * as React from 'react'; -import { QuickSearchItem } from '../utils'; +import { QuickSearchItem } from '../types'; const _DevSampleSearchItem: QuickSearchItem = { id: 'dev-sample', diff --git a/src/app/Topology/Actions/types.ts b/src/app/Topology/Actions/types.ts new file mode 100644 index 000000000..17856bee1 --- /dev/null +++ b/src/app/Topology/Actions/types.ts @@ -0,0 +1,90 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { NodeType } from '@app/Shared/Services/api.types'; +import { NotificationService } from '@app/Shared/Services/Notifications.service'; +import type { FeatureLevel } from '@app/Shared/Services/service.types'; +import { Services } from '@app/Shared/Services/Services'; +import { DropdownItemProps, LabelProps } from '@patternfly/react-core'; +import { ContextMenuItem as PFContextMenuItem } from '@patternfly/react-topology'; +import * as React from 'react'; +import { useHistory } from 'react-router-dom'; +import { Observable } from 'rxjs'; +import type { GraphElement, ListElement } from '../Shared/types'; + +export interface ActionUtils { + history: ReturnType; + services: Services; + notifications: NotificationService; +} + +export interface QuickSearchItem { + id: string; + name: string; + icon?: React.ReactNode; + labels?: { + content: string; + color?: LabelProps['color']; + icon?: React.ReactNode; + }[]; + descriptionShort?: string; + descriptionFull?: string; + featureLevel: FeatureLevel; + disabled?: boolean; + actionText?: React.ReactNode; + createAction?: (utils: ActionUtils) => void; +} + +export type NodeActionFunction = (element: GraphElement | ListElement, actionUtils: ActionUtils) => void; + +export type MenuItemVariant = 'dropdownItem' | 'contextMenuItem'; + +export type MenuItemComponent = React.FC | React.FC>; + +export type NodeActionKey = + | 'VIEW_DASHBOARD' + | 'VIEW_RECORDINGS' + | 'CREATE_RECORDINGS' + | 'CREATE_RULES' + | 'DELETE_TARGET' + | 'GROUP_START_RECORDING' + | 'GROUP_STOP_RECORDING' + | 'GROUP_DELETE_RECORDING' + | 'GROUP_ARCHIVE_RECORDING' + | ''; + +export interface NodeAction { + readonly key: NodeActionKey; + readonly isGroup?: boolean; + readonly action?: NodeActionFunction; + readonly title?: React.ReactNode; + readonly isSeparator?: boolean; + readonly isDisabled?: (element: GraphElement | ListElement, actionUtils: ActionUtils) => Observable; + readonly includeList?: NodeType[]; // Empty means all + readonly blockList?: NodeType[]; // Empty means none +} + +export type GroupActionResponse = { + errors?: { + message: string; + path: (string | number)[]; + }[]; + data: { + environmentNodes: { + descendantTargets: { name: string }[]; // graphql query specifies name + }[]; + }; +}; diff --git a/src/app/Topology/Actions/utils.ts b/src/app/Topology/Actions/utils.ts deleted file mode 100644 index 4e4c71ea0..000000000 --- a/src/app/Topology/Actions/utils.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright The Cryostat Authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Notifications } from '@app/Notifications/Notifications'; -import { Services } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { LabelProps } from '@patternfly/react-core'; -import * as React from 'react'; -import { useHistory } from 'react-router-dom'; - -export interface ActionUtils { - history: ReturnType; - services: Services; - notifications: Notifications; -} - -export interface QuickSearchItem { - id: string; - name: string; - icon?: React.ReactNode; - labels?: { - content: string; - color?: LabelProps['color']; - icon?: React.ReactNode; - }[]; - descriptionShort?: string; - descriptionFull?: string; - featureLevel: FeatureLevel; - disabled?: boolean; - actionText?: React.ReactNode; - createAction?: (utils: ActionUtils) => void; -} diff --git a/src/app/Topology/Actions/utils.tsx b/src/app/Topology/Actions/utils.tsx new file mode 100644 index 000000000..952b1124d --- /dev/null +++ b/src/app/Topology/Actions/utils.tsx @@ -0,0 +1,367 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ActiveRecording, + EnvironmentNode, + NotificationMessage, + NotificationCategory, + NodeType, + TargetNode, +} from '@app/Shared/Services/api.types'; +import { getAllLeaves, isTargetNode } from '@app/Shared/Services/api.utils'; +import { NotificationService } from '@app/Shared/Services/Notifications.service'; +import { ContextMenuSeparator } from '@patternfly/react-topology'; +import * as React from 'react'; +import { merge, filter, map, debounceTime } from 'rxjs'; +import { getConnectUrlFromEvent } from '../Entity/utils'; +import { GraphElement, ListElement } from '../Shared/types'; +import { ContextMenuItem } from './NodeActions'; +import type { ActionUtils, NodeAction, NodeActionKey, GroupActionResponse, MenuItemVariant } from './types'; + +export const QUICK_RECORDING_NAME = 'cryostat_topology_action'; + +export const QUICK_RECORDING_LABEL_KEY = 'cryostat.io.topology-group'; + +export const isQuickRecording = (recording: ActiveRecording) => { + return recording.name === QUICK_RECORDING_NAME; +}; + +export const isQuickRecordingExist = (group: EnvironmentNode, { services }: ActionUtils) => { + const svcUrls = new Set(getAllLeaves(group).map((tn) => tn.target.connectUrl)); + const filterFn = (e: NotificationMessage) => { + const targetId = getConnectUrlFromEvent(e); + const recording = e.message.recording; + return targetId !== undefined && svcUrls.has(targetId) && isQuickRecording(recording); + }; + + return merge( + services.api.groupHasRecording(group, { name: QUICK_RECORDING_NAME }), + services.notificationChannel.messages(NotificationCategory.ActiveRecordingCreated).pipe( + filter(filterFn), + map((_) => true), + ), + services.notificationChannel.messages(NotificationCategory.ActiveRecordingDeleted).pipe( + filter(filterFn), + debounceTime(500), + map((_) => services.api.groupHasRecording(group, { name: QUICK_RECORDING_NAME })), + ), + ); +}; + +export const nodeActions: NodeAction[] = [ + { + key: 'VIEW_DASHBOARD', + action: (element, { history, services }) => { + const targetNode: TargetNode = element.getData(); + + services.target.setTarget(targetNode.target); + history.push('/'); + }, + title: 'View Dashboard', + }, + { + key: 'VIEW_RECORDINGS', + action: (element, { history, services }) => { + const targetNode: TargetNode = element.getData(); + + services.target.setTarget(targetNode.target); + history.push('/recordings'); + }, + title: 'View Recordings', + }, + { key: '', isSeparator: true }, + { + key: 'CREATE_RECORDINGS', + action: (element, { history, services }) => { + const targetNode: TargetNode = element.getData(); + + services.target.setTarget(targetNode.target); + history.push('/recordings/create'); + }, + title: 'Create Recordings', + }, + { + key: 'CREATE_RULES', + action: (element, { history, services }) => { + const targetNode: TargetNode = element.getData(); + + services.target.setTarget(targetNode.target); + history.push('/rules/create'); + }, + title: 'Create Automated Rules', + }, + { key: '', isSeparator: true }, + { + key: 'DELETE_TARGET', + action: (element, { services }) => { + const targetNode: TargetNode = element.getData(); + services.api.deleteTarget(targetNode.target).subscribe(() => undefined); + }, + title: 'Delete Target', + includeList: [NodeType.CUSTOM_TARGET], + }, + { + key: 'GROUP_START_RECORDING', + title: 'Start recording', + isGroup: true, + action: (element, { services, notifications }) => { + const group: EnvironmentNode = element.getData(); + services.api + .graphql( + ` + query StartRecordingForGroup($filter: EnvironmentNodeFilterInput, $recordingName: String!, $labels: String) { + environmentNodes(filter: $filter) { + name + descendantTargets { + name + doStartRecording(recording: { + name: $recordingName, + template: "Continuous", + templateType: "TARGET", + duration: 0, + restart: true, + metadata: { + labels: $labels + }, + }) { + name + state + } + } + } + } + `, + { + filter: { id: group.id }, + recordingName: QUICK_RECORDING_NAME, + labels: services.api.stringifyRecordingLabels([ + { + key: QUICK_RECORDING_LABEL_KEY, + value: group.name.replace(/[\s+-]/g, '_'), + }, + ]), + }, + false, + true, + ) + .subscribe((body) => { + notifyGroupActionErrors('GROUP_START_RECORDING', group, body, notifications); + }); + }, + }, + { + key: 'GROUP_ARCHIVE_RECORDING', + title: 'Archive recording', + isGroup: true, + action: (element, { services, notifications }) => { + const group: EnvironmentNode = element.getData(); + services.api + .graphql( + ` + query DeleteRecordingForGroup ($groupFilter: EnvironmentNodeFilterInput, $recordingFilter: ActiveRecordingFilterInput){ + environmentNodes(filter: $groupFilter) { + name + descendantTargets { + name + recordings { + active(filter: $recordingFilter) { + data { + doArchive { + name + } + } + } + } + } + } + } + `, + { + groupFilter: { id: group.id }, + recordingFilter: { name: QUICK_RECORDING_NAME }, + }, + false, + true, + ) + .subscribe((body) => { + notifyGroupActionErrors('GROUP_ARCHIVE_RECORDING', group, body, notifications); + }); + }, + isDisabled: (element, utils) => { + return isQuickRecordingExist(element.getData(), utils).pipe(map((exist) => !exist)); + }, + }, + { + key: 'GROUP_STOP_RECORDING', + title: 'Stop recording', + isGroup: true, + action: (element, { services, notifications }) => { + const group: EnvironmentNode = element.getData(); + services.api + .graphql( + ` + query StopRecordingForGroup ($groupFilter: EnvironmentNodeFilterInput, $recordingFilter: ActiveRecordingFilterInput){ + environmentNodes(filter: $groupFilter) { + name + descendantTargets { + name + recordings { + active(filter: $recordingFilter) { + data { + doStop { + name + state + } + } + } + } + } + } + } + `, + { + groupFilter: { id: group.id }, + recordingFilter: { name: QUICK_RECORDING_NAME }, + }, + false, + true, + ) + .subscribe((body) => { + notifyGroupActionErrors('GROUP_STOP_RECORDING', group, body, notifications); + }); + }, + isDisabled: (element, utils) => { + return isQuickRecordingExist(element.getData(), utils).pipe(map((exist) => !exist)); + }, + }, + { key: '', isSeparator: true, isGroup: true }, + { + key: 'GROUP_DELETE_RECORDING', + title: 'Delete recording', + isGroup: true, + action: (element, { services, notifications }) => { + const group: EnvironmentNode = element.getData(); + services.api + .graphql( + ` + query DeleteRecordingForGroup ($groupFilter: EnvironmentNodeFilterInput, $recordingFilter: ActiveRecordingFilterInput){ + environmentNodes(filter: $groupFilter) { + name + descendantTargets { + name + recordings { + active(filter: $recordingFilter) { + data { + doDelete { + name + state + } + } + } + } + } + } + } + `, + { + groupFilter: { id: group.id }, + recordingFilter: { name: QUICK_RECORDING_NAME }, + }, + false, + true, + ) + .subscribe((body) => { + notifyGroupActionErrors('GROUP_DELETE_RECORDING', group, body, notifications); + }); + }, + isDisabled: (element, utils) => { + return isQuickRecordingExist(element.getData(), utils).pipe(map((exist) => !exist)); + }, + }, +]; + +export const notifyGroupActionErrors = ( + type: Extract< + 'GROUP_START_RECORDING' | 'GROUP_ARCHIVE_RECORDING' | 'GROUP_STOP_RECORDING' | 'GROUP_DELETE_RECORDING', + NodeActionKey + >, + group: EnvironmentNode, + { errors, data }: GroupActionResponse, + notifications: NotificationService, +): void => { + if (errors) { + const actionVerb = type + .split('_') + .slice(1) + .map((str) => str.toLowerCase()) + .join(' '); + const groupDisplay = `${group.nodeType} ${group.name}`; + errors.forEach((err) => { + // Location of failed target node + const searchIndex = Number(err.path[err.path.indexOf('descendantTargets') + 1]); + if (searchIndex == undefined) { + notifications.danger(`Could not ${actionVerb} for a Target in ${groupDisplay}`, err.message); + } + + // Get the name of failed target node + const name: string | undefined = data.environmentNodes[0]?.descendantTargets[searchIndex]?.name; + + if (name) { + notifications.danger(`Could not ${actionVerb} for Target ${name} in ${groupDisplay}`, err.message); + } else { + notifications.danger(`Could not ${actionVerb} for a Target in ${groupDisplay}`, err.message); + } + }); + } +}; + +export const actionFactory = ( + element: GraphElement | ListElement, + variant: MenuItemVariant = 'contextMenuItem', + actionFilter = (_: NodeAction) => true, +) => { + const data: TargetNode = element.getData(); + const isGroup = !isTargetNode(data); + let filtered = nodeActions.filter((action) => { + return ( + actionFilter(action) && + (action.isGroup || false) === isGroup && + (!action.includeList || action.includeList.includes(data.nodeType)) && + (!action.blockList || !action.blockList.includes(data.nodeType)) + ); + }); + + // Remove trailing separator + let stop: number = filtered.length - 1; + for (; stop >= 0; stop--) { + if (!filtered[stop].isSeparator) { + break; + } + } + filtered = stop >= 0 ? filtered.slice(0, stop + 1) : []; + + return filtered.map(({ isSeparator, key, title, isDisabled, action }, index) => { + if (isSeparator) { + return ; + } + return ( + + {title} + + ); + }); +}; diff --git a/src/app/Topology/Shared/Entity/EntityAnnotations.tsx b/src/app/Topology/Entity/EntityAnnotations.tsx similarity index 96% rename from src/app/Topology/Shared/Entity/EntityAnnotations.tsx rename to src/app/Topology/Entity/EntityAnnotations.tsx index f07fcea65..916762317 100644 --- a/src/app/Topology/Shared/Entity/EntityAnnotations.tsx +++ b/src/app/Topology/Entity/EntityAnnotations.tsx @@ -15,7 +15,7 @@ */ import { Label, LabelGroup } from '@patternfly/react-core'; import * as React from 'react'; -import { EmptyText } from '../EmptyText'; +import { EmptyText } from '../../Shared/Components/EmptyText'; export const EntityAnnotations: React.FC<{ annotations?: object; maxDisplay?: number }> = ({ annotations, diff --git a/src/app/Topology/Shared/Entity/EntityDetails.tsx b/src/app/Topology/Entity/EntityDetails.tsx similarity index 95% rename from src/app/Topology/Shared/Entity/EntityDetails.tsx rename to src/app/Topology/Entity/EntityDetails.tsx index 44be03392..fb84f404e 100644 --- a/src/app/Topology/Shared/Entity/EntityDetails.tsx +++ b/src/app/Topology/Entity/EntityDetails.tsx @@ -14,13 +14,13 @@ * limitations under the License. */ -import { LinearDotSpinner } from '@app/Shared/LinearDotSpinner'; -import { PropertyPath } from '@app/Shared/PropertyPath'; -import { MBeanMetrics, MBeanMetricsResponse } from '@app/Shared/Services/Api.service'; +import { LinearDotSpinner } from '@app/Shared/Components/LinearDotSpinner'; +import { EnvironmentNode, MBeanMetrics, MBeanMetricsResponse, TargetNode } from '@app/Shared/Services/api.types'; +import { isTargetNode } from '@app/Shared/Services/api.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { ActionDropdown, NodeAction } from '@app/Topology/Actions/NodeActions'; -import useDayjs from '@app/utils/useDayjs'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { ActionDropdown } from '@app/Topology/Actions/NodeActions'; +import useDayjs from '@app/utils/hooks/useDayjs'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { formatBytes, hashCode, portalRoot, splitWordsOnUppercase } from '@app/utils/utils'; import { Alert, @@ -50,23 +50,29 @@ import { GraphElement, NodeStatus } from '@patternfly/react-topology'; import * as React from 'react'; import { Link } from 'react-router-dom'; import { catchError, concatMap, map, of } from 'rxjs'; -import { isRenderable } from '../../GraphView/UtilsFactory'; -import { EnvironmentNode, isTargetNode, TargetNode } from '../../typings'; -import { EmptyText } from '../EmptyText'; -import { actionFactory, getStatusTargetNode, ListElement, nodeTypeToAbbr, StatusExtra } from '../utils'; +import { EmptyText } from '../../Shared/Components/EmptyText'; +import { NodeAction } from '../Actions/types'; +import { actionFactory } from '../Actions/utils'; +import { isRenderable } from '../GraphView/utils'; +import { PropertyPath } from '../Shared/Components/PropertyPath'; +import { ListElement, StatusExtra } from '../Shared/types'; +import { getStatusTargetNode, nodeTypeToAbbr } from '../Shared/utils'; import { EntityAnnotations } from './EntityAnnotations'; -import { EntityKeyValues, keyValueEntryTransformer } from './EntityKeyValues'; +import { EntityKeyValues } from './EntityKeyValues'; import { EntityTitle } from './EntityTitle'; +import { Nothing } from './ResourceDetails'; import { DescriptionConfig, + TargetOwnedResourceTypeAsArray, + TargetRelatedResourceTypeAsArray, + TargetOwnedResourceType, + TargetRelatedResourceType, +} from './types'; +import { getExpandedResourceDetails, getLinkPropsForTargetResource, + keyValueEntryTransformer, mapSection, - Nothing, - TargetOwnedResourceType, - TargetOwnedResourceTypeAsArray, - TargetRelatedResourceType, - TargetRelatedResourceTypeAsArray, useResources, } from './utils'; diff --git a/src/app/Topology/Shared/Entity/EntityKeyValues.tsx b/src/app/Topology/Entity/EntityKeyValues.tsx similarity index 76% rename from src/app/Topology/Shared/Entity/EntityKeyValues.tsx rename to src/app/Topology/Entity/EntityKeyValues.tsx index 164fe1cc3..4723f8617 100644 --- a/src/app/Topology/Shared/Entity/EntityKeyValues.tsx +++ b/src/app/Topology/Entity/EntityKeyValues.tsx @@ -15,19 +15,21 @@ */ import { Label, LabelGroup } from '@patternfly/react-core'; import * as React from 'react'; -import { EmptyText } from '../EmptyText'; +import { EmptyText } from '../../Shared/Components/EmptyText'; +import { valuesEntryTransformer } from './utils'; -export function keyValueEntryTransformer(kv: object): string[] { - return Object.entries(kv).map(([k, v]) => `${k}=${v}`); -} - -export const valuesEntryTransformer: (kv: string[] | object) => string[] = Object.values; - -export const EntityKeyValues: React.FC<{ +export interface EntityKeyValuesProps { kv?: string[] | object; maxDisplay?: number; transformer?: (o: object) => string[]; -}> = ({ kv, maxDisplay, transformer = valuesEntryTransformer, ...props }) => { +} + +export const EntityKeyValues: React.FC = ({ + kv, + maxDisplay, + transformer = valuesEntryTransformer, + ...props +}) => { const _transformedKv = React.useMemo(() => (kv ? transformer(kv) : []), [kv, transformer]); return _transformedKv.length ? (
diff --git a/src/app/Topology/Shared/Entity/EntityTitle.tsx b/src/app/Topology/Entity/EntityTitle.tsx similarity index 100% rename from src/app/Topology/Shared/Entity/EntityTitle.tsx rename to src/app/Topology/Entity/EntityTitle.tsx diff --git a/src/app/Topology/Entity/ResourceDetails.tsx b/src/app/Topology/Entity/ResourceDetails.tsx new file mode 100644 index 000000000..453061b59 --- /dev/null +++ b/src/app/Topology/Entity/ResourceDetails.tsx @@ -0,0 +1,83 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EmptyText } from '@app/Shared/Components/EmptyText'; +import { ActiveRecording, RecordingState } from '@app/Shared/Services/api.types'; +import { + DescriptionList, + DescriptionListGroup, + DescriptionListTermHelpText, + DescriptionListDescription, + Flex, + FlexItem, + Label, + LabelProps, + Bullseye, +} from '@patternfly/react-core'; +import { RunningIcon, BanIcon } from '@patternfly/react-icons'; +import * as React from 'react'; +import { ResourceTypes } from './types'; + +export const ActiveRecDetail: React.FC<{ resources: ActiveRecording[] }> = ({ resources, ...props }) => { + const stateGroupConfigs = React.useMemo( + () => [ + { + groupLabel: 'Running', + color: 'green', + icon: , + items: resources.filter((rec) => rec.state === RecordingState.RUNNING), + }, + { + groupLabel: 'Stopped', + color: 'orange', + icon: , + items: resources.filter((rec) => rec.state === RecordingState.STOPPED), + }, + ], + [resources], + ); + + return ( + + + Recording Status + + + {stateGroupConfigs.map(({ groupLabel, items, color, icon }) => ( + + + {items.length} + + + + + + ))} + + + + + ); +}; + +export const Nothing: React.FC<{ resources: ResourceTypes[] }> = () => { + return ( + + + + ); +}; diff --git a/src/app/Topology/Entity/types.ts b/src/app/Topology/Entity/types.ts new file mode 100644 index 000000000..5dc1cea00 --- /dev/null +++ b/src/app/Topology/Entity/types.ts @@ -0,0 +1,55 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { + EventProbe, + EventTemplate, + EventType, + NotificationMessage, + Recording, + Rule, + StoredCredential, +} from '@app/Shared/Services/api.types'; +import { Observable } from 'rxjs'; + +export type DescriptionConfig = { + key: React.Key; + title: React.ReactNode; + helperTitle: React.ReactNode; + helperDescription: React.ReactNode; + content: React.ReactNode; +}; +export type PatchFn = ( + arr: ResourceTypes[], + eventData: NotificationMessage, + removed?: boolean, +) => Observable; + +export type ResourceTypes = Recording | EventTemplate | EventType | EventProbe | Rule | StoredCredential; + +// Note: Values will be word split to used as display names +export const TargetOwnedResourceTypeAsArray = [ + 'activeRecordings', + 'archivedRecordings', + 'eventTemplates', + 'eventTypes', + 'agentProbes', +] as const; + +export const TargetRelatedResourceTypeAsArray = ['automatedRules', 'credentials'] as const; + +export type TargetOwnedResourceType = (typeof TargetOwnedResourceTypeAsArray)[number]; + +export type TargetRelatedResourceType = (typeof TargetRelatedResourceTypeAsArray)[number]; diff --git a/src/app/Topology/Shared/Entity/utils.tsx b/src/app/Topology/Entity/utils.tsx similarity index 80% rename from src/app/Topology/Shared/Entity/utils.tsx rename to src/app/Topology/Entity/utils.tsx index 02183e7e8..cc1ee4313 100644 --- a/src/app/Topology/Shared/Entity/utils.tsx +++ b/src/app/Topology/Entity/utils.tsx @@ -13,34 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { EventTemplate } from '@app/CreateRecording/CreateRecording'; -import { EventType } from '@app/Events/EventTypes'; -import { Rule } from '@app/Rules/Rules'; + +import { ApiService } from '@app/Shared/Services/Api.service'; import { - ActiveRecording, - ApiService, - EventProbe, - Recording, - RecordingState, + TargetNode, + Rule, StoredCredential, -} from '@app/Shared/Services/Api.service'; -import { NotificationCategory, NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; + NotificationCategory, + NotificationMessage, + Recording, + EventTemplate, + EventProbe, +} from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { - Bullseye, - DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTermHelpText, DescriptionListTermHelpTextButton, - Flex, - FlexItem, - Label, - LabelProps, Popover, } from '@patternfly/react-core'; -import { BanIcon, RunningIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { Link } from 'react-router-dom'; import { @@ -57,16 +50,12 @@ import { Subject, switchMap, } from 'rxjs'; -import { TargetNode } from '../../typings'; -import { EmptyText } from '../EmptyText'; +import { ActiveRecDetail, Nothing } from './ResourceDetails'; +import { DescriptionConfig, TargetOwnedResourceType, TargetRelatedResourceType, ResourceTypes, PatchFn } from './types'; -export type DescriptionConfig = { - key: React.Key; - title: React.ReactNode; - helperTitle: React.ReactNode; - helperDescription: React.ReactNode; - content: React.ReactNode; -}; +export const keyValueEntryTransformer = (kv: object): string[] => Object.entries(kv).map(([k, v]) => `${k}=${v}`); + +export const valuesEntryTransformer: (kv: string[] | object) => string[] = Object.values; export const mapSection = (d: DescriptionConfig) => ( @@ -79,23 +68,6 @@ export const mapSection = (d: DescriptionConfig) => ( ); -export type ResourceTypes = Recording | EventTemplate | EventType | EventProbe | Rule | StoredCredential; - -// Note: Values will be word split to used as display names -export const TargetOwnedResourceTypeAsArray = [ - 'activeRecordings', - 'archivedRecordings', - 'eventTemplates', - 'eventTypes', - 'agentProbes', -] as const; - -export const TargetRelatedResourceTypeAsArray = ['automatedRules', 'credentials'] as const; - -export type TargetOwnedResourceType = (typeof TargetOwnedResourceTypeAsArray)[number]; - -export type TargetRelatedResourceType = (typeof TargetRelatedResourceTypeAsArray)[number]; - export const isOwnedResource = (resourceType: TargetOwnedResourceType | TargetRelatedResourceType) => { return resourceType !== 'automatedRules' && resourceType !== 'credentials'; }; @@ -191,12 +163,6 @@ export const getResourceRemovedEvents = (resourceType: TargetOwnedResourceType | } }; -export type PatchFn = ( - arr: ResourceTypes[], - eventData: NotificationMessage, - removed?: boolean, -) => Observable; - export const getResourceListPatchFn = ( resourceType: TargetOwnedResourceType | TargetRelatedResourceType, { target }: TargetNode, @@ -295,58 +261,6 @@ export const getLinkPropsForTargetResource = ( } }; -export const ActiveRecDetail: React.FC<{ resources: ActiveRecording[] }> = ({ resources, ...props }) => { - const stateGroupConfigs = React.useMemo( - () => [ - { - groupLabel: 'Running', - color: 'green', - icon: , - items: resources.filter((rec) => rec.state === RecordingState.RUNNING), - }, - { - groupLabel: 'Stopped', - color: 'orange', - icon: , - items: resources.filter((rec) => rec.state === RecordingState.STOPPED), - }, - ], - [resources], - ); - - return ( - - - Recording Status - - - {stateGroupConfigs.map(({ groupLabel, items, color, icon }) => ( - - - {items.length} - - - - - - ))} - - - - - ); -}; - -export const Nothing: React.FC<{ resources: ResourceTypes[] }> = () => { - return ( - - - - ); -}; - export const getExpandedResourceDetails = ( resourceType: TargetOwnedResourceType | TargetRelatedResourceType, ): React.FC<{ resources: ResourceTypes[] }> => { diff --git a/src/app/Topology/GraphView/CustomGroup.tsx b/src/app/Topology/GraphView/CustomGroup.tsx index 9103e40fb..9cecd2672 100644 --- a/src/app/Topology/GraphView/CustomGroup.tsx +++ b/src/app/Topology/GraphView/CustomGroup.tsx @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import openjdkSvg from '@app/assets/openjdk.svg'; import { RootState } from '@app/Shared/Redux/ReduxStore'; +import { EnvironmentNode, NodeType } from '@app/Shared/Services/api.types'; import { DefaultGroup, Node, @@ -25,12 +27,9 @@ import { } from '@patternfly/react-topology'; import * as React from 'react'; import { useSelector } from 'react-redux'; -import { EnvironmentNode, NodeType } from '../typings'; -import { NODE_ICON_PADDING } from './CustomNode'; - -const DEFAULT_NODE_COLLAPSED_DIAMETER = 100; +import { DEFAULT_NODE_COLLAPSED_DIAMETER, NODE_ICON_PADDING } from './const'; -export const renderIcon = (width: number, height: number): React.ReactNode => { +export const renderGroupIcon = (width: number, height: number): React.ReactNode => { const contentSize = Math.min(width, height) - NODE_ICON_PADDING * 2; const mainContentSize = contentSize * 0.8; const [cx, cy] = [width / 2, height / 2]; @@ -73,7 +72,7 @@ const CustomGroup: React.FC = ({ const { badge: showBadge } = displayOptions.show; const collapsedContent = React.useMemo( - () => {renderIcon(collapsedWidth, collapsedHeight)}, + () => {renderGroupIcon(collapsedWidth, collapsedHeight)}, [collapsedWidth, collapsedHeight], ); diff --git a/src/app/Topology/GraphView/CustomNode.tsx b/src/app/Topology/GraphView/CustomNode.tsx index 6ddb0d55c..5d100a4f7 100644 --- a/src/app/Topology/GraphView/CustomNode.tsx +++ b/src/app/Topology/GraphView/CustomNode.tsx @@ -17,8 +17,10 @@ import cryostatSvg from '@app/assets/cryostat_icon_rgb_default.svg'; import openjdkSvg from '@app/assets/openjdk.svg'; import { RootState } from '@app/Shared/Redux/ReduxStore'; -import { includesTarget } from '@app/Shared/Services/Target.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { TargetNode } from '@app/Shared/Services/api.types'; +import { includesTarget } from '@app/Shared/Services/api.utils'; +import { useMatchedTargetsSvc } from '@app/utils/hooks/useMatchedTargetsSvc'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { ContainerNodeIcon } from '@patternfly/react-icons'; import { css } from '@patternfly/react-styles'; import { @@ -39,15 +41,11 @@ import { import * as React from 'react'; import { useSelector } from 'react-redux'; import { map } from 'rxjs'; -import { getStatusTargetNode, nodeTypeToAbbr, useMatchedTargetsSvc } from '../Shared/utils'; -import { TargetNode } from '../typings'; +import { getStatusTargetNode, nodeTypeToAbbr, TOPOLOGY_GRAPH_ID } from '../Shared/utils'; +import { NODE_BADGE_COLOR, NODE_ICON_PADDING, RESOURCE_NAME_TRUNCATE_LENGTH } from './const'; import { getNodeDecorators } from './NodeDecorator'; -import { TOPOLOGY_GRAPH_ID } from './TopologyGraphView'; -import { RESOURCE_NAME_TRUNCATE_LENGTH } from './UtilsFactory'; -export const NODE_ICON_PADDING = 5; - -export const renderIcon = (graphic, _data: TargetNode, element: Node, useAlt: boolean): React.ReactNode => { +export const renderNodeIcon = (graphic: string, _data: TargetNode, element: Node, useAlt: boolean): React.ReactNode => { const { width, height } = element.getDimensions(); const contentSize = Math.min(width, height) - NODE_ICON_PADDING * 2; @@ -73,8 +71,6 @@ export interface CustomNodeProps extends Partial = ({ element, onSelect, @@ -140,7 +136,7 @@ const CustomNode: React.FC = ({ showLabel attachments={nodeDecorators} > - {renderIcon(graphic, data, element, !showIcon)} + {renderNodeIcon(graphic, data, element, !showIcon)} diff --git a/src/app/Topology/GraphView/NodeDecorator.tsx b/src/app/Topology/GraphView/NodeDecorator.tsx index b945ae68c..c88632529 100644 --- a/src/app/Topology/GraphView/NodeDecorator.tsx +++ b/src/app/Topology/GraphView/NodeDecorator.tsx @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ActiveRecording, RecordingState } from '@app/Shared/Services/Api.service'; + +import { TargetNode, ActiveRecording, RecordingState } from '@app/Shared/Services/api.types'; import { portalRoot } from '@app/utils/utils'; import { Tooltip } from '@patternfly/react-core'; import { InProgressIcon, RunningIcon, WarningTriangleIcon } from '@patternfly/react-icons'; @@ -26,9 +27,8 @@ import { TopologyQuadrant, } from '@patternfly/react-topology'; import * as React from 'react'; -import { useResources } from '../Shared/Entity/utils'; +import { useResources } from '../Entity/utils'; import { getStatusTargetNode } from '../Shared/utils'; -import { TargetNode } from '../typings'; export const getNodeDecorators = (element: Node) => { return ( diff --git a/src/app/Topology/GraphView/TopologyControlBar.tsx b/src/app/Topology/GraphView/TopologyControlBar.tsx index 4ad0df024..ac24f3921 100644 --- a/src/app/Topology/GraphView/TopologyControlBar.tsx +++ b/src/app/Topology/GraphView/TopologyControlBar.tsx @@ -22,7 +22,7 @@ import { Visualization, } from '@patternfly/react-topology'; import * as React from 'react'; -import { CollapseIcon } from '../Shared/CollapseIcon'; +import { CollapseIcon } from '../Shared/Components/CollapseIcon'; export interface TopologyControlBarProps { visualization: Visualization; diff --git a/src/app/Topology/GraphView/TopologyGraphView.tsx b/src/app/Topology/GraphView/TopologyGraphView.tsx index d93c4bbf1..6325dad4c 100644 --- a/src/app/Topology/GraphView/TopologyGraphView.tsx +++ b/src/app/Topology/GraphView/TopologyGraphView.tsx @@ -14,6 +14,8 @@ * limitations under the License. */ import { RootState } from '@app/Shared/Redux/ReduxStore'; +import { MatchedTargetsServiceContext } from '@app/Shared/Services/service.utils'; +import { useMatchedTargetsSvcSource } from '@app/utils/hooks/useMatchedTargetsSvcSource'; import { getFromLocalStorage, saveToLocalStorage } from '@app/utils/LocalStorage'; import { Divider, Stack, StackItem } from '@patternfly/react-core'; import { css } from '@patternfly/react-styles'; @@ -38,27 +40,21 @@ import * as _ from 'lodash'; import * as React from 'react'; import { useSelector } from 'react-redux'; import { QuickSearchContextMenu } from '../Actions/QuickSearchPanel'; -import EntityDetails from '../Shared/Entity/EntityDetails'; -import { TopologyEmptyState } from '../Shared/TopologyEmptyState'; -import { TopologyExceedLimitState } from '../Shared/TopologyExceedLimitState'; -import { - DiscoveryTreeContext, - MatchedTargetsServiceContext, - TransformConfig, - useMatchedTargetsSvcSource, -} from '../Shared/utils'; +import EntityDetails from '../Entity/EntityDetails'; +import { TopologyEmptyState } from '../Shared/Components/TopologyEmptyState'; +import { TopologyExceedLimitState } from '../Shared/Components/TopologyExceedLimitState'; +import type { TransformConfig } from '../Shared/types'; +import { DiscoveryTreeContext, TOPOLOGY_GRAPH_ID } from '../Shared/utils'; import { TopologySideBar } from '../SideBar/TopologySideBar'; import { TopologyToolbar, TopologyToolbarVariant } from '../Toolbar/TopologyToolbar'; import { TopologyControlBar } from './TopologyControlBar'; -import { componentFactory, getNodeById, layoutFactory, transformData } from './UtilsFactory'; +import { componentFactory, getNodeById, layoutFactory, transformData } from './utils'; export const MAX_NODE_LIMIT = 100; export const DEFAULT_SIZEBAR_SIZE = 500; export const MIN_SIZEBAR_SIZE = 400; -export const TOPOLOGY_GRAPH_ID = 'cryostat-target-topology-graph'; - export type SavedGraphPosition = { id?: string; type?: string; diff --git a/src/app/Topology/GraphView/const.ts b/src/app/Topology/GraphView/const.ts new file mode 100644 index 000000000..0f747f155 --- /dev/null +++ b/src/app/Topology/GraphView/const.ts @@ -0,0 +1,37 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Unit: px +export const DEFAULT_NODE_DIAMETER = 80; + +export const DEFAULT_GROUP_PADDING = 30; +export const DEFAULT_NODE_PADDING = 60; + +export const DEFAULT_NODE_PADDINGS = [0, DEFAULT_NODE_PADDING]; +export const DEFAULT_GROUP_PADDINGS = [ + DEFAULT_GROUP_PADDING, + DEFAULT_GROUP_PADDING, + DEFAULT_GROUP_PADDING + 15, + DEFAULT_GROUP_PADDING, +]; + +export const RESOURCE_NAME_TRUNCATE_LENGTH = 20; + +export const NODE_BADGE_COLOR = 'var(--pf-global--palette--blue-500)'; + +export const NODE_ICON_PADDING = 5; + +export const DEFAULT_NODE_COLLAPSED_DIAMETER = 100; diff --git a/src/app/Topology/GraphView/UtilsFactory.tsx b/src/app/Topology/GraphView/utils.tsx similarity index 92% rename from src/app/Topology/GraphView/UtilsFactory.tsx rename to src/app/Topology/GraphView/utils.tsx index 0ca62c9ea..3e26686e0 100644 --- a/src/app/Topology/GraphView/UtilsFactory.tsx +++ b/src/app/Topology/GraphView/utils.tsx @@ -15,6 +15,8 @@ */ import { TopologyFilters } from '@app/Shared/Redux/Filters/TopologyFilterSlice'; +import { EnvironmentNode, NodeType, TargetNode } from '@app/Shared/Services/api.types'; +import { getAllLeaves, getUniqueGroupId, getUniqueTargetId, isTargetNode } from '@app/Shared/Services/api.utils'; import { ColaLayout, ComponentFactory, @@ -22,7 +24,6 @@ import { EdgeModel, Graph, GraphComponent, - GraphElement, isNode, Layout, LayoutFactory, @@ -37,39 +38,15 @@ import { withDragNode, withPanZoom, withSelection, + GraphElement, } from '@patternfly/react-topology'; -import { - actionFactory, - COLLAPSE_EXEMPTS, - getAllLeaves, - getUniqueGroupId, - getUniqueTargetId, - isGraphElement, - isGroupNodeFiltered, - isTargetNodeFiltered, - ListElement, - TransformConfig, -} from '../Shared/utils'; -import { EnvironmentNode, isTargetNode, NodeType, TargetNode } from '../typings'; +import { actionFactory } from '../Actions/utils'; +import { ListElement, TransformConfig } from '../Shared/types'; +import { COLLAPSE_EXEMPTS, isGraphElement, isGroupNodeFiltered, isTargetNodeFiltered } from '../Shared/utils'; +import { DEFAULT_NODE_DIAMETER, DEFAULT_NODE_PADDINGS, DEFAULT_GROUP_PADDINGS } from './const'; import CustomGroup from './CustomGroup'; import CustomNode from './CustomNode'; -// Unit: px -export const DEFAULT_NODE_DIAMETER = 80; - -export const DEFAULT_GROUP_PADDING = 30; -export const DEFAULT_NODE_PADDING = 60; - -export const DEFAULT_NODE_PADDINGS = [0, DEFAULT_NODE_PADDING]; -export const DEFAULT_GROUP_PADDINGS = [ - DEFAULT_GROUP_PADDING, - DEFAULT_GROUP_PADDING, - DEFAULT_GROUP_PADDING + 15, - DEFAULT_GROUP_PADDING, -]; - -export const RESOURCE_NAME_TRUNCATE_LENGTH = 20; - const _buildFullNodeModel = ( node: EnvironmentNode | TargetNode, expandMode = true, diff --git a/src/app/Topology/ListView/TopologyListView.tsx b/src/app/Topology/ListView/TopologyListView.tsx index 5d1f034cd..cb723f003 100644 --- a/src/app/Topology/ListView/TopologyListView.tsx +++ b/src/app/Topology/ListView/TopologyListView.tsx @@ -14,17 +14,20 @@ * limitations under the License. */ import { RootState } from '@app/Shared/Redux/ReduxStore'; +import { Target } from '@app/Shared/Services/api.types'; +import { getAllLeaves } from '@app/Shared/Services/api.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useMatchExpressionSvc } from '@app/utils/hooks/useMatchExpressionSvc'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { Divider, Stack, StackItem, TreeView, TreeViewDataItem } from '@patternfly/react-core'; import * as React from 'react'; import { useSelector } from 'react-redux'; import { Subject, catchError, combineLatest, of, switchMap } from 'rxjs'; -import { TopologyEmptyState } from '../Shared/TopologyEmptyState'; -import { DiscoveryTreeContext, TransformConfig, getAllLeaves, useExprSvc } from '../Shared/utils'; +import { TopologyEmptyState } from '../Shared/Components/TopologyEmptyState'; +import type { TransformConfig } from '../Shared/types'; +import { DiscoveryTreeContext } from '../Shared/utils'; import { TopologyToolbar, TopologyToolbarVariant } from '../Toolbar/TopologyToolbar'; -import { transformData } from './UtilsFactory'; +import { transformData } from './utils'; export interface TopologyListViewProps { transformConfig?: TransformConfig; @@ -34,7 +37,7 @@ export const TopologyListView: React.FC = ({ transformCon const discoveryTree = React.useContext(DiscoveryTreeContext); const svcContext = React.useContext(ServiceContext); const addSubscription = useSubscriptions(); - const matchExprService = useExprSvc(); + const matchExprService = useMatchExpressionSvc(); const tSubjectRef = React.useRef(new Subject()); const tSubject = tSubjectRef.current; diff --git a/src/app/Topology/ListView/UtilsFactory.tsx b/src/app/Topology/ListView/utils.tsx similarity index 94% rename from src/app/Topology/ListView/UtilsFactory.tsx rename to src/app/Topology/ListView/utils.tsx index 31cee8ddd..afecd03f9 100644 --- a/src/app/Topology/ListView/UtilsFactory.tsx +++ b/src/app/Topology/ListView/utils.tsx @@ -14,22 +14,21 @@ * limitations under the License. */ import { TopologyFilters } from '@app/Shared/Redux/Filters/TopologyFilterSlice'; -import { Target, includesTarget } from '@app/Shared/Services/Target.service'; -import { Badge, Flex, FlexItem, Label, LabelGroup, TreeViewDataItem } from '@patternfly/react-core'; -import * as React from 'react'; -import { ActionDropdown } from '../Actions/NodeActions'; -import EntityDetails from '../Shared/Entity/EntityDetails'; +import { EnvironmentNode, Target, TargetNode, NodeType } from '@app/Shared/Services/api.types'; import { - actionFactory, - COLLAPSE_EXEMPTS, getAllLeaves, getUniqueGroupId, getUniqueTargetId, - isGroupNodeFiltered, - isTargetNodeFiltered, - TransformConfig, -} from '../Shared/utils'; -import { EnvironmentNode, isTargetNode, NodeType, TargetNode } from '../typings'; + includesTarget, + isTargetNode, +} from '@app/Shared/Services/api.utils'; +import { Badge, Flex, FlexItem, Label, LabelGroup, TreeViewDataItem } from '@patternfly/react-core'; +import * as React from 'react'; +import { ActionDropdown } from '../Actions/NodeActions'; +import { actionFactory } from '../Actions/utils'; +import EntityDetails from '../Entity/EntityDetails'; +import type { TransformConfig } from '../Shared/types'; +import { COLLAPSE_EXEMPTS, isGroupNodeFiltered, isTargetNodeFiltered } from '../Shared/utils'; const _transformDataGroupedByTopLevel = ( universe: EnvironmentNode, diff --git a/src/app/Topology/Shared/CollapseIcon.tsx b/src/app/Topology/Shared/Components/CollapseIcon.tsx similarity index 100% rename from src/app/Topology/Shared/CollapseIcon.tsx rename to src/app/Topology/Shared/Components/CollapseIcon.tsx diff --git a/src/app/Shared/PropertyPath.tsx b/src/app/Topology/Shared/Components/PropertyPath.tsx similarity index 100% rename from src/app/Shared/PropertyPath.tsx rename to src/app/Topology/Shared/Components/PropertyPath.tsx diff --git a/src/app/Topology/Shared/Shortcuts.tsx b/src/app/Topology/Shared/Components/Shortcuts.tsx similarity index 100% rename from src/app/Topology/Shared/Shortcuts.tsx rename to src/app/Topology/Shared/Components/Shortcuts.tsx diff --git a/src/app/Topology/Shared/TopologyEmptyState.tsx b/src/app/Topology/Shared/Components/TopologyEmptyState.tsx similarity index 93% rename from src/app/Topology/Shared/TopologyEmptyState.tsx rename to src/app/Topology/Shared/Components/TopologyEmptyState.tsx index c8d44365a..85d66fa10 100644 --- a/src/app/Topology/Shared/TopologyEmptyState.tsx +++ b/src/app/Topology/Shared/Components/TopologyEmptyState.tsx @@ -14,6 +14,8 @@ * limitations under the License. */ import { topologyDeleteAllFiltersIntent } from '@app/Shared/Redux/ReduxStore'; +import { getAllLeaves } from '@app/Shared/Services/api.utils'; +import { SearchExprServiceContext } from '@app/Shared/Services/service.utils'; import { Bullseye, Button, @@ -28,7 +30,7 @@ import { TopologyIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { Link } from 'react-router-dom'; -import { DiscoveryTreeContext, getAllLeaves, SearchExprServiceContext } from './utils'; +import { DiscoveryTreeContext } from '../utils'; export interface TopologyEmptyStateProps {} diff --git a/src/app/Topology/Shared/TopologyExceedLimitState.tsx b/src/app/Topology/Shared/Components/TopologyExceedLimitState.tsx similarity index 100% rename from src/app/Topology/Shared/TopologyExceedLimitState.tsx rename to src/app/Topology/Shared/Components/TopologyExceedLimitState.tsx diff --git a/src/app/Topology/Shared/types.ts b/src/app/Topology/Shared/types.ts new file mode 100644 index 000000000..976dd2433 --- /dev/null +++ b/src/app/Topology/Shared/types.ts @@ -0,0 +1,29 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { GraphElement as PFGraphElement } from '@patternfly/react-topology'; + +export type GraphElement = PFGraphElement; + +export type ListElement = { + getData: GraphElement['getData']; +}; + +export type StatusExtra = { title?: string; description?: React.ReactNode; callForAction?: React.ReactNode[] }; + +export interface TransformConfig { + showOnlyTopGroup?: boolean; + expandMode?: boolean; +} diff --git a/src/app/Topology/Shared/utils.tsx b/src/app/Topology/Shared/utils.tsx index 7f3c39ad6..77e823f31 100644 --- a/src/app/Topology/Shared/utils.tsx +++ b/src/app/Topology/Shared/utils.tsx @@ -13,70 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { TopologyFilters } from '@app/Shared/Redux/Filters/TopologyFilterSlice'; -import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { NodeType, EnvironmentNode, TargetNode } from '@app/Shared/Services/api.types'; +import { DEFAULT_EMPTY_UNIVERSE, isTargetNode } from '@app/Shared/Services/api.utils'; import { Button, Text, TextVariants } from '@patternfly/react-core'; -import { ContextMenuSeparator, GraphElement, NodeStatus } from '@patternfly/react-topology'; +import { GraphElement, NodeStatus } from '@patternfly/react-topology'; import * as React from 'react'; -import { BehaviorSubject, catchError, combineLatest, debounceTime, Observable, of, switchMap, tap } from 'rxjs'; -import { ContextMenuItem, MenuItemVariant, NodeAction, nodeActions } from '../Actions/NodeActions'; import { WarningResolverAsCredModal, WarningResolverAsLink } from '../Actions/WarningResolver'; -import { EnvironmentNode, TargetNode, isTargetNode, NodeType, DEFAULT_EMPTY_UNIVERSE } from '../typings'; +import { ListElement, StatusExtra } from './types'; + +export const TOPOLOGY_GRAPH_ID = 'cryostat-target-topology-graph'; export const DiscoveryTreeContext = React.createContext(DEFAULT_EMPTY_UNIVERSE); +export const COLLAPSE_EXEMPTS = [NodeType.NAMESPACE, NodeType.REALM, NodeType.UNIVERSE]; + export const nodeTypeToAbbr = (type: NodeType): string => { // Keep uppercases (or uppercase whole word if none) and retain first 4 charaters. return (type.replace(/[^A-Z]/g, '') || type.toUpperCase()).slice(0, 4); }; -export const getAllLeaves = (root: EnvironmentNode | TargetNode): TargetNode[] => { - if (isTargetNode(root)) { - return [root]; - } - const INIT: TargetNode[] = []; - return root.children.reduce((prev, curr) => prev.concat(getAllLeaves(curr)), INIT); -}; - -export const flattenTree = ( - node: EnvironmentNode | TargetNode, - includeUniverse?: boolean, -): (EnvironmentNode | TargetNode)[] => { - if (isTargetNode(node)) { - return [node]; - } - - const INIT: (EnvironmentNode | TargetNode)[] = []; - const allChildren = node.children.reduce((prev, curr) => prev.concat(flattenTree(curr)), INIT); - - if (node.nodeType === NodeType.UNIVERSE && !includeUniverse) { - return [...allChildren]; - } - - return [node, ...allChildren]; -}; - -export const getUniqueNodeTypes = (nodes: (EnvironmentNode | TargetNode)[]): NodeType[] => { - return Array.from(new Set(nodes.map((n) => n.nodeType))); -}; - -export interface TransformConfig { - showOnlyTopGroup?: boolean; - expandMode?: boolean; -} - -export const getUniqueGroupId = (group: EnvironmentNode) => { - return `${group.id}`; -}; - -export const getUniqueTargetId = (target: TargetNode) => { - return `${target.id}`; -}; - -export type StatusExtra = { title?: string; description?: React.ReactNode; callForAction?: React.ReactNode[] }; - export const getStatusTargetNode = (node: TargetNode | EnvironmentNode): [NodeStatus?, StatusExtra?] => { if (isTargetNode(node)) { return node.target.jvmId @@ -111,53 +68,10 @@ export const getStatusTargetNode = (node: TargetNode | EnvironmentNode): [NodeSt return []; }; -export const actionFactory = ( - element: GraphElement | ListElement, - variant: MenuItemVariant = 'contextMenuItem', - actionFilter = (_: NodeAction) => true, -) => { - const data: TargetNode = element.getData(); - const isGroup = !isTargetNode(data); - let filtered = nodeActions.filter((action) => { - return ( - actionFilter(action) && - (action.isGroup || false) === isGroup && - (!action.includeList || action.includeList.includes(data.nodeType)) && - (!action.blockList || !action.blockList.includes(data.nodeType)) - ); - }); - - // Remove trailing separator - let stop: number = filtered.length - 1; - for (; stop >= 0; stop--) { - if (!filtered[stop].isSeparator) { - break; - } - } - filtered = stop >= 0 ? filtered.slice(0, stop + 1) : []; - - return filtered.map(({ isSeparator, key, title, isDisabled, action }, index) => { - if (isSeparator) { - return ; - } - return ( - - {title} - - ); - }); -}; - -export type ListElement = { - getData: GraphElement['getData']; -}; - export const isGraphElement = (element: GraphElement | ListElement): element is GraphElement => { return (element as GraphElement).getGraph !== undefined; }; -export const COLLAPSE_EXEMPTS = [NodeType.NAMESPACE, NodeType.REALM, NodeType.UNIVERSE]; - // For searching export const isGroupNodeFiltered = ( groupNode: EnvironmentNode, @@ -206,53 +120,3 @@ export const isTargetNodeFiltered = ({ target }: TargetNode, filters?: TopologyF } return matched; }; - -export const DEFAULT_MATCH_EXPR_DEBOUNCE_TIME = 300; // ms - -export class SearchExprService { - private readonly _state$ = new BehaviorSubject(''); - - searchExpression({ - debounceMs = DEFAULT_MATCH_EXPR_DEBOUNCE_TIME, - immediateFn = (_: string) => { - /* do nothing */ - }, - } = {}): Observable { - return this._state$.asObservable().pipe(tap(immediateFn), debounceTime(debounceMs)); - } - - setSearchExpression(expr: string): void { - this._state$.next(expr); - } -} - -export const SearchExprServiceContext = React.createContext(new SearchExprService()); - -export const useExprSvc = (): SearchExprService => React.useContext(SearchExprServiceContext); - -export const MatchedTargetsServiceContext = React.createContext(new BehaviorSubject(undefined)); - -export const useMatchedTargetsSvcSource = (): BehaviorSubject => { - const matchedTargetsSvcRef = React.useRef(new BehaviorSubject(undefined)); - const matchExprService = useExprSvc(); - const svc = React.useContext(ServiceContext); - const addSubscription = useSubscriptions(); - - React.useEffect(() => { - addSubscription( - combineLatest([matchExprService.searchExpression(), svc.targets.targets()]) - .pipe( - switchMap(([input, targets]) => - input ? svc.api.matchTargetsWithExpr(input, targets).pipe(catchError((_) => of([]))) : of(undefined), - ), - ) - .subscribe((ts) => { - matchedTargetsSvcRef.current.next(ts); - }), - ); - }, [svc.targets, svc.api, matchExprService, addSubscription]); - - return matchedTargetsSvcRef.current; -}; - -export const useMatchedTargetsSvc = () => React.useContext(MatchedTargetsServiceContext); diff --git a/src/app/Topology/Toolbar/FindByMatchExpression.tsx b/src/app/Topology/Toolbar/FindByMatchExpression.tsx index 6b8d97288..80cf6900b 100644 --- a/src/app/Topology/Toolbar/FindByMatchExpression.tsx +++ b/src/app/Topology/Toolbar/FindByMatchExpression.tsx @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { useMatchExpressionSvc } from '@app/utils/hooks/useMatchExpressionSvc'; import { SearchInput } from '@patternfly/react-core'; import * as React from 'react'; -import { useExprSvc } from '../Shared/utils'; export interface FindByMatchExpressionProps { isDisabled?: boolean; } export const FindByMatchExpression: React.FC = ({ isDisabled, ...props }) => { - const matchExprService = useExprSvc(); + const matchExprService = useMatchExpressionSvc(); const [expression, setExpression] = React.useState(''); return ( diff --git a/src/app/Topology/Toolbar/QuickSearchButton.tsx b/src/app/Topology/Toolbar/QuickSearchButton.tsx index 015d81877..c0c735999 100644 --- a/src/app/Topology/Toolbar/QuickSearchButton.tsx +++ b/src/app/Topology/Toolbar/QuickSearchButton.tsx @@ -16,7 +16,7 @@ import { Button, Tooltip } from '@patternfly/react-core'; import * as React from 'react'; -import QuickSearchIcon from '../Shared/QuickSearchIcon'; +import QuickSearchIcon from '../../Shared/Components/QuickSearchIcon'; export interface QuickSearchButtonProps { onClick: () => void; diff --git a/src/app/Topology/Toolbar/TopologyFilterChips.tsx b/src/app/Topology/Toolbar/TopologyFilterChips.tsx index 9e2d94a9e..e811a73e5 100644 --- a/src/app/Topology/Toolbar/TopologyFilterChips.tsx +++ b/src/app/Topology/Toolbar/TopologyFilterChips.tsx @@ -19,11 +19,11 @@ import { topologyDeleteCategoryFiltersIntent, topologyDeleteFilterIntent, } from '@app/Shared/Redux/ReduxStore'; +import { NodeType } from '@app/Shared/Services/api.types'; import { getDisplayFieldName } from '@app/utils/utils'; import { Chip, ChipGroup } from '@patternfly/react-core'; import * as React from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { NodeType } from '../typings'; export interface TopologyFilterChipsProps { className?: string; diff --git a/src/app/Topology/Toolbar/TopologyFilters.tsx b/src/app/Topology/Toolbar/TopologyFilters.tsx index 6f477020b..055971cfe 100644 --- a/src/app/Topology/Toolbar/TopologyFilters.tsx +++ b/src/app/Topology/Toolbar/TopologyFilters.tsx @@ -24,6 +24,8 @@ import { topologyUpdateCategoryIntent, topologyUpdateCategoryTypeIntent, } from '@app/Shared/Redux/ReduxStore'; +import { EnvironmentNode, TargetNode } from '@app/Shared/Services/api.types'; +import { flattenTree, getUniqueNodeTypes, isTargetNode } from '@app/Shared/Services/api.utils'; import { getDisplayFieldName } from '@app/utils/utils'; import { Divider, @@ -42,8 +44,7 @@ import { import { FilterIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { DiscoveryTreeContext, flattenTree, getUniqueNodeTypes } from '../Shared/utils'; -import { EnvironmentNode, isTargetNode, TargetNode } from '../typings'; +import { DiscoveryTreeContext } from '../Shared/utils'; export interface TopologyFiltersProps { breakpoint?: 'md' | 'lg' | 'xl' | '2xl'; diff --git a/src/app/Topology/Toolbar/TopologyToolbar.tsx b/src/app/Topology/Toolbar/TopologyToolbar.tsx index 7566e4ef3..537415af9 100644 --- a/src/app/Topology/Toolbar/TopologyToolbar.tsx +++ b/src/app/Topology/Toolbar/TopologyToolbar.tsx @@ -22,7 +22,7 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { QuickSearchModal } from '../Actions/QuickSearchPanel'; -import Shortcuts, { ShortcutCommand } from '../Shared/Shortcuts'; +import Shortcuts, { ShortcutCommand } from '../Shared/Components/Shortcuts'; import { DisplayOptions } from './DisplayOptions'; import { FindByMatchExpression } from './FindByMatchExpression'; import { HelpButton } from './HelpButton'; diff --git a/src/app/Topology/Topology.tsx b/src/app/Topology/Topology.tsx index f7485249a..6ef624742 100644 --- a/src/app/Topology/Topology.tsx +++ b/src/app/Topology/Topology.tsx @@ -16,27 +16,29 @@ import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; import { ErrorView } from '@app/ErrorView/ErrorView'; -import { LinearDotSpinner } from '@app/Shared/LinearDotSpinner'; +import { LinearDotSpinner } from '@app/Shared/Components/LinearDotSpinner'; import { ViewMode } from '@app/Shared/Redux/Configurations/TopologyConfigSlice'; import { RootState } from '@app/Shared/Redux/ReduxStore'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { NotificationCategory } from '@app/Shared/Services/api.types'; +import { DEFAULT_EMPTY_UNIVERSE } from '@app/Shared/Services/api.utils'; +import { MatchExpressionService } from '@app/Shared/Services/MatchExpression.service'; +import { SearchExprServiceContext } from '@app/Shared/Services/service.utils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { Bullseye, Card, CardBody } from '@patternfly/react-core'; import * as React from 'react'; import { useSelector } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { TopologyGraphView } from './GraphView/TopologyGraphView'; import { TopologyListView } from './ListView/TopologyListView'; -import { DiscoveryTreeContext, SearchExprService, SearchExprServiceContext } from './Shared/utils'; -import { DEFAULT_EMPTY_UNIVERSE } from './typings'; +import { DiscoveryTreeContext } from './Shared/utils'; export interface TopologyProps {} export const Topology: React.FC = ({ ..._props }) => { const addSubscription = useSubscriptions(); const context = React.useContext(ServiceContext); - const matchExpreRef = React.useRef(new SearchExprService()); + const matchExpreRef = React.useRef(new MatchExpressionService()); const firstFetchRef = React.useRef(false); const firstFetched = firstFetchRef.current; diff --git a/src/app/Topology/typings.ts b/src/app/Topology/typings.ts deleted file mode 100644 index 2883ca2b1..000000000 --- a/src/app/Topology/typings.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright The Cryostat Authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Target } from '@app/Shared/Services/Target.service'; - -export enum NodeType { - // The entire deployment scenario Cryostat finds itself in. - UNIVERSE = 'Universe', - // A division of the deployment scenario (i.e. Kubernetes, JDP, Custom Target, CryostatAgent) - REALM = 'Realm', - // A plain target JVM, connectable over JMX. - JVM = 'JVM', - // A target JVM using the Cryostat Agent, *not* connectable over JMX. Agent instances - // that do publish a JMX Service URL should publish themselves with the JVM NodeType. - AGENT = 'CryostatAgent', - // Custom target defined via Custom Target Creation Form. - CUSTOM_TARGET = 'CustomTarget', - // Kubernetes platform. - NAMESPACE = 'Namespace', - STATEFULSET = 'StatefulSet', - DAEMONSET = 'DaemonSet', - DEPLOYMENT = 'Deployment', - DEPLOYMENTCONFIG = 'DeploymentConfig', // OpenShift specific - REPLICASET = 'ReplicaSet', - REPLICATIONCONTROLLER = 'ReplicationController', - POD = 'Pod', - ENDPOINT = 'Endpoint', - // Standalone targets - TARGET = 'Target', -} - -export interface NodeLabels { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly [key: string]: any; -} - -interface _AbstractNode { - readonly id: number; - readonly name: string; - readonly nodeType: NodeType; - readonly labels: NodeLabels; -} - -export interface EnvironmentNode extends _AbstractNode { - readonly children: (EnvironmentNode | TargetNode)[]; -} - -export interface TargetNode extends _AbstractNode { - readonly target: Target; -} - -export const DEFAULT_EMPTY_UNIVERSE: EnvironmentNode = { - id: 0, - name: 'Universe', - nodeType: NodeType.UNIVERSE, - labels: {}, - children: [], -}; - -export const isTargetNode = (node: EnvironmentNode | TargetNode): node is TargetNode => { - return node['target'] !== undefined && node['children'] === undefined; -}; diff --git a/src/app/index.tsx b/src/app/index.tsx index 6e2e15c9c..41027a80f 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -21,9 +21,9 @@ import '@app/app.css'; import '@app/Topology/styles/base.css'; import '@i18n/config'; import { AppLayout } from '@app/AppLayout/AppLayout'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; import { AppRoutes } from '@app/routes'; import { store } from '@app/Shared/Redux/ReduxStore'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; import * as React from 'react'; import { Provider } from 'react-redux'; diff --git a/src/app/routes.tsx b/src/app/routes.tsx index ee053872d..1c33a04d4 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -27,16 +27,16 @@ import NotFound from './NotFound/NotFound'; import QuickStarts from './QuickStarts/QuickStartsCatalogPage'; import Recordings from './Recordings/Recordings'; import CreateRule from './Rules/CreateRule'; -import Rules from './Rules/Rules'; +import RulesTable from './Rules/Rules'; import SecurityPanel from './SecurityPanel/SecurityPanel'; import Settings from './Settings/Settings'; -import { DefaultFallBack, ErrorBoundary } from './Shared/ErrorBoundary'; -import { FeatureLevel } from './Shared/Services/Settings.service'; +import { DefaultFallBack, ErrorBoundary } from './Shared/Components/ErrorBoundary'; +import { FeatureLevel } from './Shared/Services/service.types'; import CreateTarget from './Topology/Actions/CreateTarget'; import Topology from './Topology/Topology'; -import { useDocumentTitle } from './utils/useDocumentTitle'; -import { useFeatureLevel } from './utils/useFeatureLevel'; -import { useLogin } from './utils/useLogin'; +import { useDocumentTitle } from './utils/hooks/useDocumentTitle'; +import { useFeatureLevel } from './utils/hooks/useFeatureLevel'; +import { useLogin } from './utils/hooks/useLogin'; import { accessibleRouteChangeHandler } from './utils/utils'; let routeFocusTimer: number; @@ -111,7 +111,7 @@ const routes: IAppRoute[] = [ ], }, { - component: Rules, + component: RulesTable, exact: true, label: 'Automated Rules', path: '/rules', diff --git a/src/app/utils/LocalStorage.ts b/src/app/utils/LocalStorage.ts index 68262701b..c571d6bf2 100644 --- a/src/app/utils/LocalStorage.ts +++ b/src/app/utils/LocalStorage.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -/* eslint-disable @typescript-eslint/no-explicit-any */ + export enum LocalStorageKey { ASSET_VERSION, FEATURE_LEVEL, @@ -44,7 +44,7 @@ export enum LocalStorageKey { export type LocalStorageKeyStrings = keyof typeof LocalStorageKey; -export const getFromLocalStorage = (key: LocalStorageKeyStrings, defaultValue: any): any => { +export const getFromLocalStorage = (key: LocalStorageKeyStrings, defaultValue: T): T => { if (typeof window === 'undefined') { return defaultValue; } @@ -56,7 +56,7 @@ export const getFromLocalStorage = (key: LocalStorageKeyStrings, defaultValue: a } }; -export const saveToLocalStorage = (key: LocalStorageKeyStrings, value: any, error?: () => void) => { +export const saveToLocalStorage = (key: LocalStorageKeyStrings, value: T, error?: () => void): void => { try { if (typeof window !== 'undefined') { window.localStorage.setItem(key, JSON.stringify(value)); @@ -67,7 +67,7 @@ export const saveToLocalStorage = (key: LocalStorageKeyStrings, value: any, erro } }; -export const removeFromLocalStorage = (key: LocalStorageKeyStrings, error?: () => void): any => { +export const removeFromLocalStorage = (key: LocalStorageKeyStrings, error?: () => void): void => { try { if (typeof window !== 'undefined') { window.localStorage.removeItem(key); diff --git a/src/app/utils/fakeData.ts b/src/app/utils/fakeData.ts index e84c1d956..f1d5544fd 100644 --- a/src/app/utils/fakeData.ts +++ b/src/app/utils/fakeData.ts @@ -16,29 +16,33 @@ import { JFRMetricsChartController } from '@app/Dashboard/Charts/jfr/JFRMetricsChartController'; import { MBeanMetricsChartController } from '@app/Dashboard/Charts/mbean/MBeanMetricsChartController'; -import { EventType } from '@app/Events/EventTypes'; -import { Notifications, NotificationsInstance } from '@app/Notifications/Notifications'; -import { Rule } from '@app/Rules/Rules'; +import { ApiService } from '@app/Shared/Services/Api.service'; import { + Target, ActiveRecording, + RecordingState, + Recording, + MBeanMetrics, ActiveRecordingFilterInput, - ApiService, ArchivedRecording, - ChartControllerConfig, - EventProbe, EventTemplate, - MBeanMetrics, - Recording, + EventProbe, + Rule, + StoredCredential, RecordingAttributes, - RecordingState, + NullableTarget, + EventType, + CachedReportValue, + AnalysisResult, SimpleResponse, - StoredCredential, -} from '@app/Shared/Services/Api.service'; +} from '@app/Shared/Services/api.types'; import { LoginService } from '@app/Shared/Services/Login.service'; -import { CachedReportValue, ReportService, AnalysisResult } from '@app/Shared/Services/Report.service'; +import { NotificationService, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; +import { ReportService } from '@app/Shared/Services/Report.service'; +import { ChartControllerConfig } from '@app/Shared/Services/service.types'; import { defaultServices, Services } from '@app/Shared/Services/Services'; import { SettingsService } from '@app/Shared/Services/Settings.service'; -import { Target, TargetService } from '@app/Shared/Services/Target.service'; +import { TargetService } from '@app/Shared/Services/Target.service'; import { Observable, of } from 'rxjs'; export const fakeTarget: Target = { @@ -103,9 +107,9 @@ export const fakeEvaluations: AnalysisResult[] = [ }, }, { - topic: 'classloading', name: 'Class Leak', score: 0, + topic: 'classloading', evaluation: { summary: 'leaked classes', explanation: 'classes were loaded and leaked', @@ -120,9 +124,9 @@ export const fakeEvaluations: AnalysisResult[] = [ }, }, { - topic: 'classloading', name: 'Class Loading Pressure', score: 0, + topic: 'classloading', evaluation: { summary: 'too much loading pressure', explanation: 'lots of classloading slowing things down', @@ -137,9 +141,9 @@ export const fakeEvaluations: AnalysisResult[] = [ }, }, { - topic: 'jvm_information', name: 'Discouraged Management Agent Settings', score: 50, + topic: 'jvm_information', evaluation: { summary: 'bad settings set', explanation: 'these settings can cause problems', @@ -154,9 +158,9 @@ export const fakeEvaluations: AnalysisResult[] = [ }, }, { - topic: 'exceptions', name: 'Thrown Exceptions', score: 0.2, + topic: 'exceptions', evaluation: { summary: 'many exceptions thrown which is slow', explanation: 'exception processing is slower than normal code execution', @@ -178,13 +182,13 @@ export const fakeCachedReport: CachedReportValue = { }; class FakeTargetService extends TargetService { - target(): Observable { + target(): Observable { return of(fakeTarget); } } class FakeReportService extends ReportService { - constructor(notifications: Notifications, login: LoginService) { + constructor(notifications: NotificationService, login: LoginService) { super(login, notifications); } @@ -210,7 +214,7 @@ class FakeSetting extends SettingsService { } class FakeApiService extends ApiService { - constructor(target: TargetService, notifications: Notifications, login: LoginService) { + constructor(target: TargetService, notifications: NotificationService, login: LoginService) { super(target, notifications, login); } diff --git a/src/app/utils/useDayjs.ts b/src/app/utils/hooks/useDayjs.ts similarity index 100% rename from src/app/utils/useDayjs.ts rename to src/app/utils/hooks/useDayjs.ts diff --git a/src/app/utils/useDocumentTitle.ts b/src/app/utils/hooks/useDocumentTitle.ts similarity index 100% rename from src/app/utils/useDocumentTitle.ts rename to src/app/utils/hooks/useDocumentTitle.ts diff --git a/src/app/utils/useFeatureLevel.ts b/src/app/utils/hooks/useFeatureLevel.ts similarity index 94% rename from src/app/utils/useFeatureLevel.ts rename to src/app/utils/hooks/useFeatureLevel.ts index 66bd2a1b9..2d73db794 100644 --- a/src/app/utils/useFeatureLevel.ts +++ b/src/app/utils/hooks/useFeatureLevel.ts @@ -13,8 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; import * as React from 'react'; import { Subscription } from 'rxjs'; diff --git a/src/app/utils/useLogin.ts b/src/app/utils/hooks/useLogin.ts similarity index 94% rename from src/app/utils/useLogin.ts rename to src/app/utils/hooks/useLogin.ts index 66509b56e..7ea25b65f 100644 --- a/src/app/utils/useLogin.ts +++ b/src/app/utils/hooks/useLogin.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SessionState } from '@app/Shared/Services/Login.service'; +import { SessionState } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; import * as React from 'react'; diff --git a/src/app/utils/hooks/useMatchExpressionSvc.ts b/src/app/utils/hooks/useMatchExpressionSvc.ts new file mode 100644 index 000000000..6ea5a75fe --- /dev/null +++ b/src/app/utils/hooks/useMatchExpressionSvc.ts @@ -0,0 +1,20 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { MatchExpressionService } from '@app/Shared/Services/MatchExpression.service'; +import { SearchExprServiceContext } from '@app/Shared/Services/service.utils'; +import * as React from 'react'; + +export const useMatchExpressionSvc = (): MatchExpressionService => React.useContext(SearchExprServiceContext); diff --git a/src/app/utils/hooks/useMatchedTargetsSvc.ts b/src/app/utils/hooks/useMatchedTargetsSvc.ts new file mode 100644 index 000000000..90c75603e --- /dev/null +++ b/src/app/utils/hooks/useMatchedTargetsSvc.ts @@ -0,0 +1,19 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { MatchedTargetsServiceContext } from '@app/Shared/Services/service.utils'; +import * as React from 'react'; + +export const useMatchedTargetsSvc = () => React.useContext(MatchedTargetsServiceContext); diff --git a/src/app/utils/hooks/useMatchedTargetsSvcSource.ts b/src/app/utils/hooks/useMatchedTargetsSvcSource.ts new file mode 100644 index 000000000..74a5c1981 --- /dev/null +++ b/src/app/utils/hooks/useMatchedTargetsSvcSource.ts @@ -0,0 +1,44 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Target } from '@app/Shared/Services/api.types'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import * as React from 'react'; +import { BehaviorSubject, combineLatest, switchMap, catchError, of } from 'rxjs'; +import { useMatchExpressionSvc } from './useMatchExpressionSvc'; +import { useSubscriptions } from './useSubscriptions'; + +export const useMatchedTargetsSvcSource = (): BehaviorSubject => { + const matchedTargetsSvcRef = React.useRef(new BehaviorSubject(undefined)); + const matchExprService = useMatchExpressionSvc(); + const svc = React.useContext(ServiceContext); + const addSubscription = useSubscriptions(); + + React.useEffect(() => { + addSubscription( + combineLatest([matchExprService.searchExpression(), svc.targets.targets()]) + .pipe( + switchMap(([input, targets]) => + input ? svc.api.matchTargetsWithExpr(input, targets).pipe(catchError((_) => of([]))) : of(undefined), + ), + ) + .subscribe((ts) => { + matchedTargetsSvcRef.current.next(ts); + }), + ); + }, [svc.targets, svc.api, matchExprService, addSubscription]); + + return matchedTargetsSvcRef.current; +}; diff --git a/src/app/utils/useSetState.ts b/src/app/utils/hooks/useSetState.ts similarity index 100% rename from src/app/utils/useSetState.ts rename to src/app/utils/hooks/useSetState.ts diff --git a/src/app/utils/useSort.ts b/src/app/utils/hooks/useSort.ts similarity index 100% rename from src/app/utils/useSort.ts rename to src/app/utils/hooks/useSort.ts diff --git a/src/app/utils/useSubscriptions.ts b/src/app/utils/hooks/useSubscriptions.ts similarity index 100% rename from src/app/utils/useSubscriptions.ts rename to src/app/utils/hooks/useSubscriptions.ts diff --git a/src/app/utils/useTheme.ts b/src/app/utils/hooks/useTheme.ts similarity index 96% rename from src/app/utils/useTheme.ts rename to src/app/utils/hooks/useTheme.ts index 011c7b2fa..8ec632e8b 100644 --- a/src/app/utils/useTheme.ts +++ b/src/app/utils/hooks/useTheme.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ThemeSetting, ThemeType } from '@app/Settings/SettingsUtils'; +import { ThemeSetting, ThemeType } from '@app/Settings/types'; import { ServiceContext } from '@app/Shared/Services/Services'; import * as React from 'react'; import { Subscription } from 'rxjs'; diff --git a/src/app/utils/utils.ts b/src/app/utils/utils.ts index 01ea6f144..7c3143f71 100644 --- a/src/app/utils/utils.ts +++ b/src/app/utils/utils.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { UPLOADS_SUBDIRECTORY } from '@app/Shared/Services/Api.service'; +import { UPLOADS_SUBDIRECTORY } from '@app/Shared/Services/api.types'; import { ISortBy, SortByDirection } from '@patternfly/react-table'; import _ from 'lodash'; import { useHistory } from 'react-router-dom'; @@ -28,25 +28,26 @@ const MINUTE_MILLIS = 60 * SECOND_MILLIS; const HOUR_MILLIS = 60 * MINUTE_MILLIS; const DAY_MILLIS = 24 * HOUR_MILLIS; -// [ 0, 1, 2, 3 ] array -// 0 1 2 3 indexes -// { 0 | 1 | 2 | 3 | 4 } gap indices (drop zones) -/* eslint-disable @typescript-eslint/no-explicit-any */ -export function move(arr: any[], from: number, gapIndex: number) { +/** + * + * [ 0, 1, 2, 3 ] array + * 0 1 2 3 indexes + * { 0 | 1 | 2 | 3 | 4 } gap indices (drop zones) + */ +export const move = (arr: T[], from: number, gapIndex: number): T[] => { if (gapIndex > from) { gapIndex--; } arr.splice(gapIndex, 0, arr.splice(from, 1)[0]); return arr; -} +}; -export function swap(arr: any[], from: number, to: number) { +export const swap = (arr: T[], from: number, to: number): T[] => { arr[from] = arr.splice(to, 1, arr[from])[0]; return arr; -} -/* eslint-enable @typescript-eslint/no-explicit-any */ +}; -export const openTabForUrl = (url: string) => { +export const openTabForUrl = (url: string): void => { const anchor = document.createElement('a') as HTMLAnchorElement; anchor.setAttribute('href', url); anchor.setAttribute('target', '_blank'); @@ -63,14 +64,14 @@ export const createBlobURL = (content: string, contentType: string, timeout = 10 return url; }; -export function accessibleRouteChangeHandler() { +export const accessibleRouteChangeHandler = () => { return window.setTimeout(() => { const mainContainer = document.getElementById('primary-app-container'); if (mainContainer) { mainContainer.focus(); } }, 50); -} +}; export const hashCode = (str: string): number => { let hash = 0; @@ -101,7 +102,7 @@ export interface AutomatedAnalysisTimerObject { } export const calculateAnalysisTimer = (reportTime: number): AutomatedAnalysisTimerObject => { - let interval, timerQuantity, timerUnits; + let interval: number, timerQuantity: number, timerUnits: string; const now = Date.now(); const reportMillis = now - reportTime; if (reportMillis < MINUTE_MILLIS) { @@ -139,7 +140,7 @@ export const splitWordsOnUppercase = (str: string, capitalizeFirst?: boolean): s const needUppercase = /(url|id|jvm)/i; -export const getDisplayFieldName = (fieldName: string) => { +export const getDisplayFieldName = (fieldName: string): string => { return splitWordsOnUppercase(fieldName) .map((word) => { if (needUppercase.test(word)) { @@ -195,8 +196,7 @@ const getTransform = (tableColumns: TableColumn[], index?: number) => { return tableColumns[index]?.transform; }; -/* eslint-disable @typescript-eslint/no-explicit-any */ -export const getValue = (object: any, keyPath: string[]) => { +export const getValue = (object: R, keyPath: string[]) => { return keyPath.reduce((acc, key) => acc[key], object); }; @@ -219,7 +219,6 @@ export const sortResources = ({ index, direction }: ISortBy, resources: R[], return direction === SortByDirection.asc ? sorted : sorted.reverse(); }; -/* eslint-enable @typescript-eslint/no-explicit-any */ export interface TabConfig { tabKey: string; tabValue: string; @@ -285,3 +284,6 @@ export const jvmIdToSubdirectoryName = (jvmId: string): string => { } return utf8ToBase32(jvmId); }; + +export const includesSubstr = (a: string, b: string): boolean => + !!a && !!b && a.toLowerCase().includes(b.trim().toLowerCase()); diff --git a/src/app/utils/withThemedIcon.tsx b/src/app/utils/withThemedIcon.tsx index 5e9679e9d..6e8343b20 100644 --- a/src/app/utils/withThemedIcon.tsx +++ b/src/app/utils/withThemedIcon.tsx @@ -13,8 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ThemeSetting } from '@app/Settings/SettingsUtils'; -import { useTheme } from '@app/utils/useTheme'; +import { ThemeSetting } from '@app/Settings/types'; +import { useTheme } from '@app/utils/hooks/useTheme'; import React from 'react'; export const withThemedIcon = (icon: string, darkIcon: string, alt: string): React.FC => { diff --git a/src/itest/RecordingWorkflow.test.ts b/src/itest/RecordingWorkflow.test.ts index be33400a7..fd034fced 100644 --- a/src/itest/RecordingWorkflow.test.ts +++ b/src/itest/RecordingWorkflow.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import assert from 'assert'; -import { RecordingState } from '@app/Shared/Services/Api.service'; +import { RecordingState } from '@app/Shared/Services/api.types'; import { WebDriver } from 'selenium-webdriver'; import { Cryostat, Recordings, setupDriver, sleep } from './util'; diff --git a/src/test/About/About.test.tsx b/src/test/About/About.test.tsx index 84346cc5f..1a17bae54 100644 --- a/src/test/About/About.test.tsx +++ b/src/test/About/About.test.tsx @@ -15,7 +15,7 @@ */ import i18n from '@app/../i18n/config'; import { About } from '@app/About/About'; -import { ThemeSetting } from '@app/Settings/SettingsUtils'; +import { ThemeSetting } from '@app/Settings/types'; import { defaultServices } from '@app/Shared/Services/Services'; import { cleanup, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; diff --git a/src/test/Agent/AgentLiveProbes.test.tsx b/src/test/Agent/AgentLiveProbes.test.tsx index 2e6674fb3..32680b604 100644 --- a/src/test/Agent/AgentLiveProbes.test.tsx +++ b/src/test/Agent/AgentLiveProbes.test.tsx @@ -14,15 +14,15 @@ * limitations under the License. */ import { AgentLiveProbes } from '@app/Agent/AgentLiveProbes'; -import { DeleteActiveProbes } from '@app/Modal/DeleteWarningUtils'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; -import { EventProbe } from '@app/Shared/Services/Api.service'; +import { DeleteActiveProbes } from '@app/Modal/types'; import { - MessageMeta, MessageType, + EventProbe, NotificationCategory, + MessageMeta, NotificationMessage, -} from '@app/Shared/Services/NotificationChannel.service'; +} from '@app/Shared/Services/api.types'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; import '@testing-library/jest-dom'; import { cleanup, screen, within } from '@testing-library/react'; diff --git a/src/test/Agent/AgentProbeTemplates.test.tsx b/src/test/Agent/AgentProbeTemplates.test.tsx index ef75240c6..aa5324107 100644 --- a/src/test/Agent/AgentProbeTemplates.test.tsx +++ b/src/test/Agent/AgentProbeTemplates.test.tsx @@ -14,14 +14,14 @@ * limitations under the License. */ import { AgentProbeTemplates } from '@app/Agent/AgentProbeTemplates'; -import { DeleteProbeTemplates } from '@app/Modal/DeleteWarningUtils'; -import { ProbeTemplate } from '@app/Shared/Services/Api.service'; +import { DeleteProbeTemplates } from '@app/Modal/types'; import { - MessageMeta, MessageType, + ProbeTemplate, NotificationCategory, + MessageMeta, NotificationMessage, -} from '@app/Shared/Services/NotificationChannel.service'; +} from '@app/Shared/Services/api.types'; import { defaultServices } from '@app/Shared/Services/Services'; import '@testing-library/jest-dom'; import { cleanup, screen, within } from '@testing-library/react'; diff --git a/src/test/Archives/AllArchivedRecordingsTable.test.tsx b/src/test/Archives/AllArchivedRecordingsTable.test.tsx index bb0f049a1..824593591 100644 --- a/src/test/Archives/AllArchivedRecordingsTable.test.tsx +++ b/src/test/Archives/AllArchivedRecordingsTable.test.tsx @@ -15,9 +15,8 @@ */ import { AllArchivedRecordingsTable } from '@app/Archives/AllArchivedRecordingsTable'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; -import { ArchivedRecording, RecordingDirectory } from '@app/Shared/Services/Api.service'; -import { NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; +import { NotificationMessage, ArchivedRecording, RecordingDirectory } from '@app/Shared/Services/api.types'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; import '@testing-library/jest-dom'; import { cleanup, screen, within } from '@testing-library/react'; diff --git a/src/test/Archives/AllTargetsArchivedRecordingsTable.test.tsx b/src/test/Archives/AllTargetsArchivedRecordingsTable.test.tsx index c25b4c39a..c722f0bcd 100644 --- a/src/test/Archives/AllTargetsArchivedRecordingsTable.test.tsx +++ b/src/test/Archives/AllTargetsArchivedRecordingsTable.test.tsx @@ -14,10 +14,9 @@ * limitations under the License. */ import { AllTargetsArchivedRecordingsTable } from '@app/Archives/AllTargetsArchivedRecordingsTable'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; -import { NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; +import { Target, NotificationMessage } from '@app/Shared/Services/api.types'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; import '@testing-library/jest-dom'; import { cleanup, screen, within } from '@testing-library/react'; import * as React from 'react'; @@ -277,7 +276,7 @@ describe('', () => { expect(within(firstTarget).getByText(`${mockCount1}`)).toBeTruthy(); await user.type(search, 'asdasdjhj'); - expect(screen.getByText('No Targets')).toBeInTheDocument(); + expect(screen.getByText('No Archived Recordings')).toBeInTheDocument(); expect(screen.queryByLabelText('all-targets-table')).not.toBeInTheDocument(); await user.clear(search); diff --git a/src/test/Archives/Archives.test.tsx b/src/test/Archives/Archives.test.tsx index 870d23d26..bd034edb7 100644 --- a/src/test/Archives/Archives.test.tsx +++ b/src/test/Archives/Archives.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { Archives } from '@app/Archives/Archives'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; import { cleanup, screen, within } from '@testing-library/react'; import '@testing-library/jest-dom'; diff --git a/src/test/Common.tsx b/src/test/Common.tsx index 38089ca24..b85a3e307 100644 --- a/src/test/Common.tsx +++ b/src/test/Common.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; import { defaultAutomatedAnalysisFilters, defaultDashboardConfigs, @@ -24,13 +23,13 @@ import { RootState, setupStore, } from '@app/Shared/Redux/ReduxStore'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryHistory } from 'history'; import { t, TOptions } from 'i18next'; import React, { PropsWithChildren } from 'react'; -import '@i18n/config'; import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; diff --git a/src/test/CreateRecording/CustomRecordingForm.test.tsx b/src/test/CreateRecording/CustomRecordingForm.test.tsx index 169a22a49..245ecb5ab 100644 --- a/src/test/CreateRecording/CustomRecordingForm.test.tsx +++ b/src/test/CreateRecording/CustomRecordingForm.test.tsx @@ -14,17 +14,18 @@ * limitations under the License. */ import { CustomRecordingForm } from '@app/CreateRecording/CustomRecordingForm'; -import { authFailMessage } from '@app/ErrorView/ErrorView'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; -import { EventTemplate, RecordingAttributes, RecordingOptions } from '@app/Shared/Services/Api.service'; +import { authFailMessage } from '@app/ErrorView/types'; +import { EventTemplate, AdvancedRecordingOptions, RecordingAttributes } from '@app/Shared/Services/api.types'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { ServiceContext, Services, defaultServices } from '@app/Shared/Services/Services'; import { TargetService } from '@app/Shared/Services/Target.service'; import { screen, cleanup, act as doAct } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import * as React from 'react'; +import { Router } from 'react-router-dom'; import renderer, { act } from 'react-test-renderer'; import { of, Subject } from 'rxjs'; -import { renderWithServiceContext } from '../Common'; +import { renderWithServiceContextAndRouter } from '../Common'; jest.mock('@patternfly/react-core', () => ({ // Mock out tooltip for snapshot testing @@ -42,8 +43,7 @@ const mockCustomEventTemplate: EventTemplate = { type: 'CUSTOM', }; -const mockRecordingOptions: RecordingOptions = { - toDisk: true, +const mockRecordingOptions: AdvancedRecordingOptions = { maxAge: undefined, maxSize: 0, }; @@ -87,7 +87,9 @@ describe('', () => { tree = renderer.create( - + + + , ); @@ -97,7 +99,7 @@ describe('', () => { it('should create recording when form is filled and create is clicked', async () => { const onSubmitSpy = jest.spyOn(defaultServices.api, 'createRecording').mockReturnValue(of(mockResponse)); - const { user } = renderWithServiceContext(); + const { user } = renderWithServiceContextAndRouter(); const nameInput = screen.getByLabelText('Name *'); expect(nameInput).toBeInTheDocument(); @@ -123,18 +125,18 @@ describe('', () => { events: 'template=someEventTemplate,type=CUSTOM', duration: 30, archiveOnStop: true, - options: { - restart: false, - toDisk: true, + restart: false, + advancedOptions: { maxAge: undefined, maxSize: 0, + toDisk: true, }, metadata: { labels: {} }, } as RecordingAttributes); }); it('should show correct helper texts in metadata label editor', async () => { - const { user } = renderWithServiceContext(); + const { user } = renderWithServiceContextAndRouter(); const metadataEditorToggle = screen.getByText('Show metadata options'); expect(metadataEditorToggle).toBeInTheDocument(); @@ -157,7 +159,7 @@ describe('', () => { ...defaultServices, target: mockTargetSvc, }; - renderWithServiceContext(, { services: services }); + renderWithServiceContextAndRouter(, { services: services }); await doAct(async () => subj.next()); diff --git a/src/test/CreateRecording/SnapshotRecordingForm.test.tsx b/src/test/CreateRecording/SnapshotRecordingForm.test.tsx index 2b15fa5c7..d1974d0ef 100644 --- a/src/test/CreateRecording/SnapshotRecordingForm.test.tsx +++ b/src/test/CreateRecording/SnapshotRecordingForm.test.tsx @@ -15,8 +15,8 @@ */ import { SnapshotRecordingForm } from '@app/CreateRecording/SnapshotRecordingForm'; -import { authFailMessage } from '@app/ErrorView/ErrorView'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; +import { authFailMessage } from '@app/ErrorView/types'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { ServiceContext, Services, defaultServices } from '@app/Shared/Services/Services'; import { TargetService } from '@app/Shared/Services/Target.service'; import { screen, cleanup, act as doAct } from '@testing-library/react'; diff --git a/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.test.tsx b/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.test.tsx index 5b5e33e57..ff7ac1644 100644 --- a/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.test.tsx +++ b/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.test.tsx @@ -15,19 +15,17 @@ */ import { AutomatedAnalysisCard } from '@app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard'; import { RootState } from '@app/Shared/Redux/ReduxStore'; -import { - ArchivedRecording, - automatedAnalysisRecordingName, - defaultAutomatedAnalysisRecordingConfig, -} from '@app/Shared/Services/Api.service'; import { CachedReportValue, + AnalysisResult, + ArchivedRecording, FAILED_REPORT_MESSAGE, NO_RECORDINGS_MESSAGE, - AnalysisResult, -} from '@app/Shared/Services/Report.service'; + automatedAnalysisRecordingName, +} from '@app/Shared/Services/api.types'; +import { defaultAutomatedAnalysisRecordingConfig } from '@app/Shared/Services/service.types'; +import { automatedAnalysisConfigToRecordingAttributes } from '@app/Shared/Services/service.utils'; import { defaultServices } from '@app/Shared/Services/Services'; -import { automatedAnalysisConfigToRecordingAttributes } from '@app/Shared/Services/Settings.service'; import '@testing-library/jest-dom'; import { cleanup, screen, waitFor } from '@testing-library/react'; import * as React from 'react'; @@ -51,9 +49,9 @@ const mockEmptyCachedReport: CachedReportValue = { }; const mockRuleEvaluation1: AnalysisResult = { - topic: 'myTopic', name: 'rule1', score: 100, + topic: 'myTopic', evaluation: { summary: 'rule1 summary', explanation: 'rule1 explanation', @@ -69,9 +67,9 @@ const mockRuleEvaluation1: AnalysisResult = { }; const mockRuleEvaluation2: AnalysisResult = { - topic: 'fakeTopic', name: 'rule2', score: 0, + topic: 'fakeTopic', evaluation: { summary: 'rule2 summary', explanation: 'rule2 explanation', @@ -87,9 +85,9 @@ const mockRuleEvaluation2: AnalysisResult = { }; const mockRuleEvaluation3: AnalysisResult = { - topic: 'fakeTopic', name: 'rule3', score: 55, + topic: 'fakeTopic', evaluation: { summary: 'rule3 summary', explanation: 'rule3 explanation', @@ -105,9 +103,9 @@ const mockRuleEvaluation3: AnalysisResult = { }; const mockNaRuleEvaluation: AnalysisResult = { - topic: 'fakeTopic', name: 'N/A rule', score: -1, + topic: 'fakeTopic', evaluation: { summary: 'NArule summary', explanation: 'NArule explanation', @@ -196,6 +194,9 @@ const mockEmptyArchivedRecordingsResponse = { jest.spyOn(defaultServices.target, 'target').mockReturnValue(of(mockTarget)); jest.spyOn(defaultServices.target, 'authFailure').mockReturnValue(of()); jest.spyOn(defaultServices.target, 'authRetry').mockReturnValue(of()); +jest + .spyOn(defaultServices.settings, 'automatedAnalysisRecordingConfig') + .mockReturnValue(defaultAutomatedAnalysisRecordingConfig); describe('', () => { let preloadedState: RootState; @@ -239,13 +240,11 @@ describe('', () => { jest.spyOn(defaultServices.reports, 'getCachedAnalysisReport').mockReturnValueOnce(mockEmptyCachedReport); jest.spyOn(defaultServices.api, 'graphql').mockReturnValueOnce(of(mockEmptyArchivedRecordingsResponse)); - jest.spyOn(defaultServices.api, 'createRecording').mockReturnValueOnce(of()); + const requestSpy = jest.spyOn(defaultServices.api, 'createRecording').mockReturnValueOnce(of()); const { user } = renderWithServiceContextAndReduxStore(, { preloadState: preloadedState, }); - const requestSpy = jest.spyOn(defaultServices.api, 'createRecording'); - expect(screen.getByText(testT('AutomatedAnalysisCard.ERROR_TITLE'))).toBeInTheDocument(); // Error view expect(screen.getByText(NO_RECORDINGS_MESSAGE)).toBeInTheDocument(); // Error message expect(screen.getByText(testT('AutomatedAnalysisCard.ERROR_TEXT'))).toBeInTheDocument(); // Error details diff --git a/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisCardList.test.tsx b/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisCardList.test.tsx index f5fbbe62c..394ad1b23 100644 --- a/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisCardList.test.tsx +++ b/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisCardList.test.tsx @@ -14,19 +14,18 @@ * limitations under the License. */ import { AutomatedAnalysisCardList } from '@app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCardList'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; import { store } from '@app/Shared/Redux/ReduxStore'; -import { CategorizedRuleEvaluations, AnalysisResult } from '@app/Shared/Services/Report.service'; +import { AnalysisResult, CategorizedRuleEvaluations } from '@app/Shared/Services/api.types'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import React from 'react'; import { Provider } from 'react-redux'; import renderer, { act } from 'react-test-renderer'; -import '../../Common'; const mockRuleEvaluation1: AnalysisResult = { - topic: 'myTopic', name: 'rule1', score: 100, + topic: 'myTopic', evaluation: { summary: 'first thing happened', explanation: 'first reason', @@ -42,9 +41,9 @@ const mockRuleEvaluation1: AnalysisResult = { }; const mockRuleEvaluation2: AnalysisResult = { - topic: 'fakeTopic', name: 'rule2', score: 0, + topic: 'fakeTopic', evaluation: { summary: 'second thing happened', explanation: 'second reason', @@ -54,9 +53,9 @@ const mockRuleEvaluation2: AnalysisResult = { }; const mockRuleEvaluation3: AnalysisResult = { - topic: 'fakeTopic', name: 'rule3', score: 55, + topic: 'fakeTopic', evaluation: { summary: 'third thing happened', explanation: 'third reason', @@ -66,9 +65,9 @@ const mockRuleEvaluation3: AnalysisResult = { }; const mockNaRuleEvaluation: AnalysisResult = { - topic: 'fakeTopic', name: 'N/A rule', score: -1, + topic: 'fakeTopic', evaluation: { summary: 'fourth thing happened', explanation: 'fourth reason', diff --git a/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigDrawer.test.tsx b/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigDrawer.test.tsx index f3e4b2637..034a06b24 100644 --- a/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigDrawer.test.tsx +++ b/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigDrawer.test.tsx @@ -14,7 +14,8 @@ * limitations under the License. */ import { AutomatedAnalysisConfigDrawer } from '@app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigDrawer'; -import { defaultAutomatedAnalysisRecordingConfig, SimpleResponse } from '@app/Shared/Services/Api.service'; +import { SimpleResponse } from '@app/Shared/Services/api.types'; +import { defaultAutomatedAnalysisRecordingConfig } from '@app/Shared/Services/service.types'; import { defaultServices } from '@app/Shared/Services/Services'; import '@testing-library/jest-dom'; import { cleanup, screen } from '@testing-library/react'; diff --git a/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.test.tsx b/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.test.tsx index 12c3f103e..a0c69ed96 100644 --- a/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.test.tsx +++ b/src/test/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.test.tsx @@ -14,7 +14,8 @@ * limitations under the License. */ import { AutomatedAnalysisConfigForm } from '@app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm'; -import { AutomatedAnalysisRecordingConfig, EventTemplate } from '@app/Shared/Services/Api.service'; +import { EventTemplate } from '@app/Shared/Services/api.types'; +import { AutomatedAnalysisRecordingConfig } from '@app/Shared/Services/service.types'; import { defaultServices } from '@app/Shared/Services/Services'; import '@testing-library/jest-dom'; import { cleanup, screen } from '@testing-library/react'; diff --git a/src/test/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel.test.tsx b/src/test/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel.test.tsx index bfeaaf366..89c268fd2 100644 --- a/src/test/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel.test.tsx +++ b/src/test/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel.test.tsx @@ -15,15 +15,15 @@ */ import { ClickableAutomatedAnalysisLabel } from '@app/Dashboard/AutomatedAnalysis/ClickableAutomatedAnalysisLabel'; -import { AnalysisResult } from '@app/Shared/Services/Report.service'; +import { AnalysisResult } from '@app/Shared/Services/api.types'; import { act, cleanup, screen, within } from '@testing-library/react'; import React from 'react'; import { renderDefault } from '../../Common'; const mockRuleEvaluation1: AnalysisResult = { - topic: 'myTopic', name: 'rule1', score: 100, + topic: 'myTopic', evaluation: { summary: 'rule1 summary', explanation: 'rule1 explanation', @@ -39,9 +39,9 @@ const mockRuleEvaluation1: AnalysisResult = { }; const mockRuleEvaluation2: AnalysisResult = { - topic: 'fakeTopic', name: 'rule2', score: 55, + topic: 'fakeTopic', evaluation: { summary: 'rule2 summary', explanation: 'rule2 explanation', @@ -57,9 +57,9 @@ const mockRuleEvaluation2: AnalysisResult = { }; const mockRuleEvaluation3: AnalysisResult = { - topic: 'fakeTopic', name: 'rule3', score: 0, + topic: 'fakeTopic', evaluation: { summary: 'rule3 summary', explanation: 'rule3 explanation', @@ -75,9 +75,9 @@ const mockRuleEvaluation3: AnalysisResult = { }; const mockNaRuleEvaluation: AnalysisResult = { - topic: 'fakeTopic', name: 'N/A rule', score: -1, + topic: 'fakeTopic', evaluation: { summary: 'rule4 summary', explanation: 'rule4 explanation', @@ -96,13 +96,13 @@ describe('', () => { afterEach(cleanup); it('displays label', async () => { - renderDefault(); + renderDefault(); expect(screen.getByText(mockRuleEvaluation1.name)).toBeInTheDocument(); }); it('displays popover when critical label is clicked', async () => { - const { user } = renderDefault(); + const { user } = renderDefault(); expect(screen.getByText(mockRuleEvaluation1.name)).toBeInTheDocument(); @@ -133,9 +133,11 @@ describe('', () => { expect(setting).toBeInTheDocument(); expect(keyval).toBeInTheDocument(); expect(score).toBeInTheDocument(); + const heading = screen.getByRole('heading', { name: /danger rule1/i, }); + expect(within(heading).getByText(mockRuleEvaluation1.name)).toBeInTheDocument(); await user.click(screen.getAllByText(mockRuleEvaluation1.name)[0]); @@ -150,7 +152,7 @@ describe('', () => { }); it('displays popover when warning label is clicked', async () => { - const { user } = renderDefault(); + const { user } = renderDefault(); expect(screen.getByText(mockRuleEvaluation2.name)).toBeInTheDocument(); @@ -181,9 +183,11 @@ describe('', () => { expect(setting).toBeInTheDocument(); expect(keyval).toBeInTheDocument(); expect(score).toBeInTheDocument(); + const heading = screen.getByRole('heading', { name: /warning rule2/i, }); + expect(within(heading).getByText(mockRuleEvaluation2.name)).toBeInTheDocument(); await user.click(screen.getAllByText(mockRuleEvaluation2.name)[0]); @@ -194,12 +198,11 @@ describe('', () => { expect(setting).not.toBeInTheDocument(); expect(keyval).not.toBeInTheDocument(); expect(score).not.toBeInTheDocument(); - expect(closeButton).not.toBeInTheDocument(); }); it('displays popover when ok label is clicked', async () => { - const { user } = renderDefault(); + const { user } = renderDefault(); expect(screen.getByText(mockRuleEvaluation3.name)).toBeInTheDocument(); @@ -229,9 +232,11 @@ describe('', () => { expect(setting).toBeInTheDocument(); expect(keyval).toBeInTheDocument(); expect(score).toBeInTheDocument(); + const heading = screen.getByRole('heading', { name: /success rule3/i, }); + expect(within(heading).getByText(mockRuleEvaluation3.name)).toBeInTheDocument(); await user.click(screen.getAllByText(mockRuleEvaluation3.name)[0]); @@ -242,12 +247,11 @@ describe('', () => { expect(setting).not.toBeInTheDocument(); expect(keyval).not.toBeInTheDocument(); expect(score).not.toBeInTheDocument(); - expect(closeButton).not.toBeInTheDocument(); }); it('displays popover when N/A label is clicked', async () => { - const { user } = renderDefault(); + const { user } = renderDefault(); expect(screen.getByText(mockNaRuleEvaluation.name)).toBeInTheDocument(); @@ -260,6 +264,7 @@ describe('', () => { expect(closeButton).toBeInTheDocument(); + expect(document.getElementsByClassName('pf-m-default').item(0)).toBeInTheDocument(); const summary = screen.getByText(mockNaRuleEvaluation.evaluation.summary); const explanation = screen.getByText(mockNaRuleEvaluation.evaluation.explanation); const solution = screen.getByText(mockNaRuleEvaluation.evaluation.solution); @@ -275,9 +280,11 @@ describe('', () => { expect(setting).toBeInTheDocument(); expect(keyval).toBeInTheDocument(); expect(score).toBeInTheDocument(); + const heading = screen.getByRole('heading', { name: /default /i, }); + expect(within(heading).getByText(mockNaRuleEvaluation.name)).toBeInTheDocument(); await user.click(screen.getAllByText(mockNaRuleEvaluation.name)[0]); @@ -288,7 +295,6 @@ describe('', () => { expect(setting).not.toBeInTheDocument(); expect(keyval).not.toBeInTheDocument(); expect(score).not.toBeInTheDocument(); - expect(closeButton).not.toBeInTheDocument(); }); }); diff --git a/src/test/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisNameFilter.test.tsx b/src/test/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisNameFilter.test.tsx index eea84f623..eb4d9e0af 100644 --- a/src/test/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisNameFilter.test.tsx +++ b/src/test/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisNameFilter.test.tsx @@ -15,15 +15,15 @@ */ import { AutomatedAnalysisNameFilter } from '@app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisNameFilter'; -import { CategorizedRuleEvaluations, AnalysisResult } from '@app/Shared/Services/Report.service'; +import { AnalysisResult, CategorizedRuleEvaluations } from '@app/Shared/Services/api.types'; import { cleanup, screen, within } from '@testing-library/react'; import React from 'react'; import { renderDefault } from '../../../Common'; const mockRuleEvaluation1: AnalysisResult = { - topic: 'myTopic', name: 'rule1', score: 100, + topic: 'myTopic', evaluation: { summary: 'rule1 summary', explanation: 'rule1 explanation', @@ -39,9 +39,9 @@ const mockRuleEvaluation1: AnalysisResult = { }; const mockRuleEvaluation2: AnalysisResult = { - topic: 'fakeTopic', name: 'rule2', score: 0, + topic: 'fakeTopic', evaluation: { summary: 'rule2 summary', explanation: 'rule2 explanation', @@ -57,9 +57,9 @@ const mockRuleEvaluation2: AnalysisResult = { }; const mockRuleEvaluation3: AnalysisResult = { - topic: 'fakeTopic', name: 'rule3', score: 55, + topic: 'fakeTopic', evaluation: { summary: 'rule3 summary', explanation: 'rule3 explanation', @@ -75,9 +75,9 @@ const mockRuleEvaluation3: AnalysisResult = { }; const mockNaRuleEvaluation: AnalysisResult = { - topic: 'fakeTopic', name: 'N/A rule', score: -1, + topic: 'fakeTopic', evaluation: { summary: 'NArule summary', explanation: 'NArule explanation', diff --git a/src/test/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisTopicFilter.test.tsx b/src/test/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisTopicFilter.test.tsx index b596a12d4..c2fde083b 100644 --- a/src/test/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisTopicFilter.test.tsx +++ b/src/test/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisTopicFilter.test.tsx @@ -15,15 +15,15 @@ */ import { AutomatedAnalysisTopicFilter } from '@app/Dashboard/AutomatedAnalysis/Filters/AutomatedAnalysisTopicFilter'; -import { CategorizedRuleEvaluations, AnalysisResult } from '@app/Shared/Services/Report.service'; +import { AnalysisResult, CategorizedRuleEvaluations } from '@app/Shared/Services/api.types'; import { cleanup, screen, within } from '@testing-library/react'; import React from 'react'; import { renderDefault } from '../../../Common'; const mockRuleEvaluation1: AnalysisResult = { - topic: 'myTopic', name: 'rule1', score: 100, + topic: 'myTopic', evaluation: { summary: 'rule1 summary', explanation: 'rule1 explanation', @@ -39,9 +39,9 @@ const mockRuleEvaluation1: AnalysisResult = { }; const mockRuleEvaluation2: AnalysisResult = { - topic: 'fakeTopic', name: 'rule2', score: 0, + topic: 'fakeTopic', evaluation: { summary: 'rule2 summary', explanation: 'rule2 explanation', @@ -57,9 +57,9 @@ const mockRuleEvaluation2: AnalysisResult = { }; const mockRuleEvaluation3: AnalysisResult = { - topic: 'fakeTopic', name: 'rule3', score: 55, + topic: 'fakeTopic', evaluation: { summary: 'rule3 summary', explanation: 'rule3 explanation', @@ -75,9 +75,9 @@ const mockRuleEvaluation3: AnalysisResult = { }; const mockNaRuleEvaluation: AnalysisResult = { - topic: 'fakeTopic', name: 'N/A rule', score: -1, + topic: 'fakeTopic', evaluation: { summary: 'NArule summary', explanation: 'NArule explanation', diff --git a/src/test/Dashboard/Charts/jfr/JFRMetricsChartCard.test.tsx b/src/test/Dashboard/Charts/jfr/JFRMetricsChartCard.test.tsx index 9b6292b63..9a3fff740 100644 --- a/src/test/Dashboard/Charts/jfr/JFRMetricsChartCard.test.tsx +++ b/src/test/Dashboard/Charts/jfr/JFRMetricsChartCard.test.tsx @@ -16,13 +16,13 @@ jest.mock('@app/Dashboard/Charts/jfr/JFRMetricsChartController'); -import { ChartContext } from '@app/Dashboard/Charts/ChartContext'; +import { ChartContext } from '@app/Dashboard/Charts/context'; import { JFRMetricsChartCard, kindToId } from '@app/Dashboard/Charts/jfr/JFRMetricsChartCard'; import { JFRMetricsChartController, ControllerState } from '@app/Dashboard/Charts/jfr/JFRMetricsChartController'; import { MBeanMetricsChartController } from '@app/Dashboard/Charts/mbean/MBeanMetricsChartController'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; -import { ThemeSetting } from '@app/Settings/SettingsUtils'; +import { ThemeSetting } from '@app/Settings/types'; import { setupStore, store } from '@app/Shared/Redux/ReduxStore'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import { cleanup, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -153,19 +153,19 @@ describe('', () => { await user.click(screen.getByRole('button', { name: /create/i })); expect(history.location.pathname).toBe('/recordings/create'); expect(history.location.state).toEqual({ + name: 'dashboard_metrics', + template: { + name: 'Continuous', + type: 'TARGET', + }, + restart: true, + labels: [{ key: 'origin', value: 'dashboard_metrics' }], duration: -1, - labels: [ - { - key: 'origin', - value: 'dashboard_metrics', - }, - ], + skipDurationCheck: true, maxAge: 120, + maxAgeUnit: 1, maxSize: 100 * 1024 * 1024, - name: 'dashboard_metrics', - restartExisting: true, - templateName: 'Continuous', - templateType: 'TARGET', + maxSizeUnit: 1, }); }); diff --git a/src/test/Dashboard/Charts/mbean/MBeanMetricsChartCard.test.tsx b/src/test/Dashboard/Charts/mbean/MBeanMetricsChartCard.test.tsx index fa5217407..cd1da55d9 100644 --- a/src/test/Dashboard/Charts/mbean/MBeanMetricsChartCard.test.tsx +++ b/src/test/Dashboard/Charts/mbean/MBeanMetricsChartCard.test.tsx @@ -17,14 +17,14 @@ jest.useFakeTimers('modern').setSystemTime(new Date('14 Feb 2023 00:00:00 UTC')); jest.mock('@app/Dashboard/Charts/mbean/MBeanMetricsChartController'); -import { ChartContext } from '@app/Dashboard/Charts/ChartContext'; +import { ChartContext } from '@app/Dashboard/Charts/context'; import { JFRMetricsChartController } from '@app/Dashboard/Charts/jfr/JFRMetricsChartController'; import { MBeanMetricsChartCard } from '@app/Dashboard/Charts/mbean/MBeanMetricsChartCard'; import { MBeanMetricsChartController } from '@app/Dashboard/Charts/mbean/MBeanMetricsChartController'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; -import { ThemeSetting } from '@app/Settings/SettingsUtils'; +import { ThemeSetting } from '@app/Settings/types'; import { store } from '@app/Shared/Redux/ReduxStore'; -import { MBeanMetrics } from '@app/Shared/Services/Api.service'; +import { MBeanMetrics } from '@app/Shared/Services/api.types'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import '@i18n/config'; import { defaultDatetimeFormat } from '@i18n/datetime'; diff --git a/src/test/Dashboard/Dashboard.test.tsx b/src/test/Dashboard/Dashboard.test.tsx index 7fd146f8a..1b7ef3f4e 100644 --- a/src/test/Dashboard/Dashboard.test.tsx +++ b/src/test/Dashboard/Dashboard.test.tsx @@ -13,16 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +jest.useFakeTimers('modern').setSystemTime(new Date('14 Feb 2023 00:00:00 UTC')); + import { Dashboard } from '@app/Dashboard/Dashboard'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; +import { ThemeSetting } from '@app/Settings/types'; import { store } from '@app/Shared/Redux/ReduxStore'; +import { Target } from '@app/Shared/Services/api.types'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; +import { defaultChartControllerConfig } from '@app/Shared/Services/service.utils'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; +import { defaultDatetimeFormat } from '@i18n/datetime'; +import { createMemoryHistory } from 'history'; import React from 'react'; import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; import renderer, { act } from 'react-test-renderer'; import { of } from 'rxjs'; -import '../Common'; const mockFooConnectUrl = 'service:jmx:rmi://someFooUrl'; @@ -36,27 +43,26 @@ const mockFooTarget: Target = { }; jest.mock('@app/TargetView/TargetContextSelector', () => ({ - TargetContextSelector: (_) =>
Target Context Selector
, + TargetContextSelector: () =>
Target Context Selector
, })); -jest.mock('@app/Dashboard/AddCard', () => ({ - AddCard: (_) =>
Add Card
, +jest.mock('@app/Dashboard/AddCard.tsx', () => ({ + AddCard: () =>
Add Card
, })); jest.mock('@app/Dashboard/DashboardLayoutToolbar', () => ({ - DashboardLayoutToolbar: (_) =>
Dashboard Layout Toolbar
, -})); - -// Mock the local storage such that the first run config is not shown -jest.mock('@app/utils/LocalStorage', () => ({ - getFromLocalStorage: jest.fn(() => { - return { - _version: '0', - }; - }), + DashboardLayoutToolbar: () =>
Dashboard Layout Toolbar
, })); jest.spyOn(defaultServices.target, 'target').mockReturnValue(of(mockFooTarget)); +jest.spyOn(defaultServices.settings, 'featureLevel').mockReturnValue(of(FeatureLevel.PRODUCTION)); +jest.spyOn(defaultServices.settings, 'datetimeFormat').mockReturnValue(of(defaultDatetimeFormat)); +jest.spyOn(defaultServices.settings, 'themeSetting').mockReturnValue(of(ThemeSetting.LIGHT)); +jest.spyOn(defaultServices.settings, 'media').mockReturnValue(of()); +jest.spyOn(defaultServices.settings, 'chartControllerConfig').mockReturnValue(defaultChartControllerConfig); +jest.spyOn(defaultServices.api, 'getTargetMBeanMetrics').mockReturnValue(of({})); + +const history = createMemoryHistory({ initialEntries: ['/'] }); describe('', () => { it('renders correctly', async () => { @@ -66,7 +72,9 @@ describe('', () => { - + + + , diff --git a/src/test/Dashboard/DashboardLayoutToolbar.test.tsx b/src/test/Dashboard/DashboardLayoutToolbar.test.tsx index bf810295b..c4e125ca4 100644 --- a/src/test/Dashboard/DashboardLayoutToolbar.test.tsx +++ b/src/test/Dashboard/DashboardLayoutToolbar.test.tsx @@ -14,13 +14,12 @@ * limitations under the License. */ import { DashboardLayoutToolbar } from '@app/Dashboard/DashboardLayoutToolbar'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; import { store } from '@app/Shared/Redux/ReduxStore'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import React from 'react'; import { Provider } from 'react-redux'; import renderer, { act } from 'react-test-renderer'; -import '../Common'; jest.spyOn(defaultServices.settings, 'deletionDialogsEnabledFor').mockReturnValue(true); diff --git a/src/test/Dashboard/__snapshots__/Dashboard.test.tsx.snap b/src/test/Dashboard/__snapshots__/Dashboard.test.tsx.snap index 81c0ac209..627b4d144 100644 --- a/src/test/Dashboard/__snapshots__/Dashboard.test.tsx.snap +++ b/src/test/Dashboard/__snapshots__/Dashboard.test.tsx.snap @@ -41,8 +41,1991 @@ Array [
-
- Add Card +
+
+
+
+
+
+
+
+ Process CPU Load (last 60s, every 10s) +
+
+ +
+ +
+
+
+
+
+
+
+ + + + + + + + 12:00:00 AM + + + + + + + + + % + + + + + + + + 5.0e-11 + + + + + + + + + 1.0e-10 + + + + + + + + + 1.5e-10 + + + + + + + + + 2.0e-10 + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Heap Memory Usage (last 60s, every 10s) +
+
+ +
+ +
+
+
+
+
+
+
+ + + + + + + + 12:00:00 AM + + + + + + + + + MiB + + + + + + + + 0.20 + + + + + + + + + 0.40 + + + + + + + + + 0.60 + + + + + + + + + 0.80 + + + + + + + + + 1.0 + + + + + + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Threads (last 60s, every 10s) +
+
+ +
+ +
+
+
+
+
+
+
+ + + + + + + + 12:00:00 AM + + + + + + + + + threads + + + + + + + + 0.20 + + + + + + + + + 0.40 + + + + + + + + + 0.60 + + + + + + + + + 0.80 + + + + + + + + + 1.0 + + + + + + + + + +
+ +
+
+
+
+
+
+
+
+
', () => { }); expect(labelUploadInput.files).not.toBe(null); - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - expect(labelUploadInput.files![0]).toStrictEqual(mockMetadataFile); + expect(labelUploadInput.files?.item(0)).toStrictEqual(mockMetadataFile); expect(mockProps.setLabels).toHaveBeenCalledTimes(1); expect(mockProps.setLabels).toHaveBeenCalledWith([ diff --git a/src/test/Recordings/ActiveRecordingsTable.test.tsx b/src/test/Recordings/ActiveRecordingsTable.test.tsx index cf9475335..30e642b44 100644 --- a/src/test/Recordings/ActiveRecordingsTable.test.tsx +++ b/src/test/Recordings/ActiveRecordingsTable.test.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ import '@testing-library/jest-dom'; -import { authFailMessage } from '@app/ErrorView/ErrorView'; -import { DeleteActiveRecordings, DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; +import { authFailMessage } from '@app/ErrorView/types'; +import { DeleteActiveRecordings, DeleteOrDisableWarningType } from '@app/Modal/types'; import { ActiveRecordingsTable } from '@app/Recordings/ActiveRecordingsTable'; import { emptyActiveRecordingFilters, @@ -23,8 +23,7 @@ import { TargetRecordingFilters, } from '@app/Shared/Redux/Filters/RecordingFilterSlice'; import { RootState } from '@app/Shared/Redux/ReduxStore'; -import { ActiveRecording, RecordingState } from '@app/Shared/Services/Api.service'; -import { NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; +import { ActiveRecording, RecordingState, NotificationMessage } from '@app/Shared/Services/api.types'; import { defaultServices, Services } from '@app/Shared/Services/Services'; import { TargetService } from '@app/Shared/Services/Target.service'; import dayjs, { defaultDatetimeFormat } from '@i18n/datetime'; diff --git a/src/test/Recordings/ArchivedRecordingsTable.test.tsx b/src/test/Recordings/ArchivedRecordingsTable.test.tsx index 3f2ed83d3..58a50c434 100644 --- a/src/test/Recordings/ArchivedRecordingsTable.test.tsx +++ b/src/test/Recordings/ArchivedRecordingsTable.test.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DeleteArchivedRecordings, DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; +import { DeleteArchivedRecordings, DeleteOrDisableWarningType } from '@app/Modal/types'; import { ArchivedRecordingsTable } from '@app/Recordings/ArchivedRecordingsTable'; import { emptyActiveRecordingFilters, @@ -21,8 +21,7 @@ import { TargetRecordingFilters, } from '@app/Shared/Redux/Filters/RecordingFilterSlice'; import { RootState } from '@app/Shared/Redux/ReduxStore'; -import { ArchivedRecording, UPLOADS_SUBDIRECTORY } from '@app/Shared/Services/Api.service'; -import { NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; +import { UPLOADS_SUBDIRECTORY, ArchivedRecording, NotificationMessage } from '@app/Shared/Services/api.types'; import { defaultServices } from '@app/Shared/Services/Services'; import { Text } from '@patternfly/react-core'; import '@testing-library/jest-dom'; diff --git a/src/test/Recordings/Filters/DurationFilter.test.tsx b/src/test/Recordings/Filters/DurationFilter.test.tsx index a288075ae..4465dd63c 100644 --- a/src/test/Recordings/Filters/DurationFilter.test.tsx +++ b/src/test/Recordings/Filters/DurationFilter.test.tsx @@ -15,7 +15,7 @@ */ import { DurationFilter } from '@app/Recordings/Filters/DurationFilter'; -import { ActiveRecording, RecordingState } from '@app/Shared/Services/Api.service'; +import { ActiveRecording, RecordingState } from '@app/Shared/Services/api.types'; import { cleanup, screen } from '@testing-library/react'; import React from 'react'; import renderer, { act } from 'react-test-renderer'; diff --git a/src/test/Recordings/Filters/LabelFilter.test.tsx b/src/test/Recordings/Filters/LabelFilter.test.tsx index 5e7d7e8a2..7bb296dd3 100644 --- a/src/test/Recordings/Filters/LabelFilter.test.tsx +++ b/src/test/Recordings/Filters/LabelFilter.test.tsx @@ -15,7 +15,7 @@ */ import { LabelFilter } from '@app/Recordings/Filters/LabelFilter'; -import { ActiveRecording, RecordingState } from '@app/Shared/Services/Api.service'; +import { ActiveRecording, RecordingState } from '@app/Shared/Services/api.types'; import { cleanup, screen, within } from '@testing-library/react'; import React from 'react'; import renderer, { act } from 'react-test-renderer'; diff --git a/src/test/Recordings/Filters/NameFilter.test.tsx b/src/test/Recordings/Filters/NameFilter.test.tsx index a1036e10a..46aa56ea7 100644 --- a/src/test/Recordings/Filters/NameFilter.test.tsx +++ b/src/test/Recordings/Filters/NameFilter.test.tsx @@ -15,7 +15,7 @@ */ import { NameFilter } from '@app/Recordings/Filters/NameFilter'; -import { ActiveRecording, RecordingState } from '@app/Shared/Services/Api.service'; +import { ActiveRecording, RecordingState } from '@app/Shared/Services/api.types'; import { cleanup, screen, within } from '@testing-library/react'; import React from 'react'; import renderer, { act } from 'react-test-renderer'; diff --git a/src/test/Recordings/Filters/RecordingStateFilter.test.tsx b/src/test/Recordings/Filters/RecordingStateFilter.test.tsx index 667a4b918..45874139f 100644 --- a/src/test/Recordings/Filters/RecordingStateFilter.test.tsx +++ b/src/test/Recordings/Filters/RecordingStateFilter.test.tsx @@ -15,7 +15,7 @@ */ import { RecordingStateFilter } from '@app/Recordings/Filters/RecordingStateFilter'; -import { ActiveRecording, RecordingState } from '@app/Shared/Services/Api.service'; +import { ActiveRecording, RecordingState } from '@app/Shared/Services/api.types'; import { cleanup, screen, within } from '@testing-library/react'; import React from 'react'; import renderer, { act } from 'react-test-renderer'; diff --git a/src/test/Recordings/RecordingFilters.test.tsx b/src/test/Recordings/RecordingFilters.test.tsx index 73b45c347..1b45fe303 100644 --- a/src/test/Recordings/RecordingFilters.test.tsx +++ b/src/test/Recordings/RecordingFilters.test.tsx @@ -26,9 +26,8 @@ import { TargetRecordingFilters, } from '@app/Shared/Redux/Filters/RecordingFilterSlice'; import { RootState } from '@app/Shared/Redux/ReduxStore'; -import { ActiveRecording, ArchivedRecording, RecordingState } from '@app/Shared/Services/Api.service'; +import { Target, ActiveRecording, RecordingState, ArchivedRecording } from '@app/Shared/Services/api.types'; import { defaultServices } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; import { defaultDatetimeFormat } from '@i18n/datetime'; import { Toolbar, ToolbarContent } from '@patternfly/react-core'; import { cleanup, screen, within } from '@testing-library/react'; @@ -41,8 +40,8 @@ const mockFooTarget: Target = { connectUrl: 'service:jmx:rmi://someFooUrl', alias: 'fooTarget', annotations: { - cryostat: new Map(), - platform: new Map(), + cryostat: {}, + platform: {}, }, }; diff --git a/src/test/Recordings/RecordingLabelsPanel.test.tsx b/src/test/Recordings/RecordingLabelsPanel.test.tsx index 3eeba484a..78a48b7a2 100644 --- a/src/test/Recordings/RecordingLabelsPanel.test.tsx +++ b/src/test/Recordings/RecordingLabelsPanel.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { RecordingLabelsPanel } from '@app/Recordings/RecordingLabelsPanel'; -import { ArchivedRecording } from '@app/Shared/Services/Api.service'; +import { ArchivedRecording } from '@app/Shared/Services/api.types'; import { Drawer, DrawerContent } from '@patternfly/react-core'; import { screen } from '@testing-library/react'; import '@testing-library/jest-dom'; diff --git a/src/test/Recordings/Recordings.test.tsx b/src/test/Recordings/Recordings.test.tsx index a7cb4ba17..e048520bc 100644 --- a/src/test/Recordings/Recordings.test.tsx +++ b/src/test/Recordings/Recordings.test.tsx @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; import { Recordings } from '@app/Recordings/Recordings'; +import { Target } from '@app/Shared/Services/api.types'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; import { cleanup, screen, within } from '@testing-library/react'; import '@testing-library/jest-dom'; import { createMemoryHistory } from 'history'; diff --git a/src/test/Rules/CreateRule.test.tsx b/src/test/Rules/CreateRule.test.tsx index 44dae1fd9..85084fa57 100644 --- a/src/test/Rules/CreateRule.test.tsx +++ b/src/test/Rules/CreateRule.test.tsx @@ -13,11 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { EventTemplate } from '@app/CreateRecording/CreateRecording'; + import { CreateRule } from '@app/Rules/CreateRule'; -import { Rule } from '@app/Rules/Rules'; +import { Target, EventTemplate, Rule } from '@app/Shared/Services/api.types'; import { defaultServices } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; import '@testing-library/jest-dom'; import { cleanup, screen, waitFor } from '@testing-library/react'; import { createMemoryHistory } from 'history'; @@ -25,7 +24,7 @@ import * as React from 'react'; import { of, throwError } from 'rxjs'; import { renderWithServiceContextAndRouter } from '../Common'; -jest.mock('@app/Shared/MatchExpression/MatchExpressionVisualizer', () => ({ +jest.mock('@app/Shared/Components/MatchExpression/MatchExpressionVisualizer', () => ({ MatchExpressionVisualizer: () => <>Match Expression Visualizer, })); @@ -38,7 +37,7 @@ const mockTarget: Target = { connectUrl: mockConnectUrl, alias: 'io.cryostat.Cryostat', annotations: { - cryostat: { PORT: 9091 }, + cryostat: { PORT: '9091' }, platform: {}, }, }; diff --git a/src/test/Rules/Rules.test.tsx b/src/test/Rules/Rules.test.tsx index e38b6803e..111adda3a 100644 --- a/src/test/Rules/Rules.test.tsx +++ b/src/test/Rules/Rules.test.tsx @@ -13,14 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DeleteAutomatedRules, DeleteOrDisableWarningType, DisableAutomatedRules } from '@app/Modal/DeleteWarningUtils'; -import { NotificationsContext, NotificationsInstance } from '@app/Notifications/Notifications'; -import { Rules, Rule } from '@app/Rules/Rules'; -import { - NotificationCategory, - NotificationChannel, - NotificationMessage, -} from '@app/Shared/Services/NotificationChannel.service'; +import { DeleteAutomatedRules, DeleteOrDisableWarningType, DisableAutomatedRules } from '@app/Modal/types'; +import { RulesTable } from '@app/Rules/Rules'; +import { Rule, NotificationMessage, NotificationCategory } from '@app/Shared/Services/api.types'; +import { NotificationChannel } from '@app/Shared/Services/NotificationChannel.service'; +import { NotificationsContext, NotificationsInstance } from '@app/Shared/Services/Notifications.service'; import { ServiceContext, defaultServices, Services } from '@app/Shared/Services/Services'; import '@testing-library/jest-dom'; import { act as doAct, cleanup, screen, within } from '@testing-library/react'; @@ -120,7 +117,7 @@ describe('', () => { - + , @@ -130,7 +127,7 @@ describe('', () => { }); it('opens create rule view when Create is clicked', async () => { - const { user } = renderWithServiceContextAndRouter(, { history: history }); + const { user } = renderWithServiceContextAndRouter(, { history: history }); await user.click(screen.getByRole('button', { name: /Create/ })); @@ -138,7 +135,7 @@ describe('', () => { }); it('opens upload modal when upload icon is clicked', async () => { - const { user } = renderWithServiceContextAndRouter(, { history: history }); + const { user } = renderWithServiceContextAndRouter(, { history: history }); await user.click(screen.getByRole('button', { name: 'Upload' })); @@ -156,7 +153,7 @@ describe('', () => { }); it('shows a popup when Delete is clicked and then deletes the Rule after clicking confirmation Delete', async () => { - const { user } = renderWithServiceContextAndRouter(, { history: history }); + const { user } = renderWithServiceContextAndRouter(, { history: history }); const deleteRequestSpy = jest.spyOn(defaultServices.api, 'deleteRule').mockReturnValue(of(true)); const dialogWarningSpy = jest.spyOn(defaultServices.settings, 'setDeletionDialogsEnabledFor'); @@ -176,7 +173,7 @@ describe('', () => { }); it('deletes a rule when Delete is clicked w/o popup warning', async () => { - const { user } = renderWithServiceContextAndRouter(, { history: history }); + const { user } = renderWithServiceContextAndRouter(, { history: history }); const deleteRequestSpy = jest.spyOn(defaultServices.api, 'deleteRule').mockReturnValue(of(true)); @@ -189,7 +186,7 @@ describe('', () => { }); it('remove a rule when receiving a notification', async () => { - renderWithServiceContextAndRouter(, { history: history }); + renderWithServiceContextAndRouter(, { history: history }); expect(screen.queryByText(mockRule.name)).not.toBeInTheDocument(); }); @@ -203,7 +200,7 @@ describe('', () => { ...defaultServices, notificationChannel: mockNotifications, }; - const { container } = renderWithServiceContextAndRouter(, { history: history, services: services }); + const { container } = renderWithServiceContextAndRouter(, { history: history, services: services }); expect(await screen.findByText(mockRule.name)).toBeInTheDocument(); @@ -223,7 +220,7 @@ describe('', () => { }); it('downloads a rule when Download is clicked', async () => { - const { user } = renderWithServiceContextAndRouter(, { history: history }); + const { user } = renderWithServiceContextAndRouter(, { history: history }); await user.click(screen.getByLabelText('Actions')); await user.click(await screen.findByText('Download')); @@ -233,7 +230,7 @@ describe('', () => { }); it('updates a rule when the switch is clicked', async () => { - const { user } = renderWithServiceContextAndRouter(, { history: history }); + const { user } = renderWithServiceContextAndRouter(, { history: history }); await user.click(screen.getByRole('checkbox', { name: `${mockRule.name} is enabled` })); expect(updateSpy).toHaveBeenCalledTimes(1); @@ -242,7 +239,7 @@ describe('', () => { it('shows a popup when toggle disables rule and then disable the Rule after clicking confirmation Disable', async () => { const updateSpy = jest.spyOn(defaultServices.api, 'updateRule').mockReturnValue(of(true)); - const { user } = renderWithServiceContextAndRouter(, { history: history }); + const { user } = renderWithServiceContextAndRouter(, { history: history }); await user.click(screen.getByRole('checkbox', { name: `${mockRule.name} is enabled` })); @@ -256,7 +253,7 @@ describe('', () => { }); it('upload a rule file when Submit is clicked', async () => { - const { user } = renderWithServiceContextAndRouter(, { history: history }); + const { user } = renderWithServiceContextAndRouter(, { history: history }); await user.click(screen.getByRole('button', { name: 'Upload' })); diff --git a/src/test/SecurityPanel/Credentials/StoreCredentials.test.tsx b/src/test/SecurityPanel/Credentials/StoreCredentials.test.tsx index ea2642d50..98f050216 100644 --- a/src/test/SecurityPanel/Credentials/StoreCredentials.test.tsx +++ b/src/test/SecurityPanel/Credentials/StoreCredentials.test.tsx @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DeleteCredentials, DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; +import { DeleteCredentials, DeleteOrDisableWarningType } from '@app/Modal/types'; import { CreateCredentialModalProps } from '@app/SecurityPanel/Credentials/CreateCredentialModal'; import { StoreCredentials } from '@app/SecurityPanel/Credentials/StoreCredentials'; -import { MatchedCredential, StoredCredential } from '@app/Shared/Services/Api.service'; -import { NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; +import { StoredCredential, Target, MatchedCredential, NotificationMessage } from '@app/Shared/Services/api.types'; import { defaultServices } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; import { Modal, ModalVariant } from '@patternfly/react-core'; import { cleanup, screen, within } from '@testing-library/react'; import * as React from 'react'; diff --git a/src/test/Settings/AutoRefresh.test.tsx b/src/test/Settings/AutoRefresh.test.tsx index 85db2c566..a6f33765f 100644 --- a/src/test/Settings/AutoRefresh.test.tsx +++ b/src/test/Settings/AutoRefresh.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { AutoRefresh } from '@app/Settings/AutoRefresh'; +import { AutoRefresh } from '@app/Settings/Config/AutoRefresh'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import { cleanup, screen } from '@testing-library/react'; import * as React from 'react'; diff --git a/src/test/Settings/AutomatedAnalysisConfig.test.tsx b/src/test/Settings/AutomatedAnalysisConfig.test.tsx index 0a44e386f..1157d6259 100644 --- a/src/test/Settings/AutomatedAnalysisConfig.test.tsx +++ b/src/test/Settings/AutomatedAnalysisConfig.test.tsx @@ -15,8 +15,8 @@ */ /* eslint @typescript-eslint/no-explicit-any: 0 */ -import { AutomatedAnalysisConfig } from '@app/Settings/AutomatedAnalysisConfig'; -import { defaultAutomatedAnalysisRecordingConfig } from '@app/Shared/Services/Api.service'; +import { AutomatedAnalysis } from '@app/Settings/Config/AutomatedAnalysis'; +import { defaultAutomatedAnalysisRecordingConfig } from '@app/Shared/Services/service.types'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import * as React from 'react'; import renderer, { act } from 'react-test-renderer'; @@ -25,7 +25,7 @@ jest.mock('@app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm', () => AutomatedAnalysisConfigForm: (_: any) => <>Automated Analysis Configuration Form, })); -jest.mock('@app/Shared/TargetSelect', () => ({ +jest.mock('@app/TargetView/TargetSelect', () => ({ TargetSelect: (_: any) => <>Target Select, })); @@ -39,7 +39,7 @@ describe('', () => { await act(async () => { tree = renderer.create( - {React.createElement(AutomatedAnalysisConfig.content, null)} + {React.createElement(AutomatedAnalysis.content, null)} , ); }); diff --git a/src/test/Settings/CredentialsStorage.test.tsx b/src/test/Settings/CredentialsStorage.test.tsx index e9c55e103..b70642101 100644 --- a/src/test/Settings/CredentialsStorage.test.tsx +++ b/src/test/Settings/CredentialsStorage.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ /* eslint @typescript-eslint/no-explicit-any: 0 */ -import { CredentialsStorage, Locations } from '@app/Settings/CredentialsStorage'; +import { CredentialsStorage, Locations } from '@app/Settings/Config/CredentialsStorage'; import { getFromLocalStorage, saveToLocalStorage } from '@app/utils/LocalStorage'; import { cleanup, screen, waitFor, within } from '@testing-library/react'; import * as React from 'react'; diff --git a/src/test/Settings/DatetimeControl.test.tsx b/src/test/Settings/DatetimeControl.test.tsx index c1a66dd57..f3821d0f2 100644 --- a/src/test/Settings/DatetimeControl.test.tsx +++ b/src/test/Settings/DatetimeControl.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { DatetimeControl } from '@app/Settings/DatetimeControl'; +import { DatetimeControl } from '@app/Settings/Config/DatetimeControl'; import { defaultServices } from '@app/Shared/Services/Services'; import { defaultDatetimeFormat, locales } from '@i18n/datetime'; import { act, cleanup, screen, within } from '@testing-library/react'; diff --git a/src/test/Settings/DeletionDialogControl.test.tsx b/src/test/Settings/DeletionDialogControl.test.tsx index 2aac51564..cd71deee4 100644 --- a/src/test/Settings/DeletionDialogControl.test.tsx +++ b/src/test/Settings/DeletionDialogControl.test.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; -import { DeletionDialogControl } from '@app/Settings/DeletionDialogControl'; +import { DeleteOrDisableWarningType } from '@app/Modal/types'; +import { DeletionDialogControl } from '@app/Settings/Config/DeletionDialogControl'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import { cleanup, screen } from '@testing-library/react'; import * as React from 'react'; diff --git a/src/test/Settings/FeatureLevels.test.tsx b/src/test/Settings/FeatureLevels.test.tsx index d4f221c72..42853f644 100644 --- a/src/test/Settings/FeatureLevels.test.tsx +++ b/src/test/Settings/FeatureLevels.test.tsx @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FeatureLevels } from '@app/Settings/FeatureLevels'; +import { FeatureLevels } from '@app/Settings/Config/FeatureLevels'; +import { FeatureLevel } from '@app/Shared/Services/service.types'; import { defaultServices } from '@app/Shared/Services/Services'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; import { cleanup, screen, act } from '@testing-library/react'; import * as React from 'react'; import { of } from 'rxjs'; diff --git a/src/test/Settings/Language.test.tsx b/src/test/Settings/Language.test.tsx index 0eebd3990..95616cbb2 100644 --- a/src/test/Settings/Language.test.tsx +++ b/src/test/Settings/Language.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Language } from '@app/Settings/Language'; +import { Language } from '@app/Settings/Config/Language'; import i18next, { i18nLanguages } from '@i18n/config'; import { localeReadable } from '@i18n/i18nextUtil'; import { act, cleanup, screen, within } from '@testing-library/react'; diff --git a/src/test/Settings/NotificationControl.test.tsx b/src/test/Settings/NotificationControl.test.tsx index a776d46ca..8874484fc 100644 --- a/src/test/Settings/NotificationControl.test.tsx +++ b/src/test/Settings/NotificationControl.test.tsx @@ -13,8 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotificationControl } from '@app/Settings/NotificationControl'; -import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { NotificationControl } from '@app/Settings/Config/NotificationControl'; +import { NotificationCategory } from '@app/Shared/Services/api.types'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import { act as doAct, cleanup, screen } from '@testing-library/react'; import * as React from 'react'; diff --git a/src/test/Settings/Settings.test.tsx b/src/test/Settings/Settings.test.tsx index d878ef014..dabec3c8b 100644 --- a/src/test/Settings/Settings.test.tsx +++ b/src/test/Settings/Settings.test.tsx @@ -14,24 +14,20 @@ * limitations under the License. */ -// Must import before @app/Settings/Settings (circular deps) -/* eslint import/order: 0*/ -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; import { Settings } from '@app/Settings/Settings'; +import { UserSetting } from '@app/Settings/types'; +import { FeatureLevel, SessionState } from '@app/Shared/Services/service.types'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import { Text } from '@patternfly/react-core'; -import '@testing-library/jest-dom'; import { cleanup, screen } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; import * as React from 'react'; +import { Router } from 'react-router-dom'; import renderer, { act } from 'react-test-renderer'; import { of } from 'rxjs'; import { renderWithServiceContextAndRouter, testT } from '../Common'; -import { createMemoryHistory } from 'history'; -import { Router } from 'react-router-dom'; -import { UserSetting } from '@app/Settings/SettingsUtils'; -import { SessionState } from '@app/Shared/Services/Login.service'; -jest.mock('@app/Settings/NotificationControl', () => ({ +jest.mock('@app/Settings/Config/NotificationControl', () => ({ NotificationControl: { titleKey: 'SETTINGS.NOTIFICATION_CONTROL.TITLE', descConstruct: 'SETTINGS.NOTIFICATION_CONTROL.DESCRIPTION', @@ -41,8 +37,8 @@ jest.mock('@app/Settings/NotificationControl', () => ({ } as UserSetting, })); -jest.mock('@app/Settings/AutomatedAnalysisConfig', () => ({ - AutomatedAnalysisConfig: { +jest.mock('@app/Settings/Config/AutomatedAnalysis', () => ({ + AutomatedAnalysis: { titleKey: 'SETTINGS.AUTOMATED_ANALYSIS_CONFIG.TITLE', descConstruct: 'SETTINGS.AUTOMATED_ANALYSIS_CONFIG.DESCRIPTION', category: 'SETTINGS.CATEGORIES.DASHBOARD', @@ -51,8 +47,8 @@ jest.mock('@app/Settings/AutomatedAnalysisConfig', () => ({ } as UserSetting, })); -jest.mock('@app/Settings/ChartCardsConfig', () => ({ - ChartCardsConfig: { +jest.mock('@app/Settings/Config/ChartCards', () => ({ + ChartCards: { titleKey: 'SETTINGS.CHARTS_CONFIG.TITLE', descConstruct: 'SETTINGS.CHARTS_CONFIG.DESCRIPTION', category: 'SETTINGS.CATEGORIES.DASHBOARD', @@ -60,7 +56,7 @@ jest.mock('@app/Settings/ChartCardsConfig', () => ({ } as UserSetting, })); -jest.mock('@app/Settings/CredentialsStorage', () => ({ +jest.mock('@app/Settings/Config/CredentialsStorage', () => ({ CredentialsStorage: { titleKey: 'SETTINGS.CREDENTIALS_STORAGE.TITLE', descConstruct: { @@ -72,7 +68,7 @@ jest.mock('@app/Settings/CredentialsStorage', () => ({ } as UserSetting, })); -jest.mock('@app/Settings/DeletionDialogControl', () => ({ +jest.mock('@app/Settings/Config/DeletionDialogControl', () => ({ DeletionDialogControl: { titleKey: 'SETTINGS.DELETION_DIALOG_CONTROL.TITLE', descConstruct: 'SETTINGS.DELETION_DIALOG_CONTROL.DESCRIPTION', @@ -81,7 +77,7 @@ jest.mock('@app/Settings/DeletionDialogControl', () => ({ } as UserSetting, })); -jest.mock('@app/Settings/WebSocketDebounce', () => ({ +jest.mock('@app/Settings/Config/WebSocketDebounce', () => ({ WebSocketDebounce: { titleKey: 'SETTINGS.WEBSOCKET_CONNECTION_DEBOUNCE.TITLE', descConstruct: 'SETTINGS.WEBSOCKET_CONNECTION_DEBOUNCE.DESCRIPTION', @@ -90,7 +86,7 @@ jest.mock('@app/Settings/WebSocketDebounce', () => ({ } as UserSetting, })); -jest.mock('@app/Settings/AutoRefresh', () => ({ +jest.mock('@app/Settings/Config/AutoRefresh', () => ({ AutoRefresh: { titleKey: 'SETTINGS.AUTO_REFRESH.TITLE', descConstruct: 'SETTINGS.AUTO_REFRESH.DESCRIPTION', @@ -99,7 +95,7 @@ jest.mock('@app/Settings/AutoRefresh', () => ({ } as UserSetting, })); -jest.mock('@app/Settings/FeatureLevels', () => ({ +jest.mock('@app/Settings/Config/FeatureLevels', () => ({ FeatureLevels: { titleKey: 'SETTINGS.FEATURE_LEVEL.TITLE', descConstruct: 'SETTINGS.FEATURE_LEVEL.DESCRIPTION', @@ -108,33 +104,31 @@ jest.mock('@app/Settings/FeatureLevels', () => ({ } as UserSetting, })); -jest.mock('@app/Settings/Language', () => ({ +jest.mock('@app/Settings/Config/Language', () => ({ Language: { titleKey: 'SETTINGS.LANGUAGE.TITLE', descConstruct: 'SETTINGS.LANGUAGE.DESCRIPTION', category: 'SETTINGS.CATEGORIES.GENERAL', - featureLevel: FeatureLevel.BETA, + featureLevel: 1, orderInGroup: 1, content: () => Language Component, } as UserSetting, })); -jest.mock('@app/Settings/DatetimeControl', () => ({ +jest.mock('@app/Settings/Config/DatetimeControl', () => ({ DatetimeControl: { titleKey: 'SETTINGS.DATETIME_CONTROL.TITLE', descConstruct: 'SETTINGS.DATETIME_CONTROL.DESCRIPTION', category: 'SETTINGS.CATEGORIES.GENERAL', - featureLevel: FeatureLevel.PRODUCTION, content: () => DatetimeControl Component, } as UserSetting, })); -jest.mock('@app/Settings/Theme', () => ({ +jest.mock('@app/Settings/Config/Theme', () => ({ Theme: { titleKey: 'SETTINGS.THEME.TITLE', descConstruct: 'SETTINGS.THEME.DESCRIPTION', category: 'SETTINGS.CATEGORIES.GENERAL', - featureLevel: FeatureLevel.PRODUCTION, content: () => Theme Component, } as UserSetting, })); diff --git a/src/test/Settings/Theme.test.tsx b/src/test/Settings/Theme.test.tsx index c3805cf56..a66c8eb36 100644 --- a/src/test/Settings/Theme.test.tsx +++ b/src/test/Settings/Theme.test.tsx @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ThemeSetting } from '@app/Settings/SettingsUtils'; -import { Theme } from '@app/Settings/Theme'; + +import { Theme } from '@app/Settings/Config/Theme'; +import { ThemeSetting } from '@app/Settings/types'; import { defaultServices } from '@app/Shared/Services/Services'; import { cleanup, screen, act, within } from '@testing-library/react'; import * as React from 'react'; diff --git a/src/test/Settings/WebSocketDebounce.test.tsx b/src/test/Settings/WebSocketDebounce.test.tsx index c72697244..b9d81c9d9 100644 --- a/src/test/Settings/WebSocketDebounce.test.tsx +++ b/src/test/Settings/WebSocketDebounce.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { WebSocketDebounce } from '@app/Settings/WebSocketDebounce'; +import { WebSocketDebounce } from '@app/Settings/Config/WebSocketDebounce'; import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; import { cleanup, screen } from '@testing-library/react'; import * as React from 'react'; diff --git a/src/test/LoadingView/LoadingView.test.tsx b/src/test/Shared/Components/LoadingView.test.tsx similarity index 95% rename from src/test/LoadingView/LoadingView.test.tsx rename to src/test/Shared/Components/LoadingView.test.tsx index 60c4d98ee..c8b6af7a2 100644 --- a/src/test/LoadingView/LoadingView.test.tsx +++ b/src/test/Shared/Components/LoadingView.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { LoadingView } from '@app/LoadingView/LoadingView'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; import { cleanup, render, screen } from '@testing-library/react'; import * as React from 'react'; import renderer, { act } from 'react-test-renderer'; diff --git a/src/test/LoadingView/__snapshots__/LoadingView.test.tsx.snap b/src/test/Shared/Components/__snapshots__/LoadingView.test.tsx.snap similarity index 100% rename from src/test/LoadingView/__snapshots__/LoadingView.test.tsx.snap rename to src/test/Shared/Components/__snapshots__/LoadingView.test.tsx.snap diff --git a/src/test/Shared/Services/Login.service.test.tsx b/src/test/Shared/Services/Login.service.test.tsx index f571c9d54..718b9d883 100644 --- a/src/test/Shared/Services/Login.service.test.tsx +++ b/src/test/Shared/Services/Login.service.test.tsx @@ -14,9 +14,10 @@ * limitations under the License. */ -import { ApiV2Response } from '@app/Shared/Services/Api.service'; +import { ApiV2Response } from '@app/Shared/Services/api.types'; import { AuthCredentials } from '@app/Shared/Services/AuthCredentials.service'; -import { AuthMethod, LoginService, SessionState } from '@app/Shared/Services/Login.service'; +import { LoginService } from '@app/Shared/Services/Login.service'; +import { AuthMethod, SessionState } from '@app/Shared/Services/service.types'; import { SettingsService } from '@app/Shared/Services/Settings.service'; import { TargetService } from '@app/Shared/Services/Target.service'; import { firstValueFrom, of, timeout } from 'rxjs'; diff --git a/src/test/Shared/TargetSelect.test.tsx b/src/test/TargetView/TargetSelect.test.tsx similarity index 97% rename from src/test/Shared/TargetSelect.test.tsx rename to src/test/TargetView/TargetSelect.test.tsx index d5f128782..86d1711e2 100644 --- a/src/test/Shared/TargetSelect.test.tsx +++ b/src/test/TargetView/TargetSelect.test.tsx @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Target } from '@app/Shared/Services/api.types'; import { defaultServices } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; -import { TargetSelect } from '@app/Shared/TargetSelect'; +import { TargetSelect } from '@app/TargetView/TargetSelect'; import '@testing-library/jest-dom'; import { cleanup, screen } from '@testing-library/react'; import * as React from 'react'; diff --git a/test-setup.js b/test-setup.js index 7027dfb47..a58055ebc 100644 --- a/test-setup.js +++ b/test-setup.js @@ -1,5 +1,6 @@ // Mock out the services shared across the app in order to help isolate // components from the ServiceContext +import '@i18n/config'; jest.mock('@app/Shared/Services/Api.service'); jest.mock('@app/Shared/Services/Login.service');