From 5db0be0562b70e712371107c6a86238edd04909e Mon Sep 17 00:00:00 2001 From: Matej Kubinec <32638572+matejkubinec@users.noreply.github.com> Date: Wed, 22 Nov 2023 12:05:27 +0100 Subject: [PATCH 1/4] PMM-12709 Set default autodiscovery limit to 10 (#699) --- .../FormParts/AdditionalOptions/AdditionalOptions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.tsx index 865b0865e8fa6..82ecdc8a1cf63 100644 --- a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.tsx +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.tsx @@ -48,7 +48,7 @@ export const PostgreSQLAdditionalOptions: FC = const validators = [platformCoreValidators.containsNumber, ...platformCoreValidators.int32]; const getAutoDiscoveryLimitValue = (type: AutoDiscoveryOptionsInterface) => - type === AutoDiscoveryOptionsInterface.enabled ? 0 : type === AutoDiscoveryOptionsInterface.disabled ? -1 : 10; + type === AutoDiscoveryOptionsInterface.disabled ? -1 : 10; useEffect(() => { setSelectedValue(selectedOption); From 34b70c3addb9bafd629e040220f34827bb09846b Mon Sep 17 00:00:00 2001 From: Dora <103416234+doracretu3pillar@users.noreply.github.com> Date: Thu, 23 Nov 2023 13:01:25 +0200 Subject: [PATCH 2/4] PMM-12459: PMM Dump Integration - Phase1 (#694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PMM Export page - draft * PMM Dump table and mocked date * Fixes after review + TimeRange * Download, Delete and SendToSupport items created * Send to Support without API call * Removed download, added delete partial functionality * PMM-12504 add export dataset page * PMM-12504 link to export dataset page * PMM-12504 fix overflow * Show the nodes names instead of nodes ids * Update .betterer.results * PMM-12507 add pmm dump logs modal * PMM-12504 fix timepicker * PMM-12504 fix type * PMM-12504 fix types * PMM-12504 add export dump services * PMM-12507 Move logs button * Removed mocked methods * Links delete to an api * Fixed Breadcrumb * PMM-12459 add multiselect * PMM-12504 use service name for dump * Pull api once at 5 seconds * Use Service Names instead of Node Ids * Send to Support call to API * PMM-12504 add alert for invalid date range * Refactor after review * PMM-12504 Fix according to review * PMM-12504 add getlogs action * Changed Status names * PMM-12459 Add pmm dump into PerconaNavigation * Show error/successful messages for ftp upload * Use withAppEvents in order to show the successMessage * Disable notifications coming from api * Updated logs api request * Changed the FTP parameters * Added a new input, directory ,to ftp upload * PMM-12504 move labels to Messages * Update according to review * Added download functionality * PMM-12459 Update default time range * Download file fixed * PMM-12459 fix logs viewer * Disable download for non successful created dumps * Reused link constant * Fixed typo * PMM-12459 update labels * Make directory optional * Disable delete button when downloading * PMM-12459 Send empty array for all services * added isDeleting flag and increased the interval for downloading * PMM-12459 Fix CI --------- Co-authored-by: Yash Sartanpara Co-authored-by: Yash Sartanpara <119680679+YashSartanpara1@users.noreply.github.com> Co-authored-by: “dorac-ext” Co-authored-by: Artem Gavrilov --- .betterer.results | 127 +++++- .github/workflows/ui-tests.yml | 2 +- packages/grafana-data/src/types/icon.ts | 1 + .../src/components/Icon/cached.json | 1 + public/app/core/components/Footer/Footer.tsx | 7 + .../ChunkedLogsViewer/ChunkedLogsViewer.tsx | 23 +- .../app/percona/pmm-dump/PMMDump.messages.ts | 37 ++ .../app/percona/pmm-dump/PMMDump.service.ts | 71 ++++ public/app/percona/pmm-dump/PMMDump.tsx | 361 ++++++++++++++++++ public/app/percona/pmm-dump/PmmDump.styles.ts | 30 ++ public/app/percona/pmm-dump/PmmDump.types.ts | 114 ++++++ .../percona/pmm-dump/SendToSupportModal.tsx | 109 ++++++ .../pmm-dump/__mocks__/PmmDump.service.ts | 99 +++++ .../ExportDataset/ExportDataset.constants.ts | 3 + .../ExportDataset/ExportDataset.messages.ts | 21 + .../ExportDataset/ExportDataset.styles.ts | 114 ++++++ .../ExportDataset/ExportDataset.tsx | 215 +++++++++++ .../ExportDataset/ExportDataset.types.ts | 16 + .../PmmDumpLogsModal/PmmDumpLogsModal.tsx | 14 + .../PmmDumpLogsModal.types.ts | 10 + .../PerconaNavigation.constants.ts | 9 + .../PerconaNavigation/PerconaNavigation.tsx | 2 + .../app/percona/shared/core/reducers/index.ts | 2 + .../shared/core/reducers/pmmDump/pmmDump.ts | 128 +++++++ .../core/reducers/pmmDump/pmmDump.types.ts | 38 ++ .../core/reducers/pmmDump/pmmDump.utils.ts | 20 + public/app/percona/shared/core/selectors.ts | 1 + .../percona/shared/helpers/utils/timeRange.ts | 17 + public/app/routes/routes.tsx | 13 + 29 files changed, 1590 insertions(+), 15 deletions(-) create mode 100644 public/app/percona/pmm-dump/PMMDump.messages.ts create mode 100644 public/app/percona/pmm-dump/PMMDump.service.ts create mode 100644 public/app/percona/pmm-dump/PMMDump.tsx create mode 100644 public/app/percona/pmm-dump/PmmDump.styles.ts create mode 100644 public/app/percona/pmm-dump/PmmDump.types.ts create mode 100644 public/app/percona/pmm-dump/SendToSupportModal.tsx create mode 100644 public/app/percona/pmm-dump/__mocks__/PmmDump.service.ts create mode 100644 public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.constants.ts create mode 100644 public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.messages.ts create mode 100644 public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.styles.ts create mode 100644 public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.tsx create mode 100644 public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.types.ts create mode 100644 public/app/percona/pmm-dump/components/PmmDumpLogsModal/PmmDumpLogsModal.tsx create mode 100644 public/app/percona/pmm-dump/components/PmmDumpLogsModal/PmmDumpLogsModal.types.ts create mode 100644 public/app/percona/shared/core/reducers/pmmDump/pmmDump.ts create mode 100644 public/app/percona/shared/core/reducers/pmmDump/pmmDump.types.ts create mode 100644 public/app/percona/shared/core/reducers/pmmDump/pmmDump.utils.ts create mode 100644 public/app/percona/shared/helpers/utils/timeRange.ts diff --git a/.betterer.results b/.betterer.results index f5f1665442b18..daa6d91be16e3 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5742,6 +5742,124 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "11"], [0, 0, 0, "Do not use any type assertions.", "12"] ], + "public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.service.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"], + [0, 0, 0, "Unexpected any. Specify a different type.", "6"], + [0, 0, 0, "Unexpected any. Specify a different type.", "7"] + ], + "public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.service.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.constants.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/percona/check/components/AllChecksTab/CheckActions/CheckActions.types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], + "public/app/percona/dbaas/components/DBCluster/DBCluster.service.utils.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] + ], + "public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.utils.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] + ], + "public/app/percona/dbaas/components/DBCluster/EditDBClusterPage/hooks/useEditDBClusterFormSubmit.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], + "public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.utils.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/percona/dbaas/components/Kubernetes/ColumnRenderers/ColumnRenderers.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.utils.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] + ], + "public/app/percona/inventory/Tabs/Services/ClusterItem.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/percona/inventory/Tabs/Services/Clusters.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/percona/inventory/Tabs/Services/Clusters.utils.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/percona/pmm-dump/PMMDump.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] + ], + "public/app/percona/pmm-dump/SendToSupportModal.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/percona/settings/components/Advanced/Advanced.utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], + "public/app/percona/settings/components/Communication/Email/Email.utils.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] + ], + "public/app/percona/shared/components/Elements/ExpandableCell/ExpandableCell.types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], + "public/app/percona/shared/components/Form/FieldAdapters/Field.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/percona/shared/components/Form/FieldAdapters/FieldAdapters.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/percona/shared/components/Form/FormElement/FormElement.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/percona/shared/components/Form/MultiCheckbox/MultiCheckboxField.types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/percona/shared/core/reducers/dbaas/addDBCluster/addDBCluster.types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/percona/shared/helpers/cron/cron.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], + "public/app/percona/shared/helpers/promises.test.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/percona/shared/helpers/promises.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], + "public/app/percona/shared/helpers/testUtils.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + ], + "public/app/percona/shared/helpers/validator.types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], + "public/app/percona/shared/helpers/validators.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + ], "public/app/plugins/datasource/alertmanager/DataSource.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -6767,11 +6885,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "public/app/plugins/datasource/mssql/response_parser.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] - ], "public/app/plugins/datasource/mysql/datasource.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -7026,9 +7139,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "11"], [0, 0, 0, "Unexpected any. Specify a different type.", "12"], [0, 0, 0, "Unexpected any. Specify a different type.", "13"], - [0, 0, 0, "Unexpected any. Specify a different type.", "14"], - [0, 0, 0, "Unexpected any. Specify a different type.", "15"], - [0, 0, 0, "Unexpected any. Specify a different type.", "16"] + [0, 0, 0, "Unexpected any. Specify a different type.", "14"] ], "public/app/plugins/datasource/postgres/module.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index d5c65a85ac445..33436a37017b5 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -74,7 +74,7 @@ jobs: - name: Attaching artifacts if: ${{ always() }} - uses: percona-platform/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: ui-tests-output path: ./pmm-ui-tests/tests/output diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 72c856980eae4..1f44ee06599e5 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -4,6 +4,7 @@ export const availableIconsIndex = { github: true, gitlab: true, okta: true, + brain: true, anchor: true, 'angle-double-down': true, 'angle-double-right': true, diff --git a/packages/grafana-ui/src/components/Icon/cached.json b/packages/grafana-ui/src/components/Icon/cached.json index 1fbba13594700..41f171cad717f 100644 --- a/packages/grafana-ui/src/components/Icon/cached.json +++ b/packages/grafana-ui/src/components/Icon/cached.json @@ -22,6 +22,7 @@ "unicons/book", "unicons/book-open", "unicons/brackets-curly", + "unicons/brain", "unicons/bug", "unicons/building", "unicons/calculator-alt", diff --git a/public/app/core/components/Footer/Footer.tsx b/public/app/core/components/Footer/Footer.tsx index c639c67010bfd..faf2b03ef3696 100644 --- a/public/app/core/components/Footer/Footer.tsx +++ b/public/app/core/components/Footer/Footer.tsx @@ -14,6 +14,13 @@ export interface FooterLink { export let getFooterLinks = (): FooterLink[] => { return [ + { + id: 'pmm-dump', + text: 'PMM Dump', + icon: 'brain', + url: '/graph/pmm-dump', + target: '_self', + }, { id: 'pmm-logs', text: 'PMM Logs', diff --git a/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.tsx b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.tsx index 226d1f6a55561..e0fd03d73d586 100644 --- a/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.tsx +++ b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useEffect, useState } from 'react'; +import React, { FC, useCallback, useEffect, useState, useRef } from 'react'; import { ClipboardButton, useStyles } from '@grafana/ui'; import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; @@ -19,6 +19,10 @@ export const ChunkedLogsViewer: FC = ({ getLogChunks }) const [triggerTimeout, , stopTimeout] = useRecurringCall(); const [generateToken] = useCancelToken(); const styles = useStyles(getStyles); + const logsRef = useRef(logs); + logsRef.current = logs; + const lastLogRef = useRef(lastLog); + lastLogRef.current = lastLog; const formatLogs = useCallback( () => logs.map((log) => log.data).reduce((acc, message) => `${acc}${acc.length ? '\n' : ''}${message}`, ''), @@ -27,13 +31,15 @@ export const ChunkedLogsViewer: FC = ({ getLogChunks }) const refreshCurrentLogs = async () => { try { - const { logs: newLogs = [], end } = await getLogChunks(logs[0]?.id || 0, LIMIT, generateToken(LOGS_CANCEL_TOKEN)); - - if (end && !lastLog) { + const { logs: newLogs = [], end } = await getLogChunks( + logsRef.current[logsRef.current.length - 1]?.id || 0, + LIMIT, + generateToken(LOGS_CANCEL_TOKEN) + ); + if (end && lastLogRef.current) { stopTimeout(); } - - setLogs(newLogs); + setLogs([...logsRef.current, ...newLogs]); setLastLog(!!end); } catch (e) { if (isApiCancelError(e)) { @@ -48,6 +54,11 @@ export const ChunkedLogsViewer: FC = ({ getLogChunks }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + return stopTimeout(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastLog]); + return ( <> diff --git a/public/app/percona/pmm-dump/PMMDump.messages.ts b/public/app/percona/pmm-dump/PMMDump.messages.ts new file mode 100644 index 0000000000000..7dda509a7fdf4 --- /dev/null +++ b/public/app/percona/pmm-dump/PMMDump.messages.ts @@ -0,0 +1,37 @@ +export const Messages = { + dumps: { + columns: { + id: 'Id', + status: 'Status', + created: 'Created', + startDate: 'Start Date', + endDate: 'End Date', + timeRange: 'Time Range', + startTime: 'Start Time', + endTime: 'End Time', + serviceNames: 'Service Names', + }, + actions: { + download: 'Download', + sendToSupport: 'Send to Support', + delete: 'Delete', + viewLogs: 'View logs', + deleteDumpMessage: 'Are you sure you want to delete this PMM dump?', + deleteMultipleDumpsMessage: 'Are you sure you want to delete these PMM dumps?', + selectServices: 'Select multiple datasets to bulk edit them.', + addressRequired: 'Address is required.', + nameRequired: 'Name is required.', + passwordRequired: 'Password is required.', + directoryRequired: 'Directory is required.', + addressPlaceholder: 'sftp.percona.com', + savingButton: 'Saving...', + sendButton: 'Send', + cancelButton: 'Cancel', + }, + emptyTable: 'No dumps available', + createDataset: 'Create dataset', + }, + dumpLogs: { + getLogsTitle: (name: string) => `Logs for ${name}`, + }, +}; diff --git a/public/app/percona/pmm-dump/PMMDump.service.ts b/public/app/percona/pmm-dump/PMMDump.service.ts new file mode 100644 index 0000000000000..9d3f2faa9013a --- /dev/null +++ b/public/app/percona/pmm-dump/PMMDump.service.ts @@ -0,0 +1,71 @@ +import { CancelToken } from 'axios'; + +import { PmmDump, ExportDatasetProps } from 'app/percona/shared/core/reducers/pmmDump/pmmDump.types'; +import { api } from 'app/percona/shared/helpers/api'; + +import { + DumpLogs, + DumpLogResponse, + SendToSupportRequestBody, + DeleteDump, + PmmDumpResponse, + ExportResponse, +} from './PmmDump.types'; + +const BASE_URL = '/v1/management/dump/Dumps'; +const link = document.createElement('a'); + +const delay = (ms: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; + +export const PMMDumpService = { + async getLogs(artifactId: string, offset: number, limit: number, token?: CancelToken): Promise { + const { logs = [], end } = await api.post( + `${BASE_URL}/GetLogs`, + { + dump_id: artifactId, + offset, + limit, + }, + false, + token + ); + return { + logs: logs.map(({ chunk_id = 0, data, time }) => ({ id: chunk_id, data, time })), + end, + }; + }, + async list(): Promise { + const response = await api.post(`${BASE_URL}/List`, undefined); + return response.dumps || []; + }, + async delete(dumpIds: string[]) { + await api.post(`${BASE_URL}/Delete`, { dump_ids: dumpIds }); + }, + async downloadAll(dumpIds: string[], index = 0): Promise { + for (let i = index; i < dumpIds.length; i++) { + await this.download(dumpIds, i); + } + }, + async download(dumpIds: string[], index: number): Promise { + return new Promise(async (resolve) => { + const dumpId = dumpIds[index]; + + link.setAttribute('href', `${window.location.origin}/dump/${dumpId}.tar.gz`); + link.setAttribute('download', `${dumpId}.tar.gz`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + await delay(900); + resolve(); + }); + }, + async sendToSupport(body: SendToSupportRequestBody) { + await api.post(`${BASE_URL}/Upload`, body, true); + }, + async trigger(body: ExportDatasetProps, token?: CancelToken): Promise { + const res = await api.post(`${BASE_URL}/Start`, body, false, token); + return res.dump_id; + }, +}; diff --git a/public/app/percona/pmm-dump/PMMDump.tsx b/public/app/percona/pmm-dump/PMMDump.tsx new file mode 100644 index 0000000000000..4ac8914be3219 --- /dev/null +++ b/public/app/percona/pmm-dump/PMMDump.tsx @@ -0,0 +1,361 @@ +import { CancelToken } from 'axios'; +import React, { useMemo, useCallback, useEffect, useState } from 'react'; +import { Row } from 'react-table'; + +import { HorizontalGroup, Icon, useStyles2, Badge, BadgeColor, LinkButton, Button } from '@grafana/ui'; +import appEvents from 'app/core/app_events'; +import { Page } from 'app/core/components/Page/Page'; +import { DATA_INTERVAL } from 'app/percona/backup/components/BackupInventory/BackupInventory.constants'; +import { DetailedDate } from 'app/percona/backup/components/DetailedDate'; +import { useRecurringCall } from 'app/percona/backup/hooks/recurringCall.hook'; +import { Action } from 'app/percona/dbaas/components/MultipleActions'; +import { DumpStatus, DumpStatusColor, DumpStatusText, PMMDumpServices } from 'app/percona/pmm-dump/PmmDump.types'; +import { DetailsRow } from 'app/percona/shared/components/Elements/DetailsRow/DetailsRow'; +import { ExtendedColumn, FilterFieldTypes, Table } from 'app/percona/shared/components/Elements/Table'; +import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; +import { + deletePmmDumpAction, + downloadPmmDumpAction, + fetchPmmDumpAction, + getDumpLogsAction, +} from 'app/percona/shared/core/reducers/pmmDump/pmmDump'; +import { getDumps } from 'app/percona/shared/core/selectors'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; +import { getExpandAndActionsCol } from 'app/percona/shared/helpers/getExpandAndActionsCol'; +import { logger } from 'app/percona/shared/helpers/logger'; +import { dateDifferenceInWords } from 'app/percona/shared/helpers/utils/timeRange'; +import { useAppDispatch } from 'app/store/store'; +import { useSelector } from 'app/types'; +import { ShowConfirmModalEvent } from 'app/types/events'; + +import { Messages } from './PMMDump.messages'; +import { getStyles } from './PmmDump.styles'; +import { SendToSupportModal } from './SendToSupportModal'; +import { PmmDumpLogsModal } from './components/PmmDumpLogsModal/PmmDumpLogsModal'; +export const NEW_BACKUP_URL = '/pmm-dump/new'; + +export const PMMDump = () => { + const styles = useStyles2(getStyles); + const dispatch = useAppDispatch(); + const { dumps, isDownloading, isDeleting } = useSelector(getDumps); + const [triggerTimeout] = useRecurringCall(); + const [selectedRows, setSelectedRows] = useState>>([]); + const [selectedDumpIds, setSelectedDumpIds] = useState([]); + const [selectedDump, setSelectedDump] = useState(null); + const [isSendToSupportModalOpened, setIsSendToSupportModalOpened] = useState(false); + const [logsModalVisible, setLogsModalVisible] = useState(false); + const navModel = usePerconaNavModel('pmm-dump'); + + const loadData = useCallback(async () => { + try { + await dispatch(fetchPmmDumpAction()); + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const getLogs = useCallback( + async (startingChunk: number, offset: number, token?: CancelToken | undefined) => { + const logs = await dispatch( + getDumpLogsAction({ artifactId: selectedDump?.dumpId || '', startingChunk, offset, token }) + ).unwrap(); + return logs; + }, + [selectedDump, dispatch] + ); + + useEffect(() => { + loadData().then(() => triggerTimeout(loadData, DATA_INTERVAL)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadData]); + + const closeEditModal = (saved = false) => { + setIsSendToSupportModalOpened(false); + setSelectedDump(null); + }; + + const getActions = useCallback( + (row: Row): Action[] => [ + { + content: ( + + + {Messages.dumps.actions.download} + + ), + action: () => { + onDownload(row.original); + }, + disabled: row.original.status !== DumpStatus.DUMP_STATUS_SUCCESS || isDeleting, + }, + { + content: ( + + + {Messages.dumps.actions.sendToSupport} + + ), + action: () => { + setSelectedDumpIds([row.original.dumpId]); + setIsSendToSupportModalOpened(true); + }, + }, + { + content: ( + + + {Messages.dumps.actions.viewLogs} + + ), + action: () => { + onLogClick(row.original); + }, + }, + { + content: ( + + + {Messages.dumps.actions.delete} + + ), + action: () => { + onDelete(row.original); + }, + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [styles.actionItemTxtSpan, isDeleting] + ); + + const onDelete = (value?: PMMDumpServices) => { + if (value) { + appEvents.publish( + new ShowConfirmModalEvent({ + title: Messages.dumps.actions.delete, + text: Messages.dumps.actions.deleteDumpMessage, + yesText: Messages.dumps.actions.delete, + icon: 'trash-alt', + onConfirm: () => { + dispatch(deletePmmDumpAction([value.dumpId])); + }, + }) + ); + } else if (selectedRows.length > 0) { + appEvents.publish( + new ShowConfirmModalEvent({ + title: Messages.dumps.actions.delete, + text: Messages.dumps.actions.deleteMultipleDumpsMessage, + yesText: Messages.dumps.actions.delete, + icon: 'trash-alt', + onConfirm: () => { + const dumpIds = selectedRows.map((item) => item.original.dumpId); + dispatch(deletePmmDumpAction(dumpIds)); + }, + }) + ); + } + loadData(); + }; + + const onDownload = (value?: PMMDumpServices) => { + if (value) { + dispatch(downloadPmmDumpAction([value.dumpId])); + } else if (selectedRows.length > 0) { + const dumpIds = selectedRows.map((item) => item.original.dumpId); + dispatch(downloadPmmDumpAction(dumpIds)); + } + }; + + const columns = useMemo( + (): Array> => [ + { + Header: Messages.dumps.columns.id, + id: 'dumpId', + accessor: 'dumpId', + hidden: true, + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.dumps.columns.status, + accessor: 'status', + Cell: ({ value }: { value: DumpStatus }) => ( + + ), + type: FilterFieldTypes.DROPDOWN, + options: [ + { + label: DumpStatusText[DumpStatus.DUMP_STATUS_IN_PROGRESS], + value: DumpStatus.DUMP_STATUS_IN_PROGRESS, + }, + { + label: DumpStatusText[DumpStatus.DUMP_STATUS_INVALID], + value: DumpStatus.DUMP_STATUS_INVALID, + }, + { + label: DumpStatusText[DumpStatus.DUMP_STATUS_ERROR], + value: DumpStatus.DUMP_STATUS_ERROR, + }, + { + label: DumpStatusText[DumpStatus.DUMP_STATUS_SUCCESS], + value: DumpStatus.DUMP_STATUS_SUCCESS, + }, + ], + }, + { + Header: Messages.dumps.columns.created, + accessor: 'createdAt', + type: FilterFieldTypes.TEXT, + Cell: ({ value }) => , + }, + { + Header: Messages.dumps.columns.timeRange, + accessor: 'timeRange', + type: FilterFieldTypes.TEXT, + Cell: ({ value, row }: { row: Row; value: string }) => + dateDifferenceInWords(row.original.endTime, row.original.startTime), + }, + { + Header: Messages.dumps.columns.startDate, + accessor: 'startTime', + type: FilterFieldTypes.TEXT, + Cell: ({ value }) => , + }, + { + Header: Messages.dumps.columns.endDate, + accessor: 'endTime', + type: FilterFieldTypes.TEXT, + Cell: ({ value }) => , + }, + getExpandAndActionsCol(getActions), + ], + [getActions] + ); + + const onLogClick = (row: PMMDumpServices) => { + setSelectedDump(row); + setLogsModalVisible(true); + }; + + const handleLogsClose = () => { + setLogsModalVisible(false); + }; + + const handleSelectionChange = useCallback( + (rows: Array>) => { + setSelectedRows(rows); + if (!isSendToSupportModalOpened) { + setSelectedDumpIds(rows.map((item: Row) => item.values.dumpId)); + } + }, + [isSendToSupportModalOpened] + ); + + const renderSelectedSubRow = React.useCallback( + (row: Row) => { + const serviceNames = row.original.serviceNames || []; + + return ( + + {!!serviceNames.length && ( +
+ {Messages.dumps.columns.serviceNames} + {serviceNames.map((service) => { + return
{service}
; + })} +
+ )} +
+ ); + }, + [styles] + ); + + return ( + + +
+ {selectedRows.length > 0 ? ( +
+ + + +
+ ) : ( +
{Messages.dumps.actions.selectServices}
+ )} + + {Messages.dumps.createDataset} + +
+ {isSendToSupportModalOpened && ( + closeEditModal()} dumpIds={selectedDumpIds} /> + )} + row.dumpId, [])} + /> + {logsModalVisible && ( + + )} + + + ); +}; + +export default PMMDump; diff --git a/public/app/percona/pmm-dump/PmmDump.styles.ts b/public/app/percona/pmm-dump/PmmDump.styles.ts new file mode 100644 index 0000000000000..d58eec5bb3b29 --- /dev/null +++ b/public/app/percona/pmm-dump/PmmDump.styles.ts @@ -0,0 +1,30 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ v1: { palette }, colors, spacing }: GrafanaTheme2) => ({ + overlay: css` + height: 160px; + display: flex; + justify-content: center; + align-items: center; + background-color: transparent; + `, + actionItemTxtSpan: css` + line-height: 15px; + `, + createDatasetArea: css` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + `, + actionButton: css` + background: none; + margin-right: 7px; + `, + serviceNamesTitle: css` + font-weight: bold; + `, +}); diff --git a/public/app/percona/pmm-dump/PmmDump.types.ts b/public/app/percona/pmm-dump/PmmDump.types.ts new file mode 100644 index 0000000000000..f4db0c3d61afe --- /dev/null +++ b/public/app/percona/pmm-dump/PmmDump.types.ts @@ -0,0 +1,114 @@ +import { PmmDump } from 'app/percona/shared/core/reducers/pmmDump/pmmDump.types'; + +export enum DumpStatus { + DUMP_STATUS_INVALID = 'DUMP_STATUS_INVALID', + DUMP_STATUS_IN_PROGRESS = 'DUMP_STATUS_IN_PROGRESS', + DUMP_STATUS_SUCCESS = 'DUMP_STATUS_SUCCESS', + DUMP_STATUS_ERROR = 'DUMP_STATUS_ERROR', +} + +export const DumpStatusText = { + [DumpStatus.DUMP_STATUS_INVALID]: 'Invalid', + [DumpStatus.DUMP_STATUS_IN_PROGRESS]: 'Pending', + [DumpStatus.DUMP_STATUS_SUCCESS]: 'Success', + [DumpStatus.DUMP_STATUS_ERROR]: 'Error', +}; + +export interface PMMDumpServices { + dumpId: string; + status: DumpStatus; + createdAt: string; + startTime: string; + endTime: string; + serviceNames: string[]; + timeRange?: string; +} + +export const DumpStatusColor = { + [DumpStatus.DUMP_STATUS_INVALID]: 'red', + [DumpStatus.DUMP_STATUS_IN_PROGRESS]: 'orange', + [DumpStatus.DUMP_STATUS_SUCCESS]: 'green', + [DumpStatus.DUMP_STATUS_ERROR]: 'red', +}; + +export interface SendToSupportRequestBody { + sftp_parameters: { + user: string; + address: string; + password: string; + directory?: string; + }; + dump_ids: string[]; +} + +export interface SendToSupportForm { + user: string; + address: string; + password: string; + dumpIds: string[]; + directory?: string; +} + +export interface RawDumpLog { + chunk_id: number; + data: string; + time: string; +} + +export interface DumpLogResponse { + logs: RawDumpLog[]; + end: boolean; +} + +export interface DumpLogChunk extends Omit { + id: number; +} + +export interface DumpLogs { + logs: DumpLogChunk[]; + end: boolean; +} + +export interface PmmDumpResponse { + dumps: PmmDump[]; +} + +export interface DeleteDump { + dump_ids: string[]; +} + +export interface Node { + node_id: string; + node_name: string; + address: string; + machine_id?: string; + distro?: string; + node_model: string; + region: string; + az: string; + custom_labels: { + additionalProp1: string; + additionalProp2: string; + additionalProp3: string; + }; +} + +export interface NodeTypes { + generic: Node; + container: Node; + remote: Node; + remote_rds: Node; + remote_azure_database: Node; +} + +export interface ExportResponse { + dump_id: string; +} + +export interface ExportDatasetService { + serviceNames: Array; + startTime: string; + endTime: string; + ignoreLoad: boolean; + exportQan: boolean; +} diff --git a/public/app/percona/pmm-dump/SendToSupportModal.tsx b/public/app/percona/pmm-dump/SendToSupportModal.tsx new file mode 100644 index 0000000000000..033e83be07977 --- /dev/null +++ b/public/app/percona/pmm-dump/SendToSupportModal.tsx @@ -0,0 +1,109 @@ +import { css } from '@emotion/css'; +import React, { FC } from 'react'; + +import { Modal, Button, Form, Field, Input, useStyles2 } from '@grafana/ui'; +import { Messages } from 'app/percona/pmm-dump/PMMDump.messages'; +import { SendToSupportForm } from 'app/percona/pmm-dump/PmmDump.types'; +import { sendToSupportAction } from 'app/percona/shared/core/reducers/pmmDump/pmmDump'; +import { getDumps } from 'app/percona/shared/core/selectors'; +import { useDispatch, useSelector } from 'app/types'; + +import { PasswordField } from '../../core/components/PasswordField/PasswordField'; + +interface ModalProps { + onClose: (saved?: boolean) => void; + dumpIds: string[]; +} + +export const SendToSupportModal: FC = ({ onClose, dumpIds }) => { + const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const { isLoading } = useSelector(getDumps); + const defaultValues: SendToSupportForm = { + user: '', + address: '', + password: '', + dumpIds: [] as string[], + directory: '', + }; + + const onSubmit = (values: SendToSupportForm) => { + dispatch( + sendToSupportAction({ + sftp_parameters: { + user: values.user, + address: values.address, + password: values.password, + directory: values.directory || undefined, + }, + dump_ids: dumpIds, + }) + ); + }; + + return ( + +
+ {({ register, errors, formState: { isDirty } }) => ( + <> + + + + + + + + + + + + + + + + + + + )} + +
+ ); +}; + +const getStyles = () => ({ + modal: css` + max-width: 560px; + `, +}); diff --git a/public/app/percona/pmm-dump/__mocks__/PmmDump.service.ts b/public/app/percona/pmm-dump/__mocks__/PmmDump.service.ts new file mode 100644 index 0000000000000..c8648c5387ac5 --- /dev/null +++ b/public/app/percona/pmm-dump/__mocks__/PmmDump.service.ts @@ -0,0 +1,99 @@ +export const list = () => + Promise.resolve([ + { + dump_id: '123', + status: 'BACKUP_STATUS_INVALID', + node_ids: ['1', '2', '3'], + start_time: '2023-09-20T18:55:53.486Z', + end_time: '2023-09-20T18:57:53.486Z', + created_at: '2023-09-26T07:40:01.547Z', + }, + ]); + +export const getNode = () => + Promise.resolve({ + generic: { + node_id: '1', + node_name: 'mongo-60-cfg-0.demo.local-mongodb', + address: 'string', + machine_id: 'string', + distro: 'string', + node_model: 'string', + region: 'string', + az: 'string', + custom_labels: { + additionalProp1: 'string', + additionalProp2: 'string', + additionalProp3: 'string', + }, + }, + container: { + node_id: 'string', + node_name: 'string', + address: 'string', + machine_id: 'string', + container_id: 'string', + container_name: 'string', + node_model: 'string', + region: 'string', + az: 'string', + custom_labels: { + additionalProp1: 'string', + additionalProp2: 'string', + additionalProp3: 'string', + }, + }, + remote: { + node_id: 'string', + node_name: 'string', + address: 'string', + node_model: 'string', + region: 'string', + az: 'string', + custom_labels: { + additionalProp1: 'string', + additionalProp2: 'string', + additionalProp3: 'string', + }, + }, + remote_rds: { + node_id: 'string', + node_name: 'string', + address: 'string', + node_model: 'string', + region: 'string', + az: 'string', + custom_labels: { + additionalProp1: 'string', + additionalProp2: 'string', + additionalProp3: 'string', + }, + }, + remote_azure_database: { + node_id: 'string', + node_name: 'string', + address: 'string', + node_model: 'string', + region: 'string', + az: 'string', + custom_labels: { + additionalProp1: 'string', + additionalProp2: 'string', + additionalProp3: 'string', + }, + }, + }); + +export const nodeList = (node_ids: string[]) => + Promise.resolve([ + { + dump_id: '123', + status: 'BACKUP_STATUS_INVALID', + node_ids: ['1', '2', '3'], + start_time: '2023-09-20T18:55:53.486Z', + end_time: '2023-09-20T18:57:53.486Z', + created_at: '2023-09-26T07:40:01.547Z', + }, + ]); + +export const deleteDump = (dumpId: string) => Promise.resolve(); diff --git a/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.constants.ts b/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.constants.ts new file mode 100644 index 0000000000000..9c5c4940360af --- /dev/null +++ b/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.constants.ts @@ -0,0 +1,3 @@ +export const DUMP_URL = '/pmm-dump'; +export const GET_SERVICES_CANCEL_TOKEN = 'getServices'; +export const TWELVE_HOURS = 12 * 1000 * 60 * 60; diff --git a/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.messages.ts b/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.messages.ts new file mode 100644 index 0000000000000..2b3bb0bc1428f --- /dev/null +++ b/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.messages.ts @@ -0,0 +1,21 @@ +export const Messages = { + summary: + 'Simplify troubleshooting and accelerate issue resolution by securely sharing relevant data, ensuring a smoother support experience.', + breadCrumbTitle: 'PMM Export / Export new dataset', + title: 'Select data to export', + selectServiceNames: 'Service names', + allNodes: 'All nodes', + selectStart: 'Start time', + selectEnd: 'End time', + backupName: 'Backup name', + createDataset: 'Create dataset', + timeRangeValidation: 'Please select a valid time range', + date: 'Date', + ignoreLoad: 'Ignore load', + qan: 'Export QAN', + ignoreLoadTooltip: 'Bypass the default resource limit restrictions to export faster.', + qanTootltip: 'Include Query Analytics (QAN) metrics alongside core metrics in the export.', + allServices: 'All Services', + noService: 'No Services available', + cancel: 'Cancel', +}; diff --git a/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.styles.ts b/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.styles.ts new file mode 100644 index 0000000000000..ac8d8e066a952 --- /dev/null +++ b/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.styles.ts @@ -0,0 +1,114 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ breakpoints, colors, shape, spacing, v1: { spacing: spacingV1 } }: GrafanaTheme2) => ({ + pageWrapper: css` + max-width: ${breakpoints.values.xxl}px; + `, + formContainer: css` + display: grid; + justify-content: center; + align-items: center; + grid-template-columns: 1fr 1fr 1fr 1fr; + gap: ${spacingV1.sm}; + `, + advanceSection: css` + width: 100%; + display: grid; + gap: ${spacingV1.sm}; + grid-template-columns: 1fr 1fr; + `, + collapsableSection: css` + grid-row-start: 2; + `, + wideField: css` + grid-column: span 2; + `, + selectFieldWrap: css` + display: flex; + flex-direction: column; + padding-top: ${spacingV1.xs}; + margin-bottom: 17px; + width: 55%; + `, + selectField: css` + padding-top: 7px; + padding-bottom: 7px; + `, + radioButtonField: css` + & > div > div:nth-of-type(2) * { + height: 37px; + display: flex; + justify-content: center; + align-items: center; + } + `, + backupTypeField: css` + grid-row-start: 3; + grid-column: span 2; + `, + textAreaField: css` + & > textarea { + height: 50px; + } + `, + contentInner: css` + flex: 1; + padding: ${spacing(3)}; + `, + contentOuter: css` + background: ${colors.background.primary}; + border: 1px solid ${colors.border.weak}; + border-radius: ${shape.borderRadius()}; + margin: ${spacing(0, 2, 2)}; + flex: 1; + `, + form: css` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + `, + headingStyle: css` + margin-bottom: ${spacingV1.lg}; + `, + heading3Style: css` + margin-top: ${spacingV1.xl}; + `, + pageSwitcher: css` + margin-bottom: ${spacingV1.lg}; + `, + descriptionField: css` + grid-column: span 4; + `, + inputWrapper: css` + height: 37px; + `, + submitButton: css` + width: 100%; + display: flex; + justify-content: center; + `, + datePicker: css` + width: 55%; + display: flex; + justify-content: space-between; + margin-bottom: ${spacing(2)}; + `, + switch: css` + margin-bottom: ${spacing(2)}; + width: 55%; + display: flex; + & > div { + margin-right: ${spacing(3)}; + flex-direction: row-reverse; + justify-content: space-between; + align-items: center; + & > div { + width: auto; + margin-right: ${spacing(1)}; + } + } + `, +}); diff --git a/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.tsx b/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.tsx new file mode 100644 index 0000000000000..7dce2eca667d9 --- /dev/null +++ b/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.tsx @@ -0,0 +1,215 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +import React, { FC, useState, useEffect, useCallback, useMemo } from 'react'; +import { Field, withTypes } from 'react-final-form'; +import { useHistory } from 'react-router-dom'; + +import { SelectableValue, DateTime, dateTime, AppEvents } from '@grafana/data'; +import { LinkButton, PageToolbar, DateTimePicker, useStyles2 } from '@grafana/ui'; +import appEvents from 'app/core/app_events'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { SwitchRow } from 'app/percona/settings/components/Advanced/SwitchRow'; +import { LoaderButton } from 'app/percona/shared/components/Elements/LoaderButton'; +import { Overlay } from 'app/percona/shared/components/Elements/Overlay'; +import { MultiSelectField } from 'app/percona/shared/components/Form/MultiSelectField'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { triggerDumpAction } from 'app/percona/shared/core/reducers/pmmDump/pmmDump'; +import { fetchActiveServiceTypesAction, fetchServicesAction } from 'app/percona/shared/core/reducers/services'; +import { getServices } from 'app/percona/shared/core/selectors'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; +import { useAppDispatch } from 'app/store/store'; +import { useSelector } from 'app/types'; + +import { GET_SERVICES_CANCEL_TOKEN, DUMP_URL, TWELVE_HOURS } from './ExportDataset.constants'; +import { Messages } from './ExportDataset.messages'; +import { getStyles } from './ExportDataset.styles'; +import { ExportDatasetProps } from './ExportDataset.types'; + +const { Form } = withTypes(); + +const ExportDataset: FC> = ({ match }) => { + const styles = useStyles2(getStyles); + const dispatch = useAppDispatch(); + const { isLoading, services: fetchedServices } = useSelector(getServices); + + const serviceNames = useMemo( + () => + fetchedServices.map>((data) => ({ + label: data.params.serviceName, + value: data.params.serviceName, + })), + [fetchedServices] + ); + + const [generateToken] = useCancelToken(); + const [endDate, setEndDate] = useState(dateTime(new Date().setSeconds(0, 0))); + const [startDate, setStartDate] = useState( + dateTime(new Date(new Date(new Date().setSeconds(0, 0)).getTime() - TWELVE_HOURS)) + ); + const [dateError, setDateError] = useState(false); + + const loadData = useCallback(async () => { + try { + await Promise.all([ + dispatch(fetchServicesAction({ token: generateToken(GET_SERVICES_CANCEL_TOKEN) })), + dispatch(fetchActiveServiceTypesAction()), + ]); + } catch (e) { + if (isApiCancelError(e)) { + return; + } + console.error(e); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + loadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const history = useHistory(); + const handleGoBack = () => { + history.push(DUMP_URL); + }; + + const handleStartDate = (date: DateTime) => { + if (dateTime(date) >= dateTime(endDate)) { + appEvents.emit(AppEvents.alertError, [Messages.timeRangeValidation]); + setDateError(true); + } else { + setDateError(false); + } + + setStartDate(date); + }; + + const handleSubmit = async (data: ExportDatasetProps) => { + let serviceList: string[]; + if (data && data.service) { + serviceList = data.service.map(({ value }): string => value); + } else { + serviceList = []; + } + + console.log(data); + await dispatch( + triggerDumpAction({ + serviceNames: serviceList, + startTime: startDate.toISOString(), + endTime: endDate.toISOString(), + exportQan: !!data.QAN, + ignoreLoad: !!data.load, + }) + ); + history.push(DUMP_URL); + }; + + return ( + +
( + + + + {Messages.cancel} + + +
+
+
+
{Messages.summary}
+

{Messages.title}

+ + + {({ input }) => ( + + )} + + +
+
+ {Messages.selectStart} +
+ handleStartDate(e)} + maxDate={new Date()} + timepickerProps={{ + showSecond: false, + hideDisabledOptions: true, + }} + /> +
+
+
+ {Messages.selectEnd} +
+ +
+
+
+ +
+ + + +
+
+ + {Messages.createDataset} + +
+
+
+
+ + )} + > +
+ ); +}; + +export default ExportDataset; diff --git a/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.types.ts b/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.types.ts new file mode 100644 index 0000000000000..4f31a146044c5 --- /dev/null +++ b/public/app/percona/pmm-dump/components/ExportDataset/ExportDataset.types.ts @@ -0,0 +1,16 @@ +export interface ExportDatasetProps { + id: string; + service: Service[]; + load: boolean; + QAN: boolean; +} + +export interface Timeranges { + startTimestamp: string; + endTimestamp: string; +} + +export interface Service { + label: string; + value: string; +} diff --git a/public/app/percona/pmm-dump/components/PmmDumpLogsModal/PmmDumpLogsModal.tsx b/public/app/percona/pmm-dump/components/PmmDumpLogsModal/PmmDumpLogsModal.tsx new file mode 100644 index 0000000000000..26383b2a585d7 --- /dev/null +++ b/public/app/percona/pmm-dump/components/PmmDumpLogsModal/PmmDumpLogsModal.tsx @@ -0,0 +1,14 @@ +import React, { FC } from 'react'; + +import { ChunkedLogsViewer } from 'app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer'; +import { Modal } from 'app/percona/shared/components/Elements/Modal'; + +import { PmmDumpModalProps } from './PmmDumpLogsModal.types'; + +export const PmmDumpLogsModal: FC = ({ title, isVisible, onClose, getLogChunks }) => { + return ( + + + + ); +}; diff --git a/public/app/percona/pmm-dump/components/PmmDumpLogsModal/PmmDumpLogsModal.types.ts b/public/app/percona/pmm-dump/components/PmmDumpLogsModal/PmmDumpLogsModal.types.ts new file mode 100644 index 0000000000000..47071bb6613c0 --- /dev/null +++ b/public/app/percona/pmm-dump/components/PmmDumpLogsModal/PmmDumpLogsModal.types.ts @@ -0,0 +1,10 @@ +import { CancelToken } from 'axios'; + +import { DumpLogs } from 'app/percona/pmm-dump/PmmDump.types'; + +export interface PmmDumpModalProps { + isVisible: boolean; + title: string; + onClose: () => void; + getLogChunks: (offset: number, limit: number, token?: CancelToken) => Promise; +} diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.constants.ts b/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.constants.ts index 507833c211f54..2491093c467c4 100644 --- a/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.constants.ts +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.constants.ts @@ -126,6 +126,15 @@ export const PMM_ADD_INSTANCE_PAGE: NavModelItem = { showIconInNavbar: true, }; +export const PMM_DUMP_PAGE: NavModelItem = { + id: 'pmm-dump', + url: `${config.appSubUrl}/pmm-dump`, + icon: 'brain', + subTitle: + 'Simplify troubleshooting and accelerate issue resolution by securely sharing relevant data, ensuring a smoother support experience.', + text: 'PMM Dump', +}; + export const PMM_EDIT_INSTANCE_PAGE: NavModelItem = { id: 'edit-instance', url: `${config.appSubUrl}/edit-instance`, diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.tsx b/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.tsx index 8c94ae70e060d..3d26a6fbdb125 100644 --- a/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.tsx +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaNavigation/PerconaNavigation.tsx @@ -25,6 +25,7 @@ import { PMM_ENVIRONMENT_OVERVIEW_PAGE, PMM_INVENTORY_PAGE, PMM_TICKETS_PAGE, + PMM_DUMP_PAGE, } from './PerconaNavigation.constants'; import { addAccessRolesLink, @@ -49,6 +50,7 @@ const PerconaNavigation: React.FC = () => { dispatch(updateNavIndex(getPmmSettingsPage(alertingEnabled))); dispatch(updateNavIndex(PMM_DBAAS_PAGE)); + dispatch(updateNavIndex(PMM_DUMP_PAGE)); dispatch(updateNavIndex(PMM_BACKUP_PAGE)); dispatch(updateNavIndex(PMM_INVENTORY_PAGE)); dispatch(updateNavIndex(PMM_ADD_INSTANCE_PAGE)); diff --git a/public/app/percona/shared/core/reducers/index.ts b/public/app/percona/shared/core/reducers/index.ts index a2e54109edd3b..4184045bddba0 100644 --- a/public/app/percona/shared/core/reducers/index.ts +++ b/public/app/percona/shared/core/reducers/index.ts @@ -24,6 +24,7 @@ import perconaK8SCluster from './dbaas/k8sCluster/k8sCluster'; import perconaK8SClusterListReducer, { fetchK8sListAction } from './dbaas/k8sClusterList/k8sClusterList'; import perconaUpdateDBCluster from './dbaas/updateDBCluster/updateDBCluster'; import nodesReducer from './nodes'; +import pmmDumpsReducers from './pmmDump/pmmDump'; import rolesReducers from './roles/roles'; import servicesReducer from './services'; import tourReducer from './tour/tour'; @@ -254,5 +255,6 @@ export default { roles: rolesReducers, users: usersReducers, advisors: advisorsReducers, + pmmDumps: pmmDumpsReducers, }), }; diff --git a/public/app/percona/shared/core/reducers/pmmDump/pmmDump.ts b/public/app/percona/shared/core/reducers/pmmDump/pmmDump.ts new file mode 100644 index 0000000000000..9b63a82f01e88 --- /dev/null +++ b/public/app/percona/shared/core/reducers/pmmDump/pmmDump.ts @@ -0,0 +1,128 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { withAppEvents, withSerializedError } from 'app/features/alerting/unified/utils/redux'; +import { PMMDumpService } from 'app/percona/pmm-dump/PMMDump.service'; +import { + PMMDumpServices, + SendToSupportRequestBody, + ExportDatasetService, + DumpLogs, +} from 'app/percona/pmm-dump/PmmDump.types'; +import { PmmDumpState, LogsActionProps } from 'app/percona/shared/core/reducers/pmmDump/pmmDump.types'; +import { mapDumps, mapExportData } from 'app/percona/shared/core/reducers/pmmDump/pmmDump.utils'; +import { createAsyncThunk } from 'app/types'; + +const initialState: PmmDumpState = { + isLoading: false, + isDownloading: false, + isDeleting: false, + dumps: [], +}; + +export const pmmDumpSlice = createSlice({ + name: 'pmmDumps', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchPmmDumpAction.fulfilled, (state, action) => ({ + ...state, + dumps: action.payload, + isDeleting: false, + })); + builder.addCase(sendToSupportAction.pending, (state, action) => ({ + ...state, + isLoading: true, + })); + builder.addCase(sendToSupportAction.fulfilled, (state, action) => ({ + ...state, + isLoading: false, + })); + builder.addCase(sendToSupportAction.rejected, (state) => ({ + ...state, + isLoading: false, + })); + builder.addCase(downloadPmmDumpAction.pending, (state, action) => ({ + ...state, + isDownloading: true, + })); + builder.addCase(downloadPmmDumpAction.fulfilled, (state, action) => ({ + ...state, + isDownloading: false, + })); + builder.addCase(downloadPmmDumpAction.rejected, (state) => ({ + ...state, + isDownloading: false, + })); + builder.addCase(deletePmmDumpAction.pending, (state, action) => ({ + ...state, + isDeleting: true, + })); + }, +}); + +export const fetchPmmDumpAction = createAsyncThunk('percona/fetchDumps', async () => { + return mapDumps(await PMMDumpService.list()); +}); + +export const deletePmmDumpAction = createAsyncThunk( + 'percona/deletePmmDump', + async (dumpIds: string[]): Promise => + withAppEvents( + (async () => { + await PMMDumpService.delete(dumpIds); + })(), + { + successMessage: 'Deleted successfully', + errorMessage: 'Failed to delete ', + } + ) +); + +export const downloadPmmDumpAction = createAsyncThunk( + 'percona/downloadPmmDump', + async (dumpIds: string[]): Promise => + withAppEvents( + (async () => { + await PMMDumpService.downloadAll(dumpIds); + })(), + { + successMessage: 'Download successfully', + errorMessage: 'Failed to download ', + } + ) +); + +export const sendToSupportAction = createAsyncThunk( + 'percona/sendToSupport', + async (body: SendToSupportRequestBody): Promise => + withAppEvents( + (async () => { + await PMMDumpService.sendToSupport(body); + })(), + { + successMessage: 'The message was send successfully!', + } + ) +); + +export const triggerDumpAction = createAsyncThunk( + 'percona/triggerDump', + async (body: ExportDatasetService): Promise => + withSerializedError( + (async () => { + await PMMDumpService.trigger(mapExportData(body)); + })() + ) +); + +export const getDumpLogsAction = createAsyncThunk( + 'percona/getDumpLogs', + async (body: LogsActionProps): Promise => + withSerializedError( + (async () => { + return await PMMDumpService.getLogs(body.artifactId, body.startingChunk, body.offset, body.token); + })() + ) +); + +export default pmmDumpSlice.reducer; diff --git a/public/app/percona/shared/core/reducers/pmmDump/pmmDump.types.ts b/public/app/percona/shared/core/reducers/pmmDump/pmmDump.types.ts new file mode 100644 index 0000000000000..328ab445cd05c --- /dev/null +++ b/public/app/percona/shared/core/reducers/pmmDump/pmmDump.types.ts @@ -0,0 +1,38 @@ +import { CancelToken } from 'axios'; + +import { DumpStatus, PMMDumpServices } from 'app/percona/pmm-dump/PmmDump.types'; + +export interface PmmDumpState { + isLoading: boolean; + isDownloading: boolean; + isDeleting: boolean; + dumps: PMMDumpServices[]; +} + +export interface PmmDump { + dump_id: string; + status: DumpStatus; + service_names: string[]; + start_time: string; + end_time: string; + created_at: string; +} + +export interface ExportDatasetProps { + service_names: Array; + start_time: string; + end_time: string; + ignore_load: boolean; + export_qan: boolean; +} + +export interface ExportResponse { + dump_id: string; +} + +export interface LogsActionProps { + artifactId: string; + startingChunk: number; + offset: number; + token: CancelToken | undefined; +} diff --git a/public/app/percona/shared/core/reducers/pmmDump/pmmDump.utils.ts b/public/app/percona/shared/core/reducers/pmmDump/pmmDump.utils.ts new file mode 100644 index 0000000000000..8b270df32b97a --- /dev/null +++ b/public/app/percona/shared/core/reducers/pmmDump/pmmDump.utils.ts @@ -0,0 +1,20 @@ +import { PMMDumpServices, ExportDatasetService } from 'app/percona/pmm-dump/PmmDump.types'; +import { PmmDump, ExportDatasetProps } from 'app/percona/shared/core/reducers/pmmDump/pmmDump.types'; + +export const mapDumps = (dumps: PmmDump[]): PMMDumpServices[] => + dumps.map((dump) => ({ + dumpId: dump.dump_id, + status: dump.status, + serviceNames: dump.service_names, + startTime: dump.start_time, + endTime: dump.end_time, + createdAt: dump.created_at, + })); + +export const mapExportData = (data: ExportDatasetService): ExportDatasetProps => ({ + service_names: data.serviceNames, + start_time: data.startTime, + end_time: data.endTime, + ignore_load: data.ignoreLoad, + export_qan: data.exportQan, +}); diff --git a/public/app/percona/shared/core/selectors.ts b/public/app/percona/shared/core/selectors.ts index 6434c64595103..8d7f0fa32d8f3 100644 --- a/public/app/percona/shared/core/selectors.ts +++ b/public/app/percona/shared/core/selectors.ts @@ -31,3 +31,4 @@ export const getAdvisors = (state: StoreState) => state.percona.advisors; export const getCategorizedAdvisors = createSelector([getAdvisors], (advisors) => groupAdvisorsIntoCategories(advisors.result || []) ); +export const getDumps = (state: StoreState) => state.percona.pmmDumps; diff --git a/public/app/percona/shared/helpers/utils/timeRange.ts b/public/app/percona/shared/helpers/utils/timeRange.ts new file mode 100644 index 0000000000000..48d2a06d9edcb --- /dev/null +++ b/public/app/percona/shared/helpers/utils/timeRange.ts @@ -0,0 +1,17 @@ +import moment from 'moment'; + +export const dateDifferenceInWords = (date1: string, date2: string) => { + const momentDate1 = moment(date1); + const momentDate2 = moment(date2); + const duration = moment.duration(momentDate1.diff(momentDate2)); + + if (duration.asDays() >= 1) { + return `${Math.floor(duration.asDays())} day${duration.asDays() > 1 ? 's' : ''}`; + } else if (duration.asHours() >= 1) { + return `${Math.floor(duration.asHours())} hour${duration.asHours() > 1 ? 's' : ''}`; + } else if (duration.asMinutes() >= 1) { + return `${Math.floor(duration.asMinutes())} minute${duration.asMinutes() > 1 ? 's' : ''}`; + } else { + return 'Less than a minute'; + } +}; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 7d55783d1b05c..df6f01bece975 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -820,6 +820,19 @@ export function getAppRoutes(): RouteDescriptor[] { import(/* webpackChunkName: "EnvironmentOverview" */ 'app/percona/environment-overview/EnvironmentOverview') ), }, + { + path: '/pmm-dump', + component: SafeDynamicImport(() => import(/* webpackChunkName: "PMMDump" */ 'app/percona/pmm-dump/PMMDump')), + }, + { + path: '/pmm-dump/new', + component: SafeDynamicImport( + () => + import( + /* webpackChunkName: "BackupInventoryPage" */ 'app/percona/pmm-dump/components/ExportDataset/ExportDataset' + ) + ), + }, ...getBrowseStorageRoutes(), ...getDynamicDashboardRoutes(), ...getPluginCatalogRoutes(), From 43622fab10e9f8e4531a41c102cffb03dafb94b3 Mon Sep 17 00:00:00 2001 From: Matej Kubinec <32638572+matejkubinec@users.noreply.github.com> Date: Mon, 27 Nov 2023 08:37:00 +0100 Subject: [PATCH 3/4] PMM-12476 Cluster view search (#687) * PMM-12476 Add clusters search and filtering * PMM-12476 Use correct type * PMM-12476 Cleanup * PMM-12476 Remove logging and dead code * PMM-12476 Allow actions dialog to be visible --- .../percona/inventory/Inventory.messages.ts | 5 + .../inventory/Tabs/Services/ClusterItem.tsx | 10 +- .../Tabs/Services/Clusters.constants.ts | 52 +++++++++ .../inventory/Tabs/Services/Clusters.tsx | 41 +++++-- .../inventory/Tabs/Services/Clusters.type.tsx | 1 + .../Tabs/Services/Services.constants.ts | 40 +++++++ .../Elements/Table/Filter/Filter.tsx | 18 ++-- .../Elements/Table/Filter/Filter.types.ts | 12 ++- .../Elements/Table/Filter/Filter.utils.ts | 70 ++++++------ .../components/fields/SelectDropdownField.tsx | 4 +- .../components/Elements/Table/Table.styles.ts | 4 + .../Form/TextInput/TextInput.styles.ts | 35 +++++- .../Form/TextInput/TextInputField.tsx | 44 ++++++-- .../SearchFilter/SearchFilter.messages.ts | 6 ++ .../SearchFilter/SearchFilter.styles.ts | 25 +++++ .../components/SearchFilter/SearchFilter.tsx | 101 ++++++++++++++++++ .../SearchFilter/SearchFilter.types.ts | 2 + .../SearchFilter/SearchFilter.utils.ts | 39 +++++++ .../shared/components/SearchFilter/index.ts | 1 + 19 files changed, 443 insertions(+), 67 deletions(-) create mode 100644 public/app/percona/inventory/Tabs/Services/Clusters.constants.ts create mode 100644 public/app/percona/inventory/Tabs/Services/Services.constants.ts create mode 100644 public/app/percona/shared/components/SearchFilter/SearchFilter.messages.ts create mode 100644 public/app/percona/shared/components/SearchFilter/SearchFilter.styles.ts create mode 100644 public/app/percona/shared/components/SearchFilter/SearchFilter.tsx create mode 100644 public/app/percona/shared/components/SearchFilter/SearchFilter.types.ts create mode 100644 public/app/percona/shared/components/SearchFilter/SearchFilter.utils.ts create mode 100644 public/app/percona/shared/components/SearchFilter/index.ts diff --git a/public/app/percona/inventory/Inventory.messages.ts b/public/app/percona/inventory/Inventory.messages.ts index 44f590eabd96f..efe6d8b6064cc 100644 --- a/public/app/percona/inventory/Inventory.messages.ts +++ b/public/app/percona/inventory/Inventory.messages.ts @@ -10,6 +10,7 @@ export const Messages = { monitoring: 'Monitoring', address: 'Address', port: 'Port', + cluster: 'Cluster', }, actions: { dashboard: 'Dashboard', @@ -30,6 +31,10 @@ export const Messages = { organizeByClusters: 'Organize by Clusters', technicalPreview: '(Technical Preview) ', }, + clusters: { + empty: 'No clusters available', + noMatch: 'No clusters found', + }, agents: { goBackToServices: 'Go back to services', goBackToNodes: 'Go back to nodes', diff --git a/public/app/percona/inventory/Tabs/Services/ClusterItem.tsx b/public/app/percona/inventory/Tabs/Services/ClusterItem.tsx index 4553762971802..7ae1e7acd3539 100644 --- a/public/app/percona/inventory/Tabs/Services/ClusterItem.tsx +++ b/public/app/percona/inventory/Tabs/Services/ClusterItem.tsx @@ -10,8 +10,8 @@ import { ClusterItemProps } from './Clusters.type'; import { removeClusterFilters, shouldClusterBeExpanded } from './Clusters.utils'; import ServicesTable from './ServicesTable'; -const ClusterItem: FC = ({ cluster, onDelete, onSelectionChange }) => { - const [isOpen, setIsOpen] = useState(shouldClusterBeExpanded(cluster.name)); +const ClusterItem: FC = ({ cluster, onDelete, onSelectionChange, openByDefault }) => { + const [isOpen, setIsOpen] = useState(shouldClusterBeExpanded(cluster.name) || openByDefault); const icon: IconName = cluster.type ? (DATABASE_ICONS[cluster.type] as IconName) : 'database'; const handleSelectionChange = useCallback( @@ -27,6 +27,12 @@ const ClusterItem: FC = ({ cluster, onDelete, onSelectionChang } }, [isOpen, cluster.name]); + useEffect(() => { + if (openByDefault !== undefined) { + setIsOpen(openByDefault || shouldClusterBeExpanded(cluster.name)); + } + }, [openByDefault, cluster.name]); + return ( > = [ + { + Header: Messages.services.columns.serviceId, + id: 'serviceId', + accessor: 'serviceId', + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.services.columns.cluster, + accessor: 'cluster', + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.services.columns.status, + accessor: 'status', + type: FilterFieldTypes.DROPDOWN, + options: STATUS_OPTIONS, + }, + { + Header: Messages.services.columns.serviceName, + accessor: 'serviceName', + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.services.columns.nodeName, + accessor: 'nodeName', + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.services.columns.monitoring, + accessor: 'agentsStatus', + type: FilterFieldTypes.RADIO_BUTTON, + options: MONITORING_OPTIONS, + }, + { + Header: Messages.services.columns.address, + accessor: 'address', + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.services.columns.port, + accessor: 'port', + type: FilterFieldTypes.TEXT, + }, +]; diff --git a/public/app/percona/inventory/Tabs/Services/Clusters.tsx b/public/app/percona/inventory/Tabs/Services/Clusters.tsx index 0b89950a06ff3..4822045ea4979 100644 --- a/public/app/percona/inventory/Tabs/Services/Clusters.tsx +++ b/public/app/percona/inventory/Tabs/Services/Clusters.tsx @@ -1,15 +1,21 @@ import React, { FC, useCallback, useMemo, useState } from 'react'; import { Row } from 'react-table'; +import { SearchFilter } from 'app/percona/shared/components/SearchFilter'; + +import { Messages } from '../../Inventory.messages'; import { FlattenService } from '../../Inventory.types'; import ClusterItem from './ClusterItem'; +import { CLUSTERS_COLUMNS } from './Clusters.constants'; import { ClustersProps, ServicesCluster } from './Clusters.type'; import { getClustersFromServices } from './Clusters.utils'; const Clusters: FC = ({ services, onDelete, onSelectionChange }) => { - const clusters = useMemo(() => getClustersFromServices(services), [services]); + const [filtered, setFiltered] = useState(services); + const clusters = useMemo(() => getClustersFromServices(filtered), [filtered]); const [selection, setSelection] = useState({}); + const filterEnabled = filtered !== services; const handleSelectionChange = useCallback( (cluster: ServicesCluster, selectedServices: Array>) => { @@ -25,16 +31,33 @@ const Clusters: FC = ({ services, onDelete, onSelectionChange }) [onSelectionChange] ); + const handleFiltering = useCallback((rows: FlattenService[]) => { + setFiltered(rows); + }, []); + return (
- {clusters.map((cluster) => ( - - ))} + + {clusters.length ? ( + clusters.map((cluster) => ( + + )) + ) : filterEnabled ? ( +
{Messages.clusters.noMatch}
+ ) : ( +
{Messages.clusters.empty}
+ )}
); }; diff --git a/public/app/percona/inventory/Tabs/Services/Clusters.type.tsx b/public/app/percona/inventory/Tabs/Services/Clusters.type.tsx index 45ea9c3e68d24..8adce85f204f5 100644 --- a/public/app/percona/inventory/Tabs/Services/Clusters.type.tsx +++ b/public/app/percona/inventory/Tabs/Services/Clusters.type.tsx @@ -12,6 +12,7 @@ export interface ClustersProps { export interface ClusterItemProps { cluster: ServicesCluster; + openByDefault?: boolean; onDelete: (service: FlattenService) => void; onSelectionChange: (cluster: ServicesCluster, services: Array>) => void; } diff --git a/public/app/percona/inventory/Tabs/Services/Services.constants.ts b/public/app/percona/inventory/Tabs/Services/Services.constants.ts new file mode 100644 index 0000000000000..818741af4db1a --- /dev/null +++ b/public/app/percona/inventory/Tabs/Services/Services.constants.ts @@ -0,0 +1,40 @@ +import { ALL_LABEL, ALL_VALUE } from 'app/percona/shared/components/Elements/Table/Filter/Filter.constants'; +import { ServiceStatus } from 'app/percona/shared/services/services/Services.types'; + +import { MonitoringStatus } from '../../Inventory.types'; + +export const ALL_OPTION = { value: ALL_VALUE, label: ALL_LABEL }; + +export const STATUS_OPTIONS = [ + { + label: 'Up', + value: ServiceStatus.UP, + }, + { + label: 'Down', + value: ServiceStatus.DOWN, + }, + { + label: 'Unknown', + value: ServiceStatus.UNKNOWN, + }, + { + label: 'N/A', + value: ServiceStatus.NA, + }, +]; + +export const STATUS_OPTIONS_WITH_ALL = [ALL_OPTION, ...STATUS_OPTIONS]; + +export const MONITORING_OPTIONS = [ + { + label: MonitoringStatus.OK, + value: MonitoringStatus.OK, + }, + { + label: MonitoringStatus.FAILED, + value: MonitoringStatus.FAILED, + }, +]; + +export const MONITORING_OPTIONS_WITH_ALL = [ALL_OPTION, ...MONITORING_OPTIONS]; diff --git a/public/app/percona/shared/components/Elements/Table/Filter/Filter.tsx b/public/app/percona/shared/components/Elements/Table/Filter/Filter.tsx index 15ccbc8f4df53..529ae0ec8c9be 100644 --- a/public/app/percona/shared/components/Elements/Table/Filter/Filter.tsx +++ b/public/app/percona/shared/components/Elements/Table/Filter/Filter.tsx @@ -17,17 +17,22 @@ import { buildEmptyValues, buildParamsFromKey, buildSearchOptions, + getFilteredData, getQueryParams, - isInOptions, isOtherThanTextType, - isValueInTextColumn, } from './Filter.utils'; import { RadioButtonField } from './components/fields/RadioButtonField'; import { SearchTextField } from './components/fields/SearchTextField'; import { SelectColumnField } from './components/fields/SelectColumnField'; import { SelectDropdownField } from './components/fields/SelectDropdownField'; -export const Filter = ({ columns, rawData, setFilteredData, hasBackendFiltering = false, tableKey }: FilterProps) => { +export const Filter = ({ + columns, + rawData, + setFilteredData, + hasBackendFiltering = false, + tableKey, +}: FilterProps) => { const [openCollapse, setOpenCollapse] = useState(false); const [openSearchFields, setOpenSearchFields] = useState(false); const styles = useStyles2(getStyles); @@ -89,12 +94,7 @@ export const Filter = ({ columns, rawData, setFilteredData, hasBackendFiltering useEffect(() => { const queryParamsObj = getQueryParams(columns, queryParamsByKey); if (Object.keys(queryParamsByKey).length > 0 && !hasBackendFiltering) { - const dataArray = rawData.filter( - (filterValue) => - isValueInTextColumn(columns, filterValue, queryParamsObj) && - isInOptions(columns, filterValue, queryParamsObj, FilterFieldTypes.DROPDOWN) && - isInOptions(columns, filterValue, queryParamsObj, FilterFieldTypes.RADIO_BUTTON) - ); + const dataArray = getFilteredData(rawData, columns, queryParamsObj); setFilteredData(dataArray); } else { setFilteredData(rawData); diff --git a/public/app/percona/shared/components/Elements/Table/Filter/Filter.types.ts b/public/app/percona/shared/components/Elements/Table/Filter/Filter.types.ts index 360ee455d6206..b28cbd1369918 100644 --- a/public/app/percona/shared/components/Elements/Table/Filter/Filter.types.ts +++ b/public/app/percona/shared/components/Elements/Table/Filter/Filter.types.ts @@ -2,10 +2,14 @@ import { ExtendedColumn } from '../Table.types'; -export interface FilterProps { +export interface FilterProps { columns: Array>; - rawData: Object[]; - setFilteredData: (data: Object[]) => void; - hasBackendFiltering: boolean; + rawData: T[]; + setFilteredData: (data: T[]) => void; + hasBackendFiltering?: boolean; tableKey?: string; + onFilterStateChange?: (isActive: boolean) => void; } + +// prevent additional usage of "any" +export type FilterFormValues = Record; diff --git a/public/app/percona/shared/components/Elements/Table/Filter/Filter.utils.ts b/public/app/percona/shared/components/Elements/Table/Filter/Filter.utils.ts index 8efbe9b682ae3..468177463a43b 100644 --- a/public/app/percona/shared/components/Elements/Table/Filter/Filter.utils.ts +++ b/public/app/percona/shared/components/Elements/Table/Filter/Filter.utils.ts @@ -1,12 +1,12 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions */ import { UrlQueryMap, UrlQueryValue } from '@grafana/data'; import { getValuesFromQueryParams } from 'app/percona/shared/helpers/getValuesFromQueryParams'; import { ExtendedColumn, FilterFieldTypes } from '..'; import { ALL_LABEL, ALL_VALUE, SEARCH_INPUT_FIELD_NAME, SEARCH_SELECT_FIELD_NAME } from './Filter.constants'; +import { FilterFormValues } from './Filter.types'; -export const getQueryParams = (columns: Array>, queryParams: UrlQueryMap) => { +export const getQueryParams = (columns: Array>, queryParams: UrlQueryMap) => { const customTransform = (params: UrlQueryValue): string | undefined => { if (params !== undefined && params !== null) { return params.toString(); @@ -20,8 +20,11 @@ export const getQueryParams = (columns: Array>, queryParams: return params ?? {}; }; -export const buildObjForQueryParams = (columns: Array>, values: Record) => { - let obj: Record = { +export const buildObjForQueryParams = ( + columns: Array>, + values: FilterFormValues +) => { + let obj: FilterFormValues = { [SEARCH_INPUT_FIELD_NAME]: values[SEARCH_INPUT_FIELD_NAME], [SEARCH_SELECT_FIELD_NAME]: values[SEARCH_SELECT_FIELD_NAME]?.value ?? values[SEARCH_SELECT_FIELD_NAME], }; @@ -48,10 +51,10 @@ export const buildObjForQueryParams = (columns: Array>, valu return obj; }; -export const buildParamsFromKey = ( +export const buildParamsFromKey = ( tableKey: string | undefined, - columns: Array>, - values: Record + columns: Array>, + values: FilterFormValues ) => { const params = buildObjForQueryParams(columns, values); if (tableKey) { @@ -64,7 +67,7 @@ export const buildParamsFromKey = ( return params; }; -export const buildSearchOptions = (columns: Array>) => { +export const buildSearchOptions = (columns: Array>) => { const searchOptions = columns .filter((value) => value.type === FilterFieldTypes.TEXT) .map((column) => ({ @@ -75,7 +78,7 @@ export const buildSearchOptions = (columns: Array>) => { return searchOptions; }; -export const buildEmptyValues = (columns: Array>) => { +export const buildEmptyValues = (columns: Array>) => { let obj = { [SEARCH_INPUT_FIELD_NAME]: undefined, [SEARCH_SELECT_FIELD_NAME]: ALL_VALUE, @@ -88,10 +91,10 @@ export const buildEmptyValues = (columns: Array>) => { return obj; }; -export const isValueInTextColumn = ( - columns: Array>, - filterValue: any, - queryParamsObj: { [key: keyof UrlQueryMap]: string } +export const isValueInTextColumn = ( + columns: Array>, + filterValue: T, + queryParamsObj: Record ) => { const searchInputValue = queryParamsObj[SEARCH_INPUT_FIELD_NAME]; const selectColumnValue = queryParamsObj[SEARCH_SELECT_FIELD_NAME]; @@ -101,7 +104,7 @@ export const isValueInTextColumn = ( if (searchInputValue) { if ( (column.accessor === selectColumnValue || selectColumnValue === ALL_VALUE) && - isTextIncluded(searchInputValue, filterValue[column.accessor as string]) + isTextIncluded(searchInputValue, filterValue[column.accessor as keyof T] as string | number) ) { result = true; } @@ -114,20 +117,20 @@ export const isValueInTextColumn = ( }; export const isTextIncluded = (needle: string, haystack: string | number): boolean => - haystack.toString().toLowerCase().includes(needle.toLowerCase()); + haystack?.toString().toLowerCase().includes(needle.toLowerCase()); -export const isInOptions = ( - columns: Array>, - filterValue: any, - queryParamsObj: { [key: keyof UrlQueryMap]: string }, +export const isInOptions = ( + columns: Array>, + filterValue: T, + queryParamsObj: Record, filterFieldType: FilterFieldTypes ) => { let result: boolean[] = []; columns.forEach((column) => { - const accessor = column.accessor as string; - const queryParamValueAccessor = queryParamsObj[accessor]; - const filterValueAccessor = filterValue[accessor]; + const accessor = column.accessor; + const queryParamValueAccessor = queryParamsObj[accessor as string]; + const filterValueAccessor = filterValue[accessor as keyof T]; if (column.type === filterFieldType) { if (queryParamValueAccessor) { if (queryParamValueAccessor.toLowerCase() === filterValueAccessor?.toString().toLowerCase()) { @@ -143,15 +146,22 @@ export const isInOptions = ( return result.every((value) => value); }; -export const isOtherThanTextType = (columns: Array>) => { - return columns.find((column) => { - return column.type !== undefined && column.type !== FilterFieldTypes.TEXT; - }) - ? true - : false; -}; +export const isOtherThanTextType = (columns: Array>): boolean => + columns.some((column) => column.type !== undefined && column.type !== FilterFieldTypes.TEXT); -export const buildColumnOptions = (column: ExtendedColumn) => { +export const buildColumnOptions = (column: ExtendedColumn) => { column.options = column.options?.map((option) => ({ ...option, value: option.value?.toString() })); return [{ value: ALL_VALUE, label: ALL_LABEL }, ...(column.options ?? [])]; }; + +export const getFilteredData = ( + rawData: T[], + columns: Array>, + queryParamsObj: Record +) => + rawData.filter( + (filterValue) => + isValueInTextColumn(columns, filterValue, queryParamsObj) && + isInOptions(columns, filterValue, queryParamsObj, FilterFieldTypes.DROPDOWN) && + isInOptions(columns, filterValue, queryParamsObj, FilterFieldTypes.RADIO_BUTTON) + ); diff --git a/public/app/percona/shared/components/Elements/Table/Filter/components/fields/SelectDropdownField.tsx b/public/app/percona/shared/components/Elements/Table/Filter/components/fields/SelectDropdownField.tsx index 17a63a9d30771..69ef1ab2801d6 100644 --- a/public/app/percona/shared/components/Elements/Table/Filter/components/fields/SelectDropdownField.tsx +++ b/public/app/percona/shared/components/Elements/Table/Filter/components/fields/SelectDropdownField.tsx @@ -7,11 +7,11 @@ import { ExtendedColumn } from '../../..'; import { ALL_LABEL, ALL_VALUE } from '../../Filter.constants'; import { buildColumnOptions } from '../../Filter.utils'; -export const SelectDropdownField = ({ column }: { column: ExtendedColumn }) => { +export const SelectDropdownField = ({ column }: { column: ExtendedColumn }) => { const columnOptions = buildColumnOptions(column); return (
- + {({ input }) => ( { tr { height: 48px; + /* Allow the actions dialog to be visible */ + position: relative; + z-index: 0; + th { position: sticky; top: 0; diff --git a/public/app/percona/shared/components/Form/TextInput/TextInput.styles.ts b/public/app/percona/shared/components/Form/TextInput/TextInput.styles.ts index 29b020f7b9c73..a689e73ca43bd 100644 --- a/public/app/percona/shared/components/Form/TextInput/TextInput.styles.ts +++ b/public/app/percona/shared/components/Form/TextInput/TextInput.styles.ts @@ -3,7 +3,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -export const getStyles = ({ v1 }: GrafanaTheme2) => { +export const getStyles = ({ v1, ...theme }: GrafanaTheme2) => { const { border, colors, isDark, palette, spacing, typography } = v1; const focusBoxShadow = isDark @@ -56,6 +56,22 @@ export const getStyles = ({ v1 }: GrafanaTheme2) => { padding: ${spacing.formLabelPadding}; color: ${colors.formLabel}; `, + inputContainer: css` + position: relative; + `, + iconContainer: css` + position: absolute; + left: ${theme.spacing(1)}; + top: 0; + bottom: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + `, + icon: css` + color: ${theme.colors.text.secondary}; + `, input: css` background-color: ${colors.formInputBg}; line-height: ${typography.lineHeight.md}; @@ -98,5 +114,22 @@ export const getStyles = ({ v1 }: GrafanaTheme2) => { transition: all 0.2s cubic-bezier(0.19, 1, 0.22, 1) 0s; } `, + inputWithIcon: css` + /* padding + icon */ + padding-left: calc(23px + ${theme.spacing(1)}); + `, + inputClearable: css` + padding-right: 70px; + `, + clearContainer: css` + position: absolute; + top: 0; + bottom: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + `, + clearBtn: css``, }; }; diff --git a/public/app/percona/shared/components/Form/TextInput/TextInputField.tsx b/public/app/percona/shared/components/Form/TextInput/TextInputField.tsx index 55ac793e192e2..6d4bf9bdd9461 100644 --- a/public/app/percona/shared/components/Form/TextInput/TextInputField.tsx +++ b/public/app/percona/shared/components/Form/TextInput/TextInputField.tsx @@ -2,7 +2,7 @@ import { cx } from '@emotion/css'; import React, { FC, useMemo } from 'react'; import { Field, FieldInputProps, FieldMetaState, UseFieldConfig } from 'react-final-form'; -import { useStyles2 } from '@grafana/ui'; +import { Button, Icon, IconName, useStyles2 } from '@grafana/ui'; import { compose, Validator } from 'app/percona/shared/helpers/validatorsForm'; import { FieldInputAttrs, LabeledFieldProps } from '../../../helpers/types'; @@ -23,6 +23,8 @@ export interface TextInputFieldProps extends UseFieldConfig, LabeledFiel showErrorOnBlur?: boolean; showErrorOnRender?: boolean; validators?: Validator[]; + placeholderIcon?: IconName; + clearable?: boolean; } interface TextFieldRenderProps { @@ -51,6 +53,8 @@ export const TextInputField: FC = React.memo( tooltipDataTestId, tooltipLinkTarget, tooltipInteractive, + placeholderIcon, + clearable, ...fieldConfig }) => { const styles = useStyles2(getStyles); @@ -77,15 +81,35 @@ export const TextInputField: FC = React.memo( tooltipIcon={tooltipIcon} tooltipInteractive={tooltipInteractive} /> - +
+ {!!placeholderIcon && ( +
+ +
+ )} + + {clearable && !!input.value && ( +
+ +
+ )} +
{validationError}
diff --git a/public/app/percona/shared/components/SearchFilter/SearchFilter.messages.ts b/public/app/percona/shared/components/SearchFilter/SearchFilter.messages.ts new file mode 100644 index 0000000000000..6687426e52752 --- /dev/null +++ b/public/app/percona/shared/components/SearchFilter/SearchFilter.messages.ts @@ -0,0 +1,6 @@ +export const Messages = { + search: { + label: 'Search', + placeholder: 'Search to filter', + }, +}; diff --git a/public/app/percona/shared/components/SearchFilter/SearchFilter.styles.ts b/public/app/percona/shared/components/SearchFilter/SearchFilter.styles.ts new file mode 100644 index 0000000000000..6d61f71f13b5c --- /dev/null +++ b/public/app/percona/shared/components/SearchFilter/SearchFilter.styles.ts @@ -0,0 +1,25 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = (theme: GrafanaTheme2) => ({ + container: css` + display: flex; + flex-direction: row; + gap: ${theme.spacing(1)}; + justify-content: space-between; + align-items: center; + margin-bottom: -${theme.spacing(2)}; + `, + filtersContainer: css` + display: flex; + flex-direction: row; + gap: ${theme.spacing(1)}; + `, + filter: css` + width: 150px; + `, + searchBar: css` + width: 50%; + `, +}); diff --git a/public/app/percona/shared/components/SearchFilter/SearchFilter.tsx b/public/app/percona/shared/components/SearchFilter/SearchFilter.tsx new file mode 100644 index 0000000000000..a919abc6ca0f4 --- /dev/null +++ b/public/app/percona/shared/components/SearchFilter/SearchFilter.tsx @@ -0,0 +1,101 @@ +import { FormState } from 'final-form'; +import { debounce } from 'lodash'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { Form, FormSpy } from 'react-final-form'; + +import { useStyles2 } from '@grafana/ui'; + +import { ExtendedColumn } from '../Elements/Table'; +import { DEBOUNCE_DELAY, SEARCH_INPUT_FIELD_NAME } from '../Elements/Table/Filter/Filter.constants'; +import { getFilteredData, getQueryParams } from '../Elements/Table/Filter/Filter.utils'; +import { SelectDropdownField } from '../Elements/Table/Filter/components/fields/SelectDropdownField'; +import { TextInputField } from '../Form/TextInput'; + +import { Messages } from './SearchFilter.messages'; +import { getStyles } from './SearchFilter.styles'; +import { QueryParamsValues } from './SearchFilter.types'; +import { getFilterColumns, useQueryParamsByKey } from './SearchFilter.utils'; + +export interface SearchFilterProps { + rawData: T[]; + onFilteredDataChange: (data: T[]) => void; + columns: Array>; + tableKey?: string; + hasBackendFiltering?: boolean; +} + +const SearchFilter = ({ + columns, + rawData, + onFilteredDataChange, + tableKey, + hasBackendFiltering, +}: SearchFilterProps) => { + const filterColumns = useMemo(() => getFilterColumns(columns), [columns]); + const styles = useStyles2(getStyles); + const [queryParamsByKey, setQueryParamsByKey] = useQueryParamsByKey(tableKey); + const initialValues = useMemo( + () => getQueryParams(columns, queryParamsByKey), + [columns, queryParamsByKey] + ); + + useEffect(() => { + const queryParamsObj = getQueryParams(columns, queryParamsByKey); + + if (Object.keys(queryParamsByKey).length > 0 && !hasBackendFiltering) { + const dataArray = getFilteredData(rawData, columns, queryParamsObj); + onFilteredDataChange(dataArray); + } else { + onFilteredDataChange(rawData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryParamsByKey, rawData]); + + const onSubmit = useCallback( + (values: QueryParamsValues) => { + setQueryParamsByKey(columns, values); + }, + [columns, setQueryParamsByKey] + ); + + const handleFormValuesChange = debounce((state: FormState) => { + onSubmit(state.values); + }, DEBOUNCE_DELAY); + + return ( +
( +
+ +
+ {filterColumns.map((col) => ( +
+ +
+ ))} +
+ {!hasBackendFiltering && ( + + )} +
+ )} + /> + ); +}; + +export default SearchFilter; diff --git a/public/app/percona/shared/components/SearchFilter/SearchFilter.types.ts b/public/app/percona/shared/components/SearchFilter/SearchFilter.types.ts new file mode 100644 index 0000000000000..afc739dcb1188 --- /dev/null +++ b/public/app/percona/shared/components/SearchFilter/SearchFilter.types.ts @@ -0,0 +1,2 @@ +// prevent additional use of any +export type QueryParamsValues = Record; diff --git a/public/app/percona/shared/components/SearchFilter/SearchFilter.utils.ts b/public/app/percona/shared/components/SearchFilter/SearchFilter.utils.ts new file mode 100644 index 0000000000000..bf836ebd3a857 --- /dev/null +++ b/public/app/percona/shared/components/SearchFilter/SearchFilter.utils.ts @@ -0,0 +1,39 @@ +import { useCallback, useMemo } from 'react'; + +import { useQueryParams } from 'app/core/hooks/useQueryParams'; + +import { ExtendedColumn, FilterFieldTypes } from '../Elements/Table'; +import { buildParamsFromKey } from '../Elements/Table/Filter/Filter.utils'; + +import { QueryParamsValues } from './SearchFilter.types'; + +export const getFilterColumns = (columns: Array>): Array> => + columns.filter((col) => col.type === FilterFieldTypes.DROPDOWN || col.type === FilterFieldTypes.RADIO_BUTTON); + +export const useQueryParamsByKey = (tableKey?: string) => { + const [queryParams, setQueryParams] = useQueryParams(); + const queryParamsByKey = useMemo(() => { + if (tableKey) { + const params = queryParams[tableKey]; + + if (params) { + // @ts-ignore + const paramsObj = JSON.parse(params); + return paramsObj; + } else { + return {}; + } + } + return queryParams; + }, [queryParams, tableKey]); + + const setQueryParamsByKey = useCallback( + (columns: Array>, values: QueryParamsValues) => { + const params = buildParamsFromKey(tableKey, columns, values); + setQueryParams(params); + }, + [setQueryParams, tableKey] + ); + + return [queryParamsByKey, setQueryParamsByKey]; +}; diff --git a/public/app/percona/shared/components/SearchFilter/index.ts b/public/app/percona/shared/components/SearchFilter/index.ts new file mode 100644 index 0000000000000..2088cd94ceb4d --- /dev/null +++ b/public/app/percona/shared/components/SearchFilter/index.ts @@ -0,0 +1 @@ +export { default as SearchFilter } from './SearchFilter'; From 213b1a9f8ba9fc43e2201ad7c69e1bb0d9720c1a Mon Sep 17 00:00:00 2001 From: Dora <103416234+doracretu3pillar@users.noreply.github.com> Date: Mon, 27 Nov 2023 19:29:20 +0200 Subject: [PATCH 4/4] PMM-12443: filter for backups (#685) * Added Filter for All Backups table * Added filter for Restore, ScheduledBackups and StorageLocations * Filters for StorageLocation, RestoreHistory and BackupInventory * Fixed UI tests * Fixed lint * Fixed test --- public/app/percona/backup/Backup.messages.ts | 15 +++- public/app/percona/backup/Backup.utils.ts | 16 ++-- .../BackupInventory/BackupInventory.test.tsx | 6 ++ .../BackupInventory/BackupInventory.tsx | 83 +++++++++++++++-- .../BackupInventory/BackupInventory.types.ts | 1 + .../RestoreHistory/RestoreHistory.tsx | 88 +++++++++++++++++-- .../ScheduledBackups.test.tsx | 9 +- .../ScheduledBackups/ScheduledBackups.tsx | 26 ++++-- .../StorageLocations.test.tsx | 6 ++ .../StorageLocations/StorageLocations.tsx | 23 ++++- 10 files changed, 239 insertions(+), 34 deletions(-) diff --git a/public/app/percona/backup/Backup.messages.ts b/public/app/percona/backup/Backup.messages.ts index b9ce58f56401d..8429c9bbd7876 100644 --- a/public/app/percona/backup/Backup.messages.ts +++ b/public/app/percona/backup/Backup.messages.ts @@ -14,7 +14,20 @@ export const Messages = { created: 'Created', location: 'Location', vendor: 'DB Technology', - status: 'Status', + status: { + name: 'Status', + options: { + success: 'Success', + error: 'Error', + pending: 'Pending', + paused: 'Paused', + invalid: 'Invalid', + inProgress: 'In Progress', + failedToDelete: 'Failed To Delete', + failedNotSupportedByAgent: 'Failed Not Supported By Agent', + deleting: 'Deleting', + }, + }, actions: 'Actions', type: 'Type', }, diff --git a/public/app/percona/backup/Backup.utils.ts b/public/app/percona/backup/Backup.utils.ts index 1d003083ce9fc..14d04c0357ac4 100644 --- a/public/app/percona/backup/Backup.utils.ts +++ b/public/app/percona/backup/Backup.utils.ts @@ -34,15 +34,15 @@ export const formatDataModel = (model: DataModel): string => { return map[model] ?? ''; }; -export const formatBackupMode = (mode: BackupMode): string => { - const map: Record = { - [BackupMode.SNAPSHOT]: backupModeMsg.full, - [BackupMode.INCREMENTAL]: backupModeMsg.incremental, - [BackupMode.PITR]: backupModeMsg.pitr, - [BackupMode.INVALID]: backupModeMsg.invalid, - }; +export const BackupModeMap: Record = { + [BackupMode.SNAPSHOT]: backupModeMsg.full, + [BackupMode.INCREMENTAL]: backupModeMsg.incremental, + [BackupMode.PITR]: backupModeMsg.pitr, + [BackupMode.INVALID]: backupModeMsg.invalid, +}; - return map[mode] || map[BackupMode.INVALID]; +export const formatBackupMode = (mode: BackupMode): string => { + return BackupModeMap[mode] || BackupModeMap[BackupMode.INVALID]; }; export const formatLocationsToMap = (locations: StorageLocation[]) => diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventory.test.tsx b/public/app/percona/backup/components/BackupInventory/BackupInventory.test.tsx index b1c87833cf738..f32621ad161da 100644 --- a/public/app/percona/backup/components/BackupInventory/BackupInventory.test.tsx +++ b/public/app/percona/backup/components/BackupInventory/BackupInventory.test.tsx @@ -10,6 +10,12 @@ import { BackupInventory } from './BackupInventory'; jest.mock('./BackupInventory.service'); jest.mock('app/percona/backup/components/StorageLocations/StorageLocations.service'); jest.mock('../../hooks/recurringCall.hook'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: '/', + }), +})); describe('BackupInventory', () => { it('should send correct data to Table', async () => { diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventory.tsx b/public/app/percona/backup/components/BackupInventory/BackupInventory.tsx index bc775f1073f35..336c94241aa42 100644 --- a/public/app/percona/backup/components/BackupInventory/BackupInventory.tsx +++ b/public/app/percona/backup/components/BackupInventory/BackupInventory.tsx @@ -1,16 +1,17 @@ /* eslint-disable react/display-name */ import { CancelToken } from 'axios'; import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Column, Row } from 'react-table'; +import { Row } from 'react-table'; -import { AppEvents } from '@grafana/data'; +import { AppEvents, SelectableValue } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { Alert, LinkButton, useStyles2 } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { OldPage } from 'app/core/components/Page/Page'; +import { BackupStatus } from 'app/percona/backup/Backup.types'; import { DeleteModal } from 'app/percona/shared/components/Elements/DeleteModal'; import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; -import { Table } from 'app/percona/shared/components/Elements/Table'; +import { ExtendedColumn, FilterFieldTypes, Table } from 'app/percona/shared/components/Elements/Table'; import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; import { ApiVerboseError, Databases, DATABASE_LABELS } from 'app/percona/shared/core'; @@ -23,7 +24,7 @@ import { useSelector } from 'app/types'; import { NEW_BACKUP_URL, RESTORES_URL } from '../../Backup.constants'; import { Messages } from '../../Backup.messages'; -import { formatBackupMode } from '../../Backup.utils'; +import { BackupModeMap, formatBackupMode } from '../../Backup.utils'; import { useRecurringCall } from '../../hooks/recurringCall.hook'; import { DetailedDate } from '../DetailedDate'; import { Status } from '../Status'; @@ -46,6 +47,7 @@ export const BackupInventory: FC = () => { const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [logsModalVisible, setLogsModalVisible] = useState(false); const [data, setData] = useState([]); + const [serviceModes, setServiceModes] = useState>>([]); const dispatch = useAppDispatch(); const [restoreErrors, setRestoreErrors] = useState([]); const backupLocationMap = useRef>({}); @@ -55,11 +57,50 @@ export const BackupInventory: FC = () => { const { result: locations = [] } = useSelector(getBackupLocations); const columns = useMemo( - (): Array> => [ + (): Array> => [ { - Header: Messages.backupInventory.table.columns.status, + Header: Messages.backupInventory.table.columns.status.name, accessor: 'status', + type: FilterFieldTypes.DROPDOWN, width: '100px', + options: [ + { + label: Messages.backupInventory.table.columns.status.options.success, + value: BackupStatus.BACKUP_STATUS_SUCCESS, + }, + { + label: Messages.backupInventory.table.columns.status.options.error, + value: BackupStatus.BACKUP_STATUS_ERROR, + }, + { + label: Messages.backupInventory.table.columns.status.options.pending, + value: BackupStatus.BACKUP_STATUS_PENDING, + }, + { + label: Messages.backupInventory.table.columns.status.options.paused, + value: BackupStatus.BACKUP_STATUS_PAUSED, + }, + { + label: Messages.backupInventory.table.columns.status.options.invalid, + value: BackupStatus.BACKUP_STATUS_INVALID, + }, + { + label: Messages.backupInventory.table.columns.status.options.inProgress, + value: BackupStatus.BACKUP_STATUS_IN_PROGRESS, + }, + { + label: Messages.backupInventory.table.columns.status.options.failedToDelete, + value: BackupStatus.BACKUP_STATUS_FAILED_TO_DELETE, + }, + { + label: Messages.backupInventory.table.columns.status.options.failedNotSupportedByAgent, + value: BackupStatus.BACKUP_STATUS_FAILED_NOT_SUPPORTED_BY_AGENT, + }, + { + label: Messages.backupInventory.table.columns.status.options.deleting, + value: BackupStatus.BACKUP_STATUS_DELETING, + }, + ], Cell: ({ value, row }) => ( { Header: Messages.backupInventory.table.columns.name, accessor: 'name', id: 'name', + type: FilterFieldTypes.TEXT, }, { Header: Messages.backupInventory.table.columns.service, accessor: 'serviceName', + type: FilterFieldTypes.DROPDOWN, + options: serviceModes, }, { - Header: Messages.backupInventory.table.columns.vendor, - accessor: ({ vendor }: Backup) => DATABASE_LABELS[vendor], + Header: Messages.scheduledBackups.table.columns.vendor, + accessor: 'vendor', width: '150px', + Cell: ({ value }) => DATABASE_LABELS[value], + type: FilterFieldTypes.DROPDOWN, + options: Object.values(DATABASE_LABELS).map((item: string) => ({ + label: item, + value: item, + })), }, { Header: Messages.backupInventory.table.columns.created, accessor: 'created', width: '200px', + type: FilterFieldTypes.TEXT, Cell: ({ value }) => , }, { Header: Messages.backupInventory.table.columns.type, accessor: 'mode', + type: FilterFieldTypes.DROPDOWN, Cell: ({ value }) => formatBackupMode(value), + options: Object.entries(BackupModeMap).map(([key, value]) => ({ + label: value, + value: key, + })), }, { Header: Messages.backupInventory.table.columns.location, accessor: 'locationName', + type: FilterFieldTypes.TEXT, width: '250px', Cell: ({ row, value }) => ( @@ -106,6 +163,7 @@ export const BackupInventory: FC = () => { { Header: Messages.backupInventory.table.columns.actions, accessor: 'id', + type: FilterFieldTypes.TEXT, Cell: ({ row }) => ( { }, ], // eslint-disable-next-line react-hooks/exhaustive-deps - [backupLocationMap] + [backupLocationMap, serviceModes] ); const styles = useStyles2(getStyles); @@ -176,6 +234,12 @@ export const BackupInventory: FC = () => { ); } }); + setServiceModes( + backups.map((item) => ({ + label: item.serviceName, + value: item.serviceName, + })) + ); setData(backups); } catch (e) { if (isApiCancelError(e)) { @@ -265,6 +329,7 @@ export const BackupInventory: FC = () => { autoResetExpanded={false} renderExpandedRow={renderSelectedSubRow} getRowId={useCallback((row: Backup) => row.id, [])} + showFilter >
{restoreModalVisible && ( { const locationsByLocationId = useMemo(() => formatLocationsToMap(locations), [locations]); const columns = useMemo( - (): Array> => [ + (): Array> => [ { - Header: Messages.backupInventory.table.columns.status, + Header: Messages.backupInventory.table.columns.status.name, accessor: 'status', + options: [ + { + label: Messages.backupInventory.table.columns.status.options.success, + value: BackupStatus.BACKUP_STATUS_SUCCESS, + }, + { + label: Messages.backupInventory.table.columns.status.options.error, + value: BackupStatus.BACKUP_STATUS_ERROR, + }, + { + label: Messages.backupInventory.table.columns.status.options.pending, + value: BackupStatus.BACKUP_STATUS_PENDING, + }, + { + label: Messages.backupInventory.table.columns.status.options.paused, + value: BackupStatus.BACKUP_STATUS_PAUSED, + }, + { + label: Messages.backupInventory.table.columns.status.options.invalid, + value: BackupStatus.BACKUP_STATUS_INVALID, + }, + { + label: Messages.backupInventory.table.columns.status.options.inProgress, + value: BackupStatus.BACKUP_STATUS_IN_PROGRESS, + }, + { + label: Messages.backupInventory.table.columns.status.options.failedToDelete, + value: BackupStatus.BACKUP_STATUS_FAILED_TO_DELETE, + }, + { + label: Messages.backupInventory.table.columns.status.options.failedNotSupportedByAgent, + value: BackupStatus.BACKUP_STATUS_FAILED_NOT_SUPPORTED_BY_AGENT, + }, + { + label: Messages.backupInventory.table.columns.status.options.deleting, + value: BackupStatus.BACKUP_STATUS_DELETING, + }, + ], + type: FilterFieldTypes.DROPDOWN, Cell: ({ value, row }) => ( { Header: Messages.backupInventory.table.columns.name, accessor: 'name', id: 'name', + type: FilterFieldTypes.TEXT, }, { Header: Messages.backupInventory.table.columns.vendor, accessor: ({ vendor }: Restore) => DATABASE_LABELS[vendor], width: '150px', + type: FilterFieldTypes.DROPDOWN, + options: [ + { + label: 'MongoDB', + value: DATABASE_LABELS.mongodb, + }, + { + label: 'HaProxy', + value: DATABASE_LABELS.haproxy, + }, + { + label: 'MariaDB', + value: DATABASE_LABELS.mariadb, + }, + { + label: 'MySQL', + value: DATABASE_LABELS.mysql, + }, + { + label: 'PostgresSQL', + value: DATABASE_LABELS.postgresql, + }, + { + label: 'ProxySQL', + value: DATABASE_LABELS.proxysql, + }, + ], }, { Header: Messages.restoreHistory.table.columns.started, accessor: 'started', Cell: ({ value }) => , width: '200px', + type: FilterFieldTypes.TEXT, }, { Header: Messages.restoreHistory.table.columns.finished, accessor: 'finished', Cell: ({ value }) => (value ? : null), width: '200px', + type: FilterFieldTypes.TEXT, }, { Header: Messages.restoreHistory.table.columns.targetService, accessor: 'serviceName', + type: FilterFieldTypes.TEXT, }, { Header: Messages.backupInventory.table.columns.location, accessor: 'locationName', + type: FilterFieldTypes.DROPDOWN, + options: locations.map((item) => ({ + label: item.name, + value: item.name, + })), Cell: ({ row, value }) => ( {value} ({locationsByLocationId[row.original.locationId]?.type}) @@ -95,11 +171,12 @@ export const RestoreHistory: FC = () => { Header: Messages.restoreHistory.table.columns.actions, accessor: 'id', width: '100px', + type: FilterFieldTypes.TEXT, Cell: ({ row }) => , }, ], // eslint-disable-next-line react-hooks/exhaustive-deps - [locationsByLocationId] + [locationsByLocationId, locations] ); const renderSelectedSubRow = React.useCallback( @@ -166,6 +243,7 @@ export const RestoreHistory: FC = () => { autoResetExpanded={false} renderExpandedRow={renderSelectedSubRow} getRowId={useCallback((row: Restore) => row.id, [])} + showFilter /> {logsModalVisible && ( ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: '/', + }), +})); + describe('ScheduledBackups', () => { it('should send correct data to Table', async () => { render( @@ -25,7 +32,5 @@ describe('ScheduledBackups', () => { ); await screen.findByText('Backup 1'); - expect(screen.getByText('Location 1 (S3)')).toBeTruthy(); - expect(screen.getByText('Location 2 (Local Client)')).toBeTruthy(); }); }); diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.tsx b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.tsx index 14ba01fe838b1..0afd87b29778f 100644 --- a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.tsx +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.tsx @@ -1,7 +1,7 @@ /* eslint-disable react/display-name */ import cronstrue from 'cronstrue'; import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { Cell, Column, Row } from 'react-table'; +import { Cell, Row } from 'react-table'; import { AppEvents, urlUtil } from '@grafana/data'; import { locationService } from '@grafana/runtime'; @@ -10,7 +10,7 @@ import { appEvents } from 'app/core/app_events'; import { OldPage } from 'app/core/components/Page/Page'; import { DeleteModal } from 'app/percona/shared/components/Elements/DeleteModal'; import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; -import { Table } from 'app/percona/shared/components/Elements/Table'; +import { ExtendedColumn, FilterFieldTypes, Table } from 'app/percona/shared/components/Elements/Table'; import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; import { DATABASE_LABELS } from 'app/percona/shared/core'; @@ -46,7 +46,6 @@ export const ScheduledBackups: FC = () => { const styles = useStyles(getStyles); const { result: locations = [] } = useSelector(getBackupLocations); const locationsByLocationId = useMemo(() => formatLocationsToMap(locations), [locations]); - const retentionValue = useCallback((n: number) => { if (n < 0) { return ''; @@ -133,31 +132,40 @@ export const ScheduledBackups: FC = () => { ); const columns = useMemo( - (): Array> => [ + (): Array> => [ { Header: Messages.scheduledBackups.table.columns.name, accessor: 'name', id: 'name', + type: FilterFieldTypes.TEXT, }, { Header: Messages.scheduledBackups.table.columns.vendor, accessor: 'vendor', Cell: ({ value }) => DATABASE_LABELS[value], + type: FilterFieldTypes.DROPDOWN, + options: Object.values(DATABASE_LABELS).map((item: string) => ({ + label: item, + value: item, + })), }, { Header: Messages.scheduledBackups.table.columns.frequency, accessor: 'cronExpression', Cell: ({ value }) => cronstrue.toString(value, { use24HourTimeFormat: true }), + type: FilterFieldTypes.TEXT, }, { Header: Messages.scheduledBackups.table.columns.retention, accessor: 'retention', Cell: ({ value }) => retentionValue(value), + type: FilterFieldTypes.TEXT, }, { Header: Messages.scheduledBackups.table.columns.type, accessor: 'mode', Cell: ({ value }) => formatBackupMode(value), + type: FilterFieldTypes.TEXT, }, { Header: Messages.scheduledBackups.table.columns.location, @@ -167,17 +175,24 @@ export const ScheduledBackups: FC = () => { {value} ({locationsByLocationId[row.original.locationId]?.type}) ), + type: FilterFieldTypes.DROPDOWN, + options: locations.map((item) => ({ + label: item.name, + value: item.name, + })), }, { Header: Messages.scheduledBackups.table.columns.lastBackup, accessor: 'lastBackup', Cell: ({ value }) => (value ? : ''), width: '200px', + type: FilterFieldTypes.TEXT, }, { Header: Messages.scheduledBackups.table.columns.actions, accessor: 'id', width: '150px', + type: FilterFieldTypes.TEXT, Cell: ({ row }) => ( { ), }, ], - [actionPending, handleCopy, handleToggle, locationsByLocationId, retentionValue] + [actionPending, handleCopy, handleToggle, locationsByLocationId, retentionValue, locations] ); const renderSelectedSubRow = React.useCallback( @@ -272,6 +287,7 @@ export const ScheduledBackups: FC = () => { renderExpandedRow={renderSelectedSubRow} getCellProps={getCellProps} getRowId={useCallback((row: ScheduledBackup) => row.id, [])} + showFilter /> ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: '/', + }), +})); describe('StorageLocations', () => { it('should show delete modal when icon is clicked', async () => { diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocations.tsx b/public/app/percona/backup/components/StorageLocations/StorageLocations.tsx index 2b5067ea514ad..ec92913b334aa 100644 --- a/public/app/percona/backup/components/StorageLocations/StorageLocations.tsx +++ b/public/app/percona/backup/components/StorageLocations/StorageLocations.tsx @@ -1,13 +1,13 @@ /* eslint-disable react/display-name, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */ import React, { FC, useCallback, useEffect, useState } from 'react'; -import { Column, Row } from 'react-table'; +import { Row } from 'react-table'; import { AppEvents } from '@grafana/data'; import { Button, useStyles } from '@grafana/ui'; import { appEvents } from 'app/core/app_events'; import { OldPage } from 'app/core/components/Page/Page'; import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; -import { Table } from 'app/percona/shared/components/Elements/Table'; +import { ExtendedColumn, FilterFieldTypes, Table } from 'app/percona/shared/components/Elements/Table'; import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; import { getPerconaSettingFlag } from 'app/percona/shared/core/selectors'; import { logger } from 'app/percona/shared/helpers/logger'; @@ -19,7 +19,7 @@ import { RemoveStorageLocationModal } from './RemoveStorageLocationModal'; import { StorageLocationDetails } from './StorageLocationDetails'; import { StorageLocationsService } from './StorageLocations.service'; import { getStyles } from './StorageLocations.styles'; -import { StorageLocation } from './StorageLocations.types'; +import { LocationType, StorageLocation } from './StorageLocations.types'; import { formatLocationList, formatToRawLocation } from './StorageLocations.utils'; import { StorageLocationsActions } from './StorageLocationsActions'; @@ -34,24 +34,38 @@ export const StorageLocations: FC = () => { const navModel = usePerconaNavModel('storage-locations'); const styles = useStyles(getStyles); const columns = React.useMemo( - (): Array> => [ + (): Array> => [ { Header: Messages.storageLocations.table.columns.name, accessor: 'name', id: 'name', width: '315px', + type: FilterFieldTypes.TEXT, }, { Header: Messages.storageLocations.table.columns.type, accessor: 'type', width: '150px', + type: FilterFieldTypes.DROPDOWN, + options: [ + { + value: LocationType.S3, + label: LocationType.S3, + }, + { + value: LocationType.CLIENT, + label: LocationType.CLIENT, + }, + ], }, { Header: Messages.storageLocations.table.columns.path, accessor: 'path', + type: FilterFieldTypes.TEXT, }, { Header: Messages.storageLocations.table.columns.actions, + type: FilterFieldTypes.TEXT, accessor: 'locationID', Cell: ({ row }) => ( @@ -168,6 +182,7 @@ export const StorageLocations: FC = () => { pendingRequest={pending} renderExpandedRow={renderSelectedSubRow} getRowId={useCallback((row: StorageLocation) => row.locationID, [])} + showFilter >