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
+ ? { 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 (
+ ,
+ ariaLabel: 'Export CSV',
+ showAriaLabelInTippy: false,
+ }
+ : {
+ text: 'Export CSV',
+ startIcon: ,
+ })}
+ onClick={handleExportButtonClick}
+ size={ComponentSizeType.medium}
+ variant={ButtonVariantType.secondary}
+ dataTestId="export-csv-button"
+ isLoading={isLoading}
+ disabled={disabled}
+ showTooltip={disabled}
+ tooltipProps={{
+ content: 'Nothing to export',
+ }}
+ />
+ )
+ }
+
+ if (triggerElementConfig.buttonProps) {
+ return
+ }
+
+ if (triggerElementConfig.customButton) {
+ 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 = () => (
+ <>
+
+ {!isLoading && exportDataError && (
+
+ )}
+ >
+ )
+
+ return (
+
+
+
Export to CSV
+
{renderExportStatus()}
+
{renderModalCTA()}
+
+
+ )
+}
+
+export default ExportToCsvDialog
diff --git a/src/Shared/Components/ExportToCsv/index.ts b/src/Shared/Components/ExportToCsv/index.ts
new file mode 100644
index 000000000..82193b2f9
--- /dev/null
+++ b/src/Shared/Components/ExportToCsv/index.ts
@@ -0,0 +1,2 @@
+export { default as ExportToCsv } from './ExportToCsv'
+export * from './types'
diff --git a/src/Shared/Components/ExportToCsv/types.ts b/src/Shared/Components/ExportToCsv/types.ts
new file mode 100644
index 000000000..e72b87bc7
--- /dev/null
+++ b/src/Shared/Components/ExportToCsv/types.ts
@@ -0,0 +1,72 @@
+import { ServerErrors } from '@Common/ServerError'
+import { APIOptions } from '@Common/Types'
+
+import { ButtonProps } from '../Button'
+
+type TriggerElementConfigType =
+ | {
+ buttonProps: ButtonProps
+ showOnlyIcon?: boolean
+ customButton?: never
+ isExternalTrigger?: false
+ }
+ | {
+ customButton: {
+ content: JSX.Element
+ className: string
+ }
+ buttonProps?: never
+ showOnlyIcon?: false
+ isExternalTrigger?: false
+ }
+ | {
+ isExternalTrigger: true
+ showOnlyIcon?: false
+ buttonProps?: never
+ customButton?: never
+ }
+ | {
+ showOnlyIcon: true
+ buttonProps?: never
+ customButton?: never
+ isExternalTrigger?: false
+ }
+
+export interface ExportToCsvProps {
+ headers: { label: string; key: HeaderItemKeyType }[]
+ apiPromise: ({
+ signal,
+ }: Pick) => Promise[]>
+ fileName: string
+ /**
+ * If nothing given will render a Button with "Export CSV" text
+ */
+ triggerElementConfig?: TriggerElementConfigType
+ /**
+ * @default false
+ */
+ disabled?: boolean
+ /**
+ * If not given would show default dialog
+ */
+ modalConfig?:
+ | {
+ hideDialog: false
+ renderCustomModal?: (proceedWithDownload: (shouldProceed: boolean) => void) => JSX.Element
+ }
+ | {
+ hideDialog: true
+ renderCustomModal?: never
+ }
+ /**
+ * If given, would trigger export on when this changes and has some value
+ */
+ downloadRequestId?: string | number
+}
+
+export interface ExportToCsvDialogProps {
+ isLoading: boolean
+ exportDataError: ServerErrors
+ initiateDownload: () => void
+ handleCancelRequest: () => void
+}
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,
diff --git a/src/Shared/Components/index.ts b/src/Shared/Components/index.ts
index 0cd4af28e..325935d52 100644
--- a/src/Shared/Components/index.ts
+++ b/src/Shared/Components/index.ts
@@ -52,6 +52,7 @@ export * from './EditImageFormField'
export * from './EnvironmentSelector'
export * from './Error'
export * from './ExcludedImageNode'
+export * from './ExportToCsv'
export * from './FeatureDescription'
export * from './FileUpload'
export * from './FilterChips'
diff --git a/src/Shared/types.ts b/src/Shared/types.ts
index c4b758b5f..10edb2844 100644
--- a/src/Shared/types.ts
+++ b/src/Shared/types.ts
@@ -18,8 +18,8 @@ import { ReactNode } from 'react'
import { ParsedCountry } from 'react-international-phone'
import { Dayjs } from 'dayjs'
-import { APIOptions, ApprovalConfigDataType, Strategy } from '@Common/Types'
-import { ReleaseMode } from '@Pages/index'
+import { ActionTypes, APIOptions, ApprovalConfigDataType, Strategy } from '@Common/Types'
+import { ReleaseMode, UserStatus } from '@Pages/index'
import {
CommonNodeAttr,
@@ -775,6 +775,7 @@ export enum EntityTypes {
GIT = 'git',
CLUSTER = 'cluster',
NOTIFICATION = 'notification',
+ OBSERVABILITY = 'observe',
}
export interface CustomRoles {
@@ -1343,3 +1344,12 @@ export interface PipelineDeploymentStrategy {
strategies: Strategy[]
error: ServerError
}
+
+export interface ObservabilityPermissionFilter {
+ entityName: OptionType[]
+ action: ActionTypes.VIEW | ActionTypes.ADMIN
+ tenant: OptionType
+ entityNameError?: boolean
+ status: UserStatus
+ timeToLive: string
+}