From 3fcf848ca8e40c9d64a3cb8ea692d109fc812503 Mon Sep 17 00:00:00 2001 From: Arun Jain Date: Fri, 5 Dec 2025 11:59:17 +0530 Subject: [PATCH 1/4] feat: Refactor DatePicker UI to use Button component, add new date utility functions, introduce observability routes, and include new SVG icons. --- src/Assets/IconV2/ic-bg-cloud-vms.svg | 4 ++ src/Assets/IconV2/ic-bg-healthy-vms.svg | 4 ++ src/Assets/IconV2/ic-bg-running-vms.svg | 4 ++ src/Assets/IconV2/ic-bg-tenants.svg | 4 ++ src/Common/Constants.ts | 8 +++- .../DatePicker/DayPickerRangeController.tsx | 22 +++++---- src/Shared/Components/DatePicker/constants.ts | 24 ---------- src/Shared/Components/DatePicker/types.ts | 2 + src/Shared/Components/DatePicker/utils.tsx | 47 ++++++++++++++++++- src/Shared/Components/Icon/Icon.tsx | 8 ++++ 10 files changed, 92 insertions(+), 35 deletions(-) create mode 100644 src/Assets/IconV2/ic-bg-cloud-vms.svg create mode 100644 src/Assets/IconV2/ic-bg-healthy-vms.svg create mode 100644 src/Assets/IconV2/ic-bg-running-vms.svg create mode 100644 src/Assets/IconV2/ic-bg-tenants.svg 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/Icon/Icon.tsx b/src/Shared/Components/Icon/Icon.tsx index 9126256d7..9b3bc96d1 100644 --- a/src/Shared/Components/Icon/Icon.tsx +++ b/src/Shared/Components/Icon/Icon.tsx @@ -17,8 +17,12 @@ import { ReactComponent as ICAther } from '@IconsV2/ic-ather.svg' import { ReactComponent as ICAwsCodecommit } from '@IconsV2/ic-aws-codecommit.svg' import { ReactComponent as ICAzure } from '@IconsV2/ic-azure.svg' import { ReactComponent as ICAzureAks } from '@IconsV2/ic-azure-aks.svg' +import { ReactComponent as ICBgCloudVms } from '@IconsV2/ic-bg-cloud-vms.svg' import { ReactComponent as ICBgCluster } from '@IconsV2/ic-bg-cluster.svg' import { ReactComponent as ICBgEnvironment } from '@IconsV2/ic-bg-environment.svg' +import { ReactComponent as ICBgHealthyVms } from '@IconsV2/ic-bg-healthy-vms.svg' +import { ReactComponent as ICBgRunningVms } from '@IconsV2/ic-bg-running-vms.svg' +import { ReactComponent as ICBgTenants } from '@IconsV2/ic-bg-tenants.svg' import { ReactComponent as ICBharatpe } from '@IconsV2/ic-bharatpe.svg' import { ReactComponent as ICBinoculars } from '@IconsV2/ic-binoculars.svg' import { ReactComponent as ICBitbucket } from '@IconsV2/ic-bitbucket.svg' @@ -262,8 +266,12 @@ export const iconMap = { 'ic-aws-codecommit': ICAwsCodecommit, 'ic-azure-aks': ICAzureAks, 'ic-azure': ICAzure, + 'ic-bg-cloud-vms': ICBgCloudVms, 'ic-bg-cluster': ICBgCluster, 'ic-bg-environment': ICBgEnvironment, + 'ic-bg-healthy-vms': ICBgHealthyVms, + 'ic-bg-running-vms': ICBgRunningVms, + 'ic-bg-tenants': ICBgTenants, 'ic-bharatpe': ICBharatpe, 'ic-binoculars': ICBinoculars, 'ic-bitbucket': ICBitbucket, From 4996cd165eef34861dd816c991e9654bf436899d Mon Sep 17 00:00:00 2001 From: Arun Jain Date: Sat, 6 Dec 2025 00:21:25 +0530 Subject: [PATCH 2/4] feat: Introduce ExportToCsv component and dialog for data export functionality --- package-lock.json | 18 ++ package.json | 2 + .../Components/ExportToCsv/ExportToCsv.tsx | 183 ++++++++++++++++++ .../ExportToCsv/ExportToCsvDialog.tsx | 84 ++++++++ src/Shared/Components/ExportToCsv/index.ts | 2 + src/Shared/Components/ExportToCsv/types.ts | 72 +++++++ src/Shared/Components/index.ts | 1 + src/Shared/types.ts | 14 +- 8 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 src/Shared/Components/ExportToCsv/ExportToCsv.tsx create mode 100644 src/Shared/Components/ExportToCsv/ExportToCsvDialog.tsx create mode 100644 src/Shared/Components/ExportToCsv/index.ts create mode 100644 src/Shared/Components/ExportToCsv/types.ts diff --git a/package-lock.json b/package-lock.json index db4615668..89378e575 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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..d6fb58479 100644 --- a/package.json +++ b/package.json @@ -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/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 = () => ( + <> +