diff --git a/package-lock.json b/package-lock.json index db4615668..c1b0199a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.21.0", + "version": "1.21.0-beta-14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.21.0", + "version": "1.21.0-beta-14", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -37,6 +37,7 @@ "nanoid": "^3.3.8", "qrcode.react": "^4.2.0", "react-canvas-confetti": "^2.0.7", + "react-csv": "^2.2.2", "react-dates": "^21.8.0", "react-draggable": "^4.4.5", "react-international-phone": "^4.5.0", @@ -57,6 +58,7 @@ "@types/dompurify": "^3.0.5", "@types/json-schema": "^7.0.15", "@types/react": "17.0.39", + "@types/react-csv": "^1.1.10", "@types/react-dates": "^21.8.6", "@types/react-dom": "17.0.13", "@types/react-router-dom": "^5.3.3", @@ -4132,6 +4134,16 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-csv": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@types/react-csv/-/react-csv-1.1.10.tgz", + "integrity": "sha512-PESAyASL7Nfi/IyBR3ufd8qZkyoS+7jOylKmJxRZUZLFASLo4NZaRsJ8rNP8pCcbIziADyWBbLPD1nPddhsL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dates": { "version": "21.8.6", "resolved": "https://registry.npmjs.org/@types/react-dates/-/react-dates-21.8.6.tgz", @@ -10479,6 +10491,12 @@ "react": "*" } }, + "node_modules/react-csv": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-csv/-/react-csv-2.2.2.tgz", + "integrity": "sha512-RG5hOcZKZFigIGE8LxIEV/OgS1vigFQT4EkaHeKgyuCbUAu9Nbd/1RYq++bJcJJ9VOqO/n9TZRADsXNDR4VEpw==", + "license": "MIT" + }, "node_modules/react-dates": { "version": "21.8.0", "resolved": "https://registry.npmjs.org/react-dates/-/react-dates-21.8.0.tgz", diff --git a/package.json b/package.json index 8a89ce40c..664b5abe0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.21.0", + "version": "1.21.0-beta-14", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", @@ -49,6 +49,7 @@ "@types/dompurify": "^3.0.5", "@types/json-schema": "^7.0.15", "@types/react": "17.0.39", + "@types/react-csv": "^1.1.10", "@types/react-dates": "^21.8.6", "@types/react-dom": "17.0.13", "@types/react-router-dom": "^5.3.3", @@ -126,6 +127,7 @@ "nanoid": "^3.3.8", "qrcode.react": "^4.2.0", "react-canvas-confetti": "^2.0.7", + "react-csv": "^2.2.2", "react-dates": "^21.8.0", "react-draggable": "^4.4.5", "react-international-phone": "^4.5.0", diff --git a/src/Assets/IconV2/ic-bg-cloud-vms.svg b/src/Assets/IconV2/ic-bg-cloud-vms.svg new file mode 100644 index 000000000..e9275a938 --- /dev/null +++ b/src/Assets/IconV2/ic-bg-cloud-vms.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Assets/IconV2/ic-bg-healthy-vms.svg b/src/Assets/IconV2/ic-bg-healthy-vms.svg new file mode 100644 index 000000000..3ec40bbd3 --- /dev/null +++ b/src/Assets/IconV2/ic-bg-healthy-vms.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Assets/IconV2/ic-bg-running-vms.svg b/src/Assets/IconV2/ic-bg-running-vms.svg new file mode 100644 index 000000000..6e5c7278f --- /dev/null +++ b/src/Assets/IconV2/ic-bg-running-vms.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Assets/IconV2/ic-bg-tenants.svg b/src/Assets/IconV2/ic-bg-tenants.svg new file mode 100644 index 000000000..e16b5fb6c --- /dev/null +++ b/src/Assets/IconV2/ic-bg-tenants.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index ce392ca05..05b492378 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -89,10 +89,15 @@ export const URLS = { GLOBAL_CONFIG_TEMPLATES_DEVTRON_APP_DETAIL: `${GLOBAL_CONFIG_TEMPLATES_DEVTRON_APP}/detail/:appId`, LICENSE_AUTH: '/license-auth', GLOBAL_CONFIG_EDIT_CLUSTER: '/global-config/cluster-env/edit/:clusterId', + // OBSERVABILITY OBSERVABILITY: OBSERVABILITY_ROOT, OBSERVABILITY_OVERVIEW: `${OBSERVABILITY_ROOT}/overview`, - OBSERVABILITY_CUSTOMER_LIST: `${OBSERVABILITY_ROOT}/tenants`, + OBSERVABILITY_TENANTS: `${OBSERVABILITY_ROOT}/tenants`, + OBSERVABILITY_TENANT_DETAILS: `${OBSERVABILITY_ROOT}/tenants/:tenantName`, + OBSERVABILITY_TENANT_OVERVIEW: `${OBSERVABILITY_ROOT}/tenants/:tenantName/overview`, + OBSERVABILITY_TENANT_VMS: `${OBSERVABILITY_ROOT}/tenants/:tenantName/vms`, + OBSERVABILITY_TENANT_VM_DETAILS: `${OBSERVABILITY_ROOT}/tenants/:tenantName/vms/:vmName`, } as const export const ROUTES = { @@ -424,6 +429,7 @@ export const DATE_TIME_FORMATS = { DD_MMM_YYYY_HH_MM: 'DD MMM YYYY, hh:mm', DD_MMM_YYYY: 'DD MMM YYYY', 'DD/MM/YYYY': 'DD/MM/YYYY', + FULL_DATE_WITH_TIME: 'DD-MM-YYYY hh:mm:ss', } export const SEMANTIC_VERSION_DOCUMENTATION_LINK = 'https://semver.org/' diff --git a/src/Shared/Components/DatePicker/DayPickerRangeController.tsx b/src/Shared/Components/DatePicker/DayPickerRangeController.tsx index 865789b43..40fa00621 100644 --- a/src/Shared/Components/DatePicker/DayPickerRangeController.tsx +++ b/src/Shared/Components/DatePicker/DayPickerRangeController.tsx @@ -24,7 +24,7 @@ import { ComponentSizeType } from '@Shared/constants' import 'react-dates/initialize' -import { Button } from '../Button' +import { Button, ButtonStyleType, ButtonVariantType } from '../Button' import { Icon } from '../Icon' import { customDayStyles, DayPickerCalendarInfoHorizontal, DayPickerRangeControllerPresets, styles } from './constants' import { DatePickerRangeControllerProps } from './types' @@ -141,15 +141,19 @@ export const DatePickerRangeController = ({ return ( <> -
-

{calendarValue}

- -
+ {...(calendarValue + ? { text: calendarValue, endIcon: } + : { + icon: , + ariaLabel: 'Show calendar', + showAriaLabelInTippy: false, + })} + variant={ButtonVariantType.secondary} + style={ButtonStyleType.neutral} + /> {showCalendar && ( d.endStr === startDateStr) - if (range) { - str = range.text - } else { - str = `${startDateStr} - ${endDateStr}` - } - } - return str -} diff --git a/src/Shared/Components/DatePicker/types.ts b/src/Shared/Components/DatePicker/types.ts index d8844e7d4..9c103f378 100644 --- a/src/Shared/Components/DatePicker/types.ts +++ b/src/Shared/Components/DatePicker/types.ts @@ -144,6 +144,8 @@ export interface DateTimePickerProps onChange: (date: Date) => void } +export type CalendarFocusInputType = 'startDate' | 'endDate' + export interface DatePickerRangeControllerProps { calendar calendarInputs diff --git a/src/Shared/Components/DatePicker/utils.tsx b/src/Shared/Components/DatePicker/utils.tsx index 5ef41b568..c8fa7522c 100644 --- a/src/Shared/Components/DatePicker/utils.tsx +++ b/src/Shared/Components/DatePicker/utils.tsx @@ -14,8 +14,10 @@ * limitations under the License. */ +import { prefixZeroIfSingleDigit } from '@Common/Helper' + import { SelectPickerOptionType } from '../SelectPicker' -import { MONTHLY_DATES_CONFIG, TIME_OPTIONS_CONFIG } from './constants' +import { DayPickerRangeControllerPresets, MONTHLY_DATES_CONFIG, TIME_OPTIONS_CONFIG } from './constants' /** * Return the options for the dates in label and value format @@ -113,3 +115,46 @@ export const getDefaultDateFromTimeToLive = (timeToLive: string, isTomorrow?: bo nextDate.setHours(hours, minutes, 0) return nextDate } + +/** + * Returns a string representing the range of dates + * given by the start and end dates. If the end date + * is 'now' and the start date includes 'now', + * it will return the corresponding range from the + * DayPickerRangeControllerPresets array. + * @param startDateStr - the start date string + * @param endDateStr - the end date string + * @returns - a string representing the range of dates + */ + +export const getCalendarValue = (startDateStr: string, endDateStr: string): string => { + let str: string = `${startDateStr} - ${endDateStr}` + if (endDateStr === 'now' && startDateStr.includes('now')) { + const range = DayPickerRangeControllerPresets.find((d) => d.endStr === startDateStr) + if (range) { + str = range.text + } else { + str = `${startDateStr} - ${endDateStr}` + } + } + return str +} + +// Need to send either the relative time like: now-5m or the timestamp to grafana +// Assuming format is 'DD-MM-YYYY hh:mm:ss' +export const getTimestampFromDateIfAvailable = (dateString: string): string => { + try { + const [day, month, yearAndTime] = dateString.split('-') + const [year, time] = yearAndTime.split(' ') + const updatedTime = time + .split(':') + .map((item) => (['0', '00'].includes(item) ? '00' : prefixZeroIfSingleDigit(Number(item)))) + .join(':') + const formattedDate = `${year}-${prefixZeroIfSingleDigit(Number(month))}-${prefixZeroIfSingleDigit(Number(day))}T${updatedTime}` + const parsedDate = new Date(formattedDate).getTime() + + return Number.isNaN(parsedDate) ? dateString : parsedDate.toString() + } catch { + return dateString + } +} diff --git a/src/Shared/Components/ExportToCsv/ExportToCsv.tsx b/src/Shared/Components/ExportToCsv/ExportToCsv.tsx new file mode 100644 index 000000000..7e1b6914d --- /dev/null +++ b/src/Shared/Components/ExportToCsv/ExportToCsv.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef, useState } from 'react' +import { CSVLink } from 'react-csv' +import moment from 'moment' + +import { getIsRequestAborted } from '@Common/API' +import { DATE_TIME_FORMATS } from '@Common/Constants' +import { showError } from '@Common/Helper' +import { ServerErrors } from '@Common/ServerError' +import { ALLOW_ACTION_OUTSIDE_FOCUS_TRAP, ComponentSizeType } from '@Shared/constants' +import { isNullOrUndefined } from '@Shared/Helpers' + +import { Button, ButtonVariantType } from '../Button' +import { Icon } from '../Icon' +import ExportToCsvDialog from './ExportToCsvDialog' +import { ExportToCsvProps } from './types' + +const ExportToCsv = ({ + apiPromise, + fileName, + triggerElementConfig, + disabled, + modalConfig, + headers, + downloadRequestId, +}: ExportToCsvProps) => { + const csvRef = useRef(null) + const abortControllerRef = useRef(new AbortController()) + + const [dataToExport, setDataToExport] = useState>>([]) + const [confirmationModalType, setConfirmationModalType] = useState<'default' | 'custom' | null>(null) + const [isLoading, setIsLoading] = useState(false) + const [dataFetchError, setDataFetchError] = useState(null) + + const handleInitiateDownload = async () => { + if (disabled) { + return + } + + try { + setIsLoading(true) + setDataFetchError(null) + const data = await apiPromise({ signal: abortControllerRef.current.signal }) + setDataToExport(data) + + csvRef.current?.link?.click() + } catch (error) { + if (!getIsRequestAborted(error)) { + showError(error) + setDataFetchError(error) + } + } finally { + abortControllerRef.current = new AbortController() + setIsLoading(false) + } + } + + const handleExportButtonClick = async () => { + if (!modalConfig?.hideDialog) { + setConfirmationModalType(!modalConfig ? 'default' : 'custom') + } + + if (!modalConfig || modalConfig.hideDialog) { + await handleInitiateDownload() + } + } + + useEffect( + () => () => { + abortControllerRef.current.abort() + }, + [], + ) + + useEffect(() => { + if (!isNullOrUndefined(downloadRequestId)) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + handleExportButtonClick() + } + }, [downloadRequestId]) + + const handleCancelRequest = () => { + abortControllerRef.current.abort() + abortControllerRef.current = new AbortController() + setConfirmationModalType(null) + setIsLoading(false) + } + + const renderTriggerButton = () => { + if (!triggerElementConfig || triggerElementConfig.showOnlyIcon) { + return ( + + ) + } + + return null + } + + const proceedWithDownloadFromCustomModal = async (shouldProceed: boolean) => { + if (!shouldProceed) { + setConfirmationModalType(null) + return + } + + setConfirmationModalType('default') + await handleInitiateDownload() + } + + const renderModal = () => { + if (!confirmationModalType || modalConfig?.hideDialog) { + return null + } + + if (confirmationModalType === 'custom' && modalConfig?.renderCustomModal) { + return modalConfig.renderCustomModal(proceedWithDownloadFromCustomModal) + } + + return ( + + ) + } + + return ( +
+ {renderTriggerButton()} + + + + {renderModal()} +
+ ) +} + +export default ExportToCsv diff --git a/src/Shared/Components/ExportToCsv/ExportToCsvDialog.tsx b/src/Shared/Components/ExportToCsv/ExportToCsvDialog.tsx new file mode 100644 index 000000000..2536d5e04 --- /dev/null +++ b/src/Shared/Components/ExportToCsv/ExportToCsvDialog.tsx @@ -0,0 +1,84 @@ +import { DetailsProgressing, VisibleModal } from '@Common/index' +import { ComponentSizeType } from '@Shared/constants' + +import { Button, ButtonStyleType, ButtonVariantType } from '../Button' +import { GenericSectionErrorState } from '../GenericSectionErrorState' +import { Icon } from '../Icon' +import { ExportToCsvDialogProps } from './types' + +const ExportToCsvDialog = ({ + exportDataError, + isLoading, + initiateDownload, + handleCancelRequest, +}: ExportToCsvDialogProps) => { + const renderExportStatus = () => { + if (exportDataError) { + return ( + + ) + } + + if (isLoading) { + return ( + + Please do not reload or press the browser back button. + + ) + } + + return ( +
+ + Your export is ready + If download does not start automatically, +
+ ) + } + + const renderModalCTA = () => ( + <> +