From 563ba5e1b6fd0ca40484381eca85cef985839e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfonso=20Mart=C3=ADnez?= Date: Wed, 26 Jun 2024 17:12:28 +0200 Subject: [PATCH] Set storage quota on storage clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Alfonso Martínez --- locales/en/plugin__odf-console.json | 38 +- .../capacity-trend-card.tsx | 5 +- .../raw-capacity-card/raw-capacity-card.tsx | 39 +- .../storage-consumers/client-list.scss | 14 + .../storage-consumers/client-list.tsx | 187 +++++++-- .../storage-consumers/onboarding-modal.scss | 31 ++ .../storage-consumers/onboarding-modal.tsx | 356 ++++++++++++++---- .../storage-consumers/remove-client-modal.tsx | 34 +- .../update-storage-quota-modal.tsx | 191 ++++++++++ packages/odf/constants/tooltips.tsx | 5 + packages/odf/hooks/index.ts | 1 + packages/odf/hooks/useRawCapacity.ts | 35 ++ packages/odf/types/index.ts | 1 + packages/odf/types/storage-consumer.ts | 6 + packages/odf/utils/storage.ts | 31 +- .../shared/src/generic/FieldLevelHelp.tsx | 4 +- packages/shared/src/status/icons.tsx | 12 + packages/shared/src/types/storage.ts | 2 + packages/shared/src/utils/metrics.ts | 4 + tsconfig.json | 2 + 20 files changed, 838 insertions(+), 160 deletions(-) create mode 100644 packages/odf/components/storage-consumers/client-list.scss create mode 100644 packages/odf/components/storage-consumers/update-storage-quota-modal.tsx create mode 100644 packages/odf/hooks/useRawCapacity.ts create mode 100644 packages/odf/types/storage-consumer.ts diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index 4a4dbb203..24676d19d 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -1102,32 +1102,56 @@ "NamespaceStore details": "NamespaceStore details", "Target Blob Container": "Target Blob Container", "Num Volumes": "Num Volumes", - "Cluster ID": "Cluster ID", + "<0>The amount of storage allocated to the client cluster for usage.<1>Due to simultaneous usage by multiple client clusters, actual available storage may vary affecting your allocated storage quota.": "<0>The amount of storage allocated to the client cluster for usage.<1>Due to simultaneous usage by multiple client clusters, actual available storage may vary affecting your allocated storage quota.", + "No storage clients found.": "No storage clients found.", + "You do not have any storage clients connected to this Data Foundation provider cluster.": "You do not have any storage clients connected to this Data Foundation provider cluster.", + "To connect a storage client to the Data Foundation provider cluster, click <2>Generate client onboarding token and use the token to deploy the client cluster.": "To connect a storage client to the Data Foundation provider cluster, click <2>Generate client onboarding token and use the token to deploy the client cluster.", + "Cluster name (ID)": "Cluster name (ID)", + "Storage quota": "Storage quota", + "Used capacity is the amount of storage consumed by the client.": "Used capacity is the amount of storage consumed by the client.", "Openshift version": "Openshift version", "Data Foundation version": "Data Foundation version", "Last heartbeat": "Last heartbeat", "ago": "ago", "Client version is out of date": "Client version is out of date", "Due to the mismatch in the client and provider version this provider cluster cannot be upgraded.": "Due to the mismatch in the client and provider version this provider cluster cannot be upgraded.", + "Unlimited": "Unlimited", + "Edit storage quota": "Edit storage quota", + "Delete storage client": "Delete storage client", "Storage clients": "Storage clients", "Generate client onboarding token": "Generate client onboarding token", "Rotate signing keys": "Rotate signing keys", "Data Foundation version sync": "Data Foundation version sync", "Client onboarding token": "Client onboarding token", + "Add storage capacity for the client cluster to consume from the provider cluster.": "Add storage capacity for the client cluster to consume from the provider cluster.", "Can not generate an onboarding token at the moment": "Can not generate an onboarding token at the moment", "The token generation service is currently unavailable. Contact our <2>customer support for further help.": "The token generation service is currently unavailable. Contact our <2>customer support for further help.", + "Generating token": "Generating token", + "Generate token": "Generate token", + "Custom": "Custom", + "Storage quota:": "Storage quota:", + "Limit the amount of storage that a client cluster can consume.": "Limit the amount of storage that a client cluster can consume.", + "Allocate quota": "Allocate quota", + "Storage quota cannot be decreased. Assign a quota higher than your current allocation.": "Storage quota cannot be decreased. Assign a quota higher than your current allocation.", + "No specific limit on storage that a client can consume.": "No specific limit on storage that a client can consume.", + "Changing the storage quota from unlimited to custom is not supported after the client cluster is onboarded.": "Changing the storage quota from unlimited to custom is not supported after the client cluster is onboarded.", + "unlimited": "unlimited", + "Generated on": "Generated on", + "On an OpenShift cluster, deploy the Data Foundation client operator using the generated token. The token includes an <2>{quotaText} storage quota for client consumption.": "On an OpenShift cluster, deploy the Data Foundation client operator using the generated token. The token includes an <2>{quotaText} storage quota for client consumption.", "Copy to clipboard": "Copy to clipboard", - "How to use this token?": "How to use this token?", - "An onboarding token is needed to connect an additional OpenShift cluster to a Data Foundation deployment. Copy the generated token and use it for deploying Data Foundation client operator on your OpenShift cluster.": "An onboarding token is needed to connect an additional OpenShift cluster to a Data Foundation deployment. Copy the generated token and use it for deploying Data Foundation client operator on your OpenShift cluster.", - "This token is valid for 48 hours and can only be used once.": "This token is valid for 48 hours and can only be used once.", + "This token is for one-time use only and is valid for 48 hours.": "This token is for one-time use only and is valid for 48 hours.", "Permanently delete storage client?": "Permanently delete storage client?", - "Deleting the storage client {getName(resource)} will remove all Ceph/Rook resources and erase all data associated with this client, leading to permanent deletion of the client. This action cannot be undone. It will destroy all pods, services and other objects in the namespace <4>{{name}}.": "Deleting the storage client {getName(resource)} will remove all Ceph/Rook resources and erase all data associated with this client, leading to permanent deletion of the client. This action cannot be undone. It will destroy all pods, services and other objects in the namespace <4>{{name}}.", - "Confirm deletion by typing <1>{{name}} below:": "Confirm deletion by typing <1>{{name}} below:", + "Deleting the storage client <2>{getName(resource)} will remove all Ceph/Rook resources and erase all data associated with this client, leading to permanent deletion of the client. This action cannot be undone. It will destroy all pods, services and other objects in the namespace <5>{{name}}.": "Deleting the storage client <2>{getName(resource)} will remove all Ceph/Rook resources and erase all data associated with this client, leading to permanent deletion of the client. This action cannot be undone. It will destroy all pods, services and other objects in the namespace <5>{{name}}.", + "Confirm deletion by typing <2>{{name}} below:": "Confirm deletion by typing <2>{{name}} below:", "Enter name": "Enter name", "Type client name to confirm": "Type client name to confirm", "This action will rotate the signing key currently used for generating and validating client onboarding tokens.": "This action will rotate the signing key currently used for generating and validating client onboarding tokens.", "Upon rotation, the existing signing key will be revoked and replaced with a new one.": "Upon rotation, the existing signing key will be revoked and replaced with a new one.", "Confirm": "Confirm", + "Storage quota request failed. Make sure your Data Foundation provider cluster has enough capacity before trying again.": "Storage quota request failed. Make sure your Data Foundation provider cluster has enough capacity before trying again.", + "Save changes": "Save changes", + "Cluster capacity not available at this moment.": "Cluster capacity not available at this moment.", + "Available capacity": "Available capacity", "Raw Capacity": "Raw Capacity", "Add Capacity": "Add Capacity", "Cluster details": "Cluster details", @@ -1193,6 +1217,7 @@ "Storage capacity utilised from the external object storage provider.": "Storage capacity utilised from the external object storage provider.", "<0>What are the different performance profiles I can use to configure performance?<1>Performance profiles types:<2><0>Balanced mode: Optimized for right amount of CPU and memory resources to support diverse workloads.<3><0>Lean mode: Minimizes resource consumption by allocating fewer CPUs and less memory for resource-efficient operations.<4><0>Performance mode: Tailored for high-performance, allocating ample CPUs and memory to ensure optimal execution of demanding workloads.": "<0>What are the different performance profiles I can use to configure performance?<1>Performance profiles types:<2><0>Balanced mode: Optimized for right amount of CPU and memory resources to support diverse workloads.<3><0>Lean mode: Minimizes resource consumption by allocating fewer CPUs and less memory for resource-efficient operations.<4><0>Performance mode: Tailored for high-performance, allocating ample CPUs and memory to ensure optimal execution of demanding workloads.", "For enhanced performance of the Data Foundation cluster, the number of CPUs and memory resources are determined based on the cluster environment, size and various other factors.": "For enhanced performance of the Data Foundation cluster, the number of CPUs and memory resources are determined based on the cluster environment, size and various other factors.", + "An onboarding token to authenticate and authorize an OpenShift cluster, granting access to the Data Foundation deployment, thus establishing a secure connection.": "An onboarding token to authenticate and authorize an OpenShift cluster, granting access to the Data Foundation deployment, thus establishing a secure connection.", "Backing Store": "Backing Store", "Bucket Class": "Bucket Class", "Namespace Store": "Namespace Store", @@ -1240,7 +1265,6 @@ "and": "and", "GiB RAM": "GiB RAM", "Configure Performance": "Configure Performance", - "Save changes": "Save changes", "hr": "hr", "min": "min", "Select at least 2 Backing Store resources": "Select at least 2 Backing Store resources", diff --git a/packages/ocs/dashboards/persistent-internal/capacity-trend-card/capacity-trend-card.tsx b/packages/ocs/dashboards/persistent-internal/capacity-trend-card/capacity-trend-card.tsx index 0ebe6e229..613de700d 100644 --- a/packages/ocs/dashboards/persistent-internal/capacity-trend-card/capacity-trend-card.tsx +++ b/packages/ocs/dashboards/persistent-internal/capacity-trend-card/capacity-trend-card.tsx @@ -17,12 +17,11 @@ import { import { ConfigMapModel } from '@odf/shared/models'; import { ConfigMapKind } from '@odf/shared/types'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; -import { getInstantVectorStats, humanizeBinaryBytes } from '@odf/shared/utils'; +import { humanizeBinaryBytes, parser } from '@odf/shared/utils'; import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; import { TFunction } from 'i18next'; import { Trans } from 'react-i18next'; import { useParams } from 'react-router-dom-v5-compat'; -import { compose } from 'redux'; import { Card, CardBody, @@ -40,8 +39,6 @@ import { } from '../../../queries/ceph-storage'; import { ODFSystemParams } from '../../../types'; -const parser = compose((val) => val?.[0]?.y, getInstantVectorStats); - const calculateDaysUp = (timespan: number): number | null => { const daysPassed: number = timespan / (60 * 60 * 24); diff --git a/packages/ocs/dashboards/persistent-internal/raw-capacity-card/raw-capacity-card.tsx b/packages/ocs/dashboards/persistent-internal/raw-capacity-card/raw-capacity-card.tsx index 141c9d0d1..f3b7cb864 100644 --- a/packages/ocs/dashboards/persistent-internal/raw-capacity-card/raw-capacity-card.tsx +++ b/packages/ocs/dashboards/persistent-internal/raw-capacity-card/raw-capacity-card.tsx @@ -1,53 +1,24 @@ import * as React from 'react'; +import { useRawCapacity } from '@odf/core/hooks'; import { useODFSystemFlagsSelector } from '@odf/core/redux'; -import { - useCustomPrometheusPoll, - usePrometheusBasePath, -} from '@odf/shared/hooks/custom-prometheus-poll'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; -import { getInstantVectorStats } from '@odf/shared/utils'; +import { parser } from '@odf/shared/utils'; import { useParams } from 'react-router-dom-v5-compat'; -import { compose } from 'redux'; -import { - CAPACITY_INFO_QUERIES, - StorageDashboardQuery, -} from '../../../queries/ceph-storage'; import { ODFSystemParams } from '../../../types'; import { CapacityCard, CapacityCardProps, } from '../../common/capacity-card/capacity-card'; -// Enchance instantVectorStats to directly parse the values (else loading state won't be accurate) -const parser = compose((val) => val?.[0]?.y, getInstantVectorStats); - const RawCapacityCard: React.FC = () => { const { t } = useCustomTranslation(); const { namespace: clusterNs } = useParams(); const { systemFlags } = useODFSystemFlagsSelector(); - const managedByOCS = systemFlags[clusterNs]?.ocsClusterName; - - const [totalCapacity, totalError, totalLoading] = useCustomPrometheusPoll({ - query: - CAPACITY_INFO_QUERIES(managedByOCS)[ - StorageDashboardQuery.RAW_CAPACITY_TOTAL - ], - endpoint: 'api/v1/query' as any, - basePath: usePrometheusBasePath(), - }); - const [usedCapacity, usedError, usedLoading] = useCustomPrometheusPoll({ - query: - CAPACITY_INFO_QUERIES(managedByOCS)[ - StorageDashboardQuery.RAW_CAPACITY_USED - ], - endpoint: 'api/v1/query' as any, - basePath: usePrometheusBasePath(), - }); - - const loadError = totalError || usedError; + const clusterName = systemFlags[clusterNs]?.ocsClusterName; - const loading = usedLoading || totalLoading; + const [totalCapacity, usedCapacity, loading, loadError] = + useRawCapacity(clusterName); const totalCapacityMetric = parser(totalCapacity); const usedCapacityMetric = parser(usedCapacity); diff --git a/packages/odf/components/storage-consumers/client-list.scss b/packages/odf/components/storage-consumers/client-list.scss new file mode 100644 index 000000000..d02b3aa1e --- /dev/null +++ b/packages/odf/components/storage-consumers/client-list.scss @@ -0,0 +1,14 @@ +.odf-storage-client-list__no-client-msg { + margin-top: 12vh; +} + +.odf-storage-client-list__no-client-msg-text { + color: var(--pf-v5-global--disabled-color--100); + text-align: center; + width: 43vw; +} + +.odf-storage-client-list__no-client-msg-icon { + width: var(--pf-v5-global--icon--FontSize--xl); + height: var(--pf-v5-global--icon--FontSize--xl); +} diff --git a/packages/odf/components/storage-consumers/client-list.tsx b/packages/odf/components/storage-consumers/client-list.tsx index c80168ff3..224a46b1a 100644 --- a/packages/odf/components/storage-consumers/client-list.tsx +++ b/packages/odf/components/storage-consumers/client-list.tsx @@ -1,11 +1,16 @@ import * as React from 'react'; -import { Kebab } from '@odf/shared'; +import { + DiskSize as QuotaSize, + diskSizeUnitOptions as QuotaSizeUnitOptions, +} from '@odf/core/constants'; +import { GrayInfoCircleIcon, Kebab } from '@odf/shared'; import { ODF_OPERATOR } from '@odf/shared/constants/common'; import { getTimeDifferenceInSeconds } from '@odf/shared/details-page/datetime'; import { useFetchCsv } from '@odf/shared/hooks'; +import { ModalKeys } from '@odf/shared/modals'; import { StorageConsumerKind } from '@odf/shared/types'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; -import { getOprVersionFromCSV } from '@odf/shared/utils'; +import { getOprVersionFromCSV, humanizeBinaryBytes } from '@odf/shared/utils'; import { referenceForModel } from '@odf/shared/utils/common'; import { K8sResourceKind, @@ -26,13 +31,14 @@ import { } from '@openshift-console/dynamic-plugin-sdk'; import { ModalComponent } from '@openshift-console/dynamic-plugin-sdk/lib/app/modal-support/ModalProvider'; import * as _ from 'lodash-es'; +import { Trans } from 'react-i18next'; import { Button, - ButtonVariant, + Flex, + FlexItem, Popover, PopoverPosition, } from '@patternfly/react-core'; -import { TrashIcon } from '@patternfly/react-icons'; import { sortable } from '@patternfly/react-table'; import { StorageConsumerModel } from '../../models'; import { useODFNamespaceSelector } from '../../redux'; @@ -42,17 +48,78 @@ import { versionMismatchFilter, } from './list-filter'; import { ClientOnBoardingModal } from './onboarding-modal'; -import { RemoveClientModal } from './remove-client-modal'; import { RotateKeysModal } from './rotate-keys-modal'; +import './client-list.scss'; + +const StorageQuotaPopoverContent: React.FC = () => { + const { t } = useCustomTranslation(); + + return ( + +

+ The amount of storage allocated to the client cluster for usage. +

+

+ Due to simultaneous usage by multiple client clusters, actual available + storage may vary affecting your allocated storage quota. +

+
+ ); +}; + +const NoClientsMessage: React.FC = () => { + const { t } = useCustomTranslation(); + return ( + <> + + + + + + {t('No storage clients found.')} + + + {t( + 'You do not have any storage clients connected to this Data Foundation provider cluster.' + )} + + + + To connect a storage client to the Data Foundation provider cluster, + click{' '} + + Generate client onboarding token + {' '} + and use the token to deploy the client cluster. + + + + + ); +}; const tableColumns = [ { className: '', id: 'name', }, + { + className: 'pf-m-width-20', + id: 'clusterName', + }, { className: '', - id: 'clusterID', + id: 'storageQuota', + }, + { + className: '', + id: 'usedCapacity', }, { className: '', @@ -96,46 +163,68 @@ const ClientsList: React.FC = (props) => { id: tableColumns[0].id, }, { - title: t('Cluster ID'), - sort: 'status.client.clusterId', + title: t('Cluster name (ID)'), + sort: 'status.client.clusterName', props: { className: tableColumns[1].className, }, id: tableColumns[1].id, }, + { + title: t('Storage quota'), + sort: 'status.storageQuotaInGiB', + props: { + className: tableColumns[2].className, + info: { popover: }, + }, + id: tableColumns[2].id, + }, + { + title: t('Used capacity'), + sort: 'status.usedCapacityInGiB', + props: { + className: tableColumns[3].className, + info: { + popover: t( + 'Used capacity is the amount of storage consumed by the client.' + ), + }, + }, + id: tableColumns[3].id, + }, { title: t('Openshift version'), sort: 'status.client.platformVersion', transforms: [sortable], props: { - className: tableColumns[2].className, + className: tableColumns[4].className, }, - id: tableColumns[2].id, + id: tableColumns[4].id, }, { title: t('Data Foundation version'), sort: 'status.client.operatorVersion', transforms: [sortable], props: { - className: tableColumns[3].className, + className: tableColumns[5].className, }, - id: tableColumns[3].id, + id: tableColumns[5].id, }, { title: t('Last heartbeat'), sort: 'status.lastHeartbeat', transforms: [sortable], props: { - className: tableColumns[4].className, + className: tableColumns[6].className, }, - id: tableColumns[4].id, + id: tableColumns[6].id, }, { title: '', props: { - className: tableColumns[5].className, + className: tableColumns[7].className, }, - id: tableColumns[5].id, + id: tableColumns[7].id, }, ], [t] @@ -153,6 +242,7 @@ const ClientsList: React.FC = (props) => { aria-label={t('Storage Clients')} columns={columns} Row={StorageClientRow} + NoDataEmptyMsg={NoClientsMessage} /> ); }; @@ -196,7 +286,7 @@ type DataFoundationVersionProps = { currentVersion: string; }; -const DataFoudationVersion: React.FC = ({ +const DataFoundationVersion: React.FC = ({ obj, currentVersion, }) => { @@ -240,12 +330,24 @@ const StorageClientRow: React.FC< StorageConsumerKind, { currentVersion: string; - deleteClient: (resource: StorageConsumerKind) => void; } > -> = ({ obj, activeColumnIDs, rowData: { currentVersion, deleteClient } }) => { +> = ({ obj, activeColumnIDs, rowData: { currentVersion } }) => { + const { t } = useCustomTranslation(); const [allowDeletion, setAllowDeletion] = React.useState(false); const DELETE_THRESHOLD = 300; // wait till 5 minutes before activating the delete button + const humanizedStorageQuota = obj?.spec?.storageQuotaInGiB + ? humanizeBinaryBytes( + obj?.spec?.storageQuotaInGiB, + QuotaSizeUnitOptions[QuotaSize.Gi] + ).string + : t('Unlimited'); + const humanizedUsedCapacity = obj?.spec?.storageQuotaInGiB + ? humanizeBinaryBytes( + obj?.status?.usedCapacityInGiB, + QuotaSizeUnitOptions[QuotaSize.Gi] + ).string + : '-'; React.useEffect(() => { const setter = () => { @@ -268,25 +370,46 @@ const StorageClientRow: React.FC< {obj?.status?.client?.name || '-'} + {obj?.status?.client?.clusterName || '-'} + {' ('} {obj?.status?.client?.clusterId || '-'} + {')'} - {getOpenshiftVersion(obj) || '-'} + {humanizedStorageQuota} - + {humanizedUsedCapacity} - + {getOpenshiftVersion(obj) || '-'} - + + + + + + + import('./update-storage-quota-modal') + ), + }, + { + key: ModalKeys.DELETE, + value: t('Delete storage client'), + isDisabled: !allowDeletion, + component: React.lazy(() => import('./remove-client-modal')), + }, + ]} + hideItems={[ModalKeys.EDIT_LABELS, ModalKeys.EDIT_ANN]} + /> ); @@ -334,10 +457,6 @@ export const ClientListPage: React.FC = () => { launchModal(modalComponent, { isOpen: true }); }; - const deleteClient = (client: StorageConsumerKind) => { - launchModal(RemoveClientModal, { resource: client, isOpen: true }); - }; - return ( <> @@ -368,7 +487,7 @@ export const ClientListPage: React.FC = () => { unfilteredData={storageClients} loaded={loaded && csvLoaded} loadError={loadError || csvLoadError} - rowData={{ currentVersion: serviceVersion, deleteClient }} + rowData={{ currentVersion: serviceVersion }} /> diff --git a/packages/odf/components/storage-consumers/onboarding-modal.scss b/packages/odf/components/storage-consumers/onboarding-modal.scss index 233d4f252..c1ae34005 100644 --- a/packages/odf/components/storage-consumers/onboarding-modal.scss +++ b/packages/odf/components/storage-consumers/onboarding-modal.scss @@ -1,4 +1,5 @@ .odf-onboarding-modal__text-area { + background-color: var(--pf-v5-global--palette--black-300); border: 1px solid #000; margin-top: var(--pf-v5-global--spacer--sm); min-height: 50px; @@ -9,3 +10,33 @@ .odf-onboarding-modal__clipboard { padding-left: 0; } + +.odf-onboarding-modal__title-desc { + margin-top: -1.5vh; + margin-bottom: 4vh; +} + +.odf-onboarding-modal__quota-desc { + .pf-v5-c-radio__description { + margin-top: -0.5rem; + } + .pf-v5-c-form__group-label-info { + margin-bottom: 0.5rem; + } +} + +.odf-onboarding-modal__timestamp-icon { + width: fit-content; +} + +.co-icon-and-text.odf-onboarding-modal__info-icon { + display: unset; + + .co-icon-flex-child { + position: unset; + } +} + +.odf-onboarding-modal__invalid-quota { + width: fit-content; +} diff --git a/packages/odf/components/storage-consumers/onboarding-modal.tsx b/packages/odf/components/storage-consumers/onboarding-modal.tsx index 7c3f39a9b..860fb67d1 100644 --- a/packages/odf/components/storage-consumers/onboarding-modal.tsx +++ b/packages/odf/components/storage-consumers/onboarding-modal.tsx @@ -1,11 +1,20 @@ import * as React from 'react'; -import { LoadingBox } from '@odf/shared'; +import { + DiskSize as QuotaSize, + diskSizeUnitOptions as QuotaSizeUnitOptions, + onboardingTokenTooltip, +} from '@odf/core/constants'; +import { StorageQuota } from '@odf/core/types'; +import { isUnlimitedQuota, isValidQuota } from '@odf/core/utils'; +import { FieldLevelHelp, ModalFooter } from '@odf/shared'; import { ModalBody, ModalTitle } from '@odf/shared/generic/ModalTitle'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; -import { ExternalLink } from '@odf/shared/utils'; +import { ExternalLink, getLastLanguage } from '@odf/shared/utils'; import { HttpError } from '@odf/shared/utils/error/http-error'; +import { RequestSizeInput } from '@odf/shared/utils/RequestSizeInput'; import { BlueInfoCircleIcon, + GreenCheckCircleIcon, StatusIconAndText, consoleFetch, } from '@openshift-console/dynamic-plugin-sdk'; @@ -18,11 +27,38 @@ import { FlexItem, Flex, Alert, + Radio, + FormGroup, + LevelItem, + Level, + AlertVariant, + ButtonVariant, } from '@patternfly/react-core'; -import { Text, TextVariants } from '@patternfly/react-core'; -import { ClipboardIcon } from '@patternfly/react-icons'; +import { CopyIcon } from '@patternfly/react-icons'; import './onboarding-modal.scss'; +const unlimitedQuota: StorageQuota = { + value: 0, + unit: null, +}; +const defaultCustomQuota: StorageQuota = { + value: 1, + unit: QuotaSize.Gi, +}; + +const getTimestamp = () => + new Intl.DateTimeFormat(getLastLanguage() || undefined, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + year: 'numeric', + timeZone: 'UTC', + timeZoneName: 'short', + hour12: false, + }).format(new Date()); + type ClientOnBoardingModalProps = ModalComponent<{ isOpen: boolean; }>; @@ -32,52 +68,61 @@ export const ClientOnBoardingModal: ClientOnBoardingModalProps = ({ closeModal, }) => { const { t } = useCustomTranslation(); - const MODAL_TITLE = t('Client onboarding token'); - const [ticket, setTicket] = React.useState(''); - const [loading, setLoading] = React.useState(false); - const [error, setError] = React.useState(null); - - const onCopyToClipboard = () => { - navigator.clipboard.writeText(ticket); - }; + const [token, setToken] = React.useState(''); + const [tokenGenerationTimestamp, setTokenGenerationTimestamp] = + React.useState(''); + const [inProgress, setInProgress] = React.useState(false); + const [error, setError] = React.useState(null); + const [quota, setQuota] = React.useState({ ...unlimitedQuota }); - React.useEffect(() => { - setLoading(true); + const generateToken = () => { + setInProgress(true); consoleFetch( '/api/proxy/plugin/odf-console/provider-proxy/onboarding-tokens', { method: 'post', + body: quota.value > 0 ? JSON.stringify(quota) : null, } ) .then((response) => { - setLoading(false); + setInProgress(false); if (!response.ok) { throw new Error('Network response is not ok!'); } return response.text(); }) .then((text) => { - setTicket(text); + setToken(text); + setTokenGenerationTimestamp(getTimestamp()); }) .catch((err: HttpError) => { - setLoading(false); + setInProgress(false); setError(err.message); }); - }, []); + }; return ( - - {MODAL_TITLE} - - - - {ticket && !loading && ( -
{ticket}
- )} - {loading && !error && } - {!loading && error && ( + + {t('Client onboarding token')} + {token ? ( + + + + ) : ( + <> + +

+ {t( + 'Add storage capacity for the client cluster to consume from the provider cluster.' + )} +

+ {error && ( @@ -91,41 +136,224 @@ export const ClientOnBoardingModal: ClientOnBoardingModalProps = ({ )} -
- {!error && !loading && ( - - - - )} - - - {t('How to use this token?')} - - - {t( - 'An onboarding token is needed to connect an additional OpenShift cluster to a Data Foundation deployment. Copy the generated token and use it for deploying Data Foundation client operator on your OpenShift cluster.' - )} - - - - } - className="text-muted" - /> - -
-
+ + + + + + + + + + + + + + )}
); }; + +type StorageQuotaBodyProps = { + quota: StorageQuota; + setQuota: React.Dispatch>; + initialQuota?: StorageQuota; + capacityInfo?: React.ReactNode; +}; + +export const StorageQuotaBody: React.FC = ({ + quota, + setQuota, + initialQuota, + capacityInfo, +}) => { + const { t } = useCustomTranslation(); + + const onCustomQuotaChange = (customQuota: StorageQuota) => { + setQuota({ ...customQuota }); + }; + + const unlimitedQuotaTypeText = t('Unlimited'); + const customQuotaTypeText = t('Custom'); + + return ( + <> + + } + isChecked={isUnlimitedQuota(quota)} + onChange={() => { + setQuota({ ...unlimitedQuota }); + }} + /> + { + setQuota({ ...(initialQuota || defaultCustomQuota) }); + }} + /> + + {!isUnlimitedQuota(quota) && ( +
+ + {t('Allocate quota')} + + + {!isValidQuota(quota, initialQuota) && ( + + )} + +
+ )} + + ); +}; + +type UnlimitedRadioDescriptionProps = { + quota: StorageQuota; +}; + +const UnlimitedRadioDescription: React.FC = ({ + quota, +}) => { + const { t } = useCustomTranslation(); + return ( + <> +

{t('No specific limit on storage that a client can consume.')}

+ {isUnlimitedQuota(quota) && ( + + )} + + ); +}; + +type TokenViewBodyProps = { + token: string; + quota: StorageQuota; + tokenGenerationTimestamp: string; +}; + +const TokenViewBody: React.FC = ({ + token, + quota, + tokenGenerationTimestamp, +}) => { + const { t } = useCustomTranslation(); + + const onCopyToClipboard = () => { + navigator.clipboard.writeText(token); + }; + + const quotaText = isUnlimitedQuota(quota) + ? t('unlimited') + : `${quota.value} ${QuotaSizeUnitOptions[quota.unit]}`; + + return ( + + + + + {t('Onboarding token')} + + {onboardingTokenTooltip(t)} + + + + + {t('Generated on')}: {tokenGenerationTimestamp} + + + + + + + + +
{token}
+
+ + + On an OpenShift cluster, deploy the Data Foundation client operator + using the generated token. The token includes an{' '} + {quotaText} storage quota for client consumption. + + + + + + + } + className="text-muted odf-onboarding-modal__info-icon" + /> + +
+ ); +}; diff --git a/packages/odf/components/storage-consumers/remove-client-modal.tsx b/packages/odf/components/storage-consumers/remove-client-modal.tsx index 7b374af3a..2517a9210 100644 --- a/packages/odf/components/storage-consumers/remove-client-modal.tsx +++ b/packages/odf/components/storage-consumers/remove-client-modal.tsx @@ -1,17 +1,18 @@ import * as React from 'react'; import { getName, + getNamespace, ModalBody, ModalFooter, ModalTitle, StorageConsumerKind, useCustomTranslation, } from '@odf/shared'; +import { CommonModalProps } from '@odf/shared/modals'; import { k8sDelete, YellowExclamationTriangleIcon, } from '@openshift-console/dynamic-plugin-sdk'; -import { ModalComponent } from '@openshift-console/dynamic-plugin-sdk/lib/app/modal-support/ModalProvider'; import { Trans } from 'react-i18next'; import { Button, @@ -22,16 +23,16 @@ import { } from '@patternfly/react-core'; import { StorageConsumerModel } from '../../models'; -type RemoveClientModalProps = ModalComponent<{ - isOpen: boolean; +type RemoveClientModalProps = CommonModalProps<{ resource: StorageConsumerKind; }>; -export const RemoveClientModal: RemoveClientModalProps = ({ - resource, - closeModal, - isOpen, -}) => { +const RemoveClientModal: React.FC = (props) => { + const { + extraProps: { resource }, + isOpen, + closeModal, + } = props; const { t } = useCustomTranslation(); const [confirmed, setConfirmed] = React.useState(false); const [inProgress, setProgress] = React.useState(false); @@ -65,13 +66,14 @@ export const RemoveClientModal: RemoveClientModalProps = ({

- Deleting the storage client {getName(resource)} will remove all - Ceph/Rook resources and erase all data associated with this client, - leading to permanent deletion of the client. This action cannot be - undone. It will destroy all pods, services and other objects in the - namespace{' '} + Deleting the storage client{' '} + {getName(resource)} will + remove all Ceph/Rook resources and erase all data associated with + this client, leading to permanent deletion of the client. This + action cannot be undone. It will destroy all pods, services and + other objects in the namespace{' '} - {{ name: getName(resource) }} + {{ name: getNamespace(resource) }} . @@ -82,7 +84,7 @@ export const RemoveClientModal: RemoveClientModalProps = ({

- Confirm deletion by typing  + Confirm deletion by typing{' '} {{ name: getName(resource) }} {' '} @@ -130,3 +132,5 @@ export const RemoveClientModal: RemoveClientModalProps = ({ ); }; + +export default RemoveClientModal; diff --git a/packages/odf/components/storage-consumers/update-storage-quota-modal.tsx b/packages/odf/components/storage-consumers/update-storage-quota-modal.tsx new file mode 100644 index 000000000..160abe651 --- /dev/null +++ b/packages/odf/components/storage-consumers/update-storage-quota-modal.tsx @@ -0,0 +1,191 @@ +import * as React from 'react'; +import { DiskSize as QuotaSize } from '@odf/core/constants'; +import { useRawCapacity } from '@odf/core/hooks'; +import { StorageConsumerModel } from '@odf/core/models'; +import { + useODFNamespaceSelector, + useODFSystemFlagsSelector, +} from '@odf/core/redux'; +import { StorageQuota } from '@odf/core/types'; +import { getQuotaValueInGiB, isValidQuota } from '@odf/core/utils'; +import { + GrayInfoCircleIcon, + ModalFooter, + Patch, + StorageConsumerKind, +} from '@odf/shared'; +import { ModalBody, ModalTitle } from '@odf/shared/generic/ModalTitle'; +import { CommonModalProps } from '@odf/shared/modals'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { + humanizeBinaryBytes, + humanizeBinaryBytesWithoutB, + parser, + units, +} from '@odf/shared/utils'; +import { + BlueInfoCircleIcon, + k8sPatch, + StatusIconAndText, +} from '@openshift-console/dynamic-plugin-sdk'; +import * as _ from 'lodash-es'; +import { + Modal, + Button, + ModalVariant, + FlexItem, + Flex, + Alert, + ButtonVariant, + AlertVariant, +} from '@patternfly/react-core'; +import './onboarding-modal.scss'; +import { StorageQuotaBody } from './onboarding-modal'; + +const getStorageConsumerQuotaWithoutB = (quotaInGiB: number) => { + const humanizedQuota = humanizeBinaryBytesWithoutB( + units.dehumanize(quotaInGiB, 'binaryBytes').value, + QuotaSize.Gi + ); + return { + value: humanizedQuota.value, + unit: humanizedQuota.unit, + } as StorageQuota; +}; + +const updateStorageConsumer = ( + storageConsumer: StorageConsumerKind, + quotaValue: number +): Promise => { + const patches: Patch[] = [ + { + op: 'replace', + path: '/spec/storageQuotaInGiB', + value: quotaValue, + }, + ]; + return k8sPatch({ + model: StorageConsumerModel, + resource: storageConsumer, + data: patches, + }); +}; + +type UpdateStorageQuotaModalProps = CommonModalProps<{ + resource: StorageConsumerKind; +}>; + +const UpdateStorageQuotaModal: React.FC = ( + props +) => { + const { + extraProps: { resource }, + isOpen, + closeModal, + } = props; + const { t } = useCustomTranslation(); + const [inProgress, setInProgress] = React.useState(false); + const [error, setError] = React.useState(null); + const initialQuota = getStorageConsumerQuotaWithoutB( + resource?.spec?.storageQuotaInGiB + ); + const [quota, setQuota] = React.useState(initialQuota); + + const updateStorageConsumerQuota = async () => { + try { + setInProgress(true); + await updateStorageConsumer(resource, getQuotaValueInGiB(quota)); + closeModal(); + } catch (e) { + setError(e); + } finally { + setInProgress(false); + } + }; + + return ( + + {t('Edit storage quota')} + + {error && ( + + )} + } + /> + + + + + + + + + + + + + ); +}; + +const AvailableCapacity: React.FC = () => { + const { t } = useCustomTranslation(); + + const { odfNamespace: clusterNs } = useODFNamespaceSelector(); + const { systemFlags } = useODFSystemFlagsSelector(); + const clusterName = systemFlags[clusterNs]?.ocsClusterName; + const [totalCapacity, usedCapacity, loading, loadError] = + useRawCapacity(clusterName); + const available = parser(totalCapacity) - parser(usedCapacity); + + let title: string; + let icon: JSX.Element; + if (loading || loadError) { + title = t('Cluster capacity not available at this moment.'); + icon = ; + } else { + title = `${t('Available capacity')} (${clusterName}): ${ + humanizeBinaryBytes(available).string + }`; + icon = ; + } + return ( + + ); +}; + +export default UpdateStorageQuotaModal; diff --git a/packages/odf/constants/tooltips.tsx b/packages/odf/constants/tooltips.tsx index c426c4b94..21c89a911 100644 --- a/packages/odf/constants/tooltips.tsx +++ b/packages/odf/constants/tooltips.tsx @@ -86,3 +86,8 @@ export const resourceRequirementsTooltip = (t: TFunction) => t( 'plugin__odf-console~For enhanced performance of the Data Foundation cluster, the number of CPUs and memory resources are determined based on the cluster environment, size and various other factors.' ); + +export const onboardingTokenTooltip = (t: TFunction) => + t( + 'plugin__odf-console~An onboarding token to authenticate and authorize an OpenShift cluster, granting access to the Data Foundation deployment, thus establishing a secure connection.' + ); diff --git a/packages/odf/hooks/index.ts b/packages/odf/hooks/index.ts index 89dc6b3d8..1683256b3 100644 --- a/packages/odf/hooks/index.ts +++ b/packages/odf/hooks/index.ts @@ -1,4 +1,5 @@ export * from './useNodesData'; +export * from './useRawCapacity'; export * from './useSafeK8sGet'; export * from './useSafeK8sList'; export * from './useSafeK8sWatchResources'; diff --git a/packages/odf/hooks/useRawCapacity.ts b/packages/odf/hooks/useRawCapacity.ts new file mode 100644 index 000000000..6a2c92e8e --- /dev/null +++ b/packages/odf/hooks/useRawCapacity.ts @@ -0,0 +1,35 @@ +import { + useCustomPrometheusPoll, + usePrometheusBasePath, +} from '@odf/shared/hooks/custom-prometheus-poll'; +import { PrometheusResponse } from '@openshift-console/dynamic-plugin-sdk'; +import { CAPACITY_INFO_QUERIES, StorageDashboardQuery } from '../queries'; + +/** + * Returns the total & used raw capacity of a cluster. + */ +export const useRawCapacity = ( + clusterName: string +): [PrometheusResponse, PrometheusResponse, boolean, any] => { + const [totalCapacity, totalError, totalLoading] = useCustomPrometheusPoll({ + query: + CAPACITY_INFO_QUERIES(clusterName)[ + StorageDashboardQuery.RAW_CAPACITY_TOTAL + ], + endpoint: 'api/v1/query' as any, + basePath: usePrometheusBasePath(), + }); + const [usedCapacity, usedError, usedLoading] = useCustomPrometheusPoll({ + query: + CAPACITY_INFO_QUERIES(clusterName)[ + StorageDashboardQuery.RAW_CAPACITY_USED + ], + endpoint: 'api/v1/query' as any, + basePath: usePrometheusBasePath(), + }); + + const loading = usedLoading || totalLoading; + const loadError = totalError || usedError; + + return [totalCapacity, usedCapacity, loading, loadError]; +}; diff --git a/packages/odf/types/index.ts b/packages/odf/types/index.ts index 930342cca..35864089c 100644 --- a/packages/odf/types/index.ts +++ b/packages/odf/types/index.ts @@ -4,3 +4,4 @@ export * from './kms'; export * from './lso'; export * from './network'; export * from './mcg'; +export * from './storage-consumer'; diff --git a/packages/odf/types/storage-consumer.ts b/packages/odf/types/storage-consumer.ts new file mode 100644 index 000000000..74ee63bee --- /dev/null +++ b/packages/odf/types/storage-consumer.ts @@ -0,0 +1,6 @@ +import { DiskSize } from '../constants'; + +export type StorageQuota = { + value: number; + unit: DiskSize; +}; diff --git a/packages/odf/utils/storage.ts b/packages/odf/utils/storage.ts index d2d8389a9..ced513038 100644 --- a/packages/odf/utils/storage.ts +++ b/packages/odf/utils/storage.ts @@ -1,8 +1,37 @@ +import { + DiskSize as QuotaSize, + diskSizeUnitOptions as QuotaSizeUnitOptions, +} from '@odf/core/constants'; +import { StorageQuota } from '@odf/core/types'; import { K8sResourceKind } from '@odf/shared/types'; -import { convertToBaseValue } from '@odf/shared/utils/humanize'; +import { + convertToBaseValue, + humanizeBinaryBytes, +} from '@odf/shared/utils/humanize'; export const calcPVsCapacity = (pvs: K8sResourceKind[]): number => pvs.reduce((sum, pv) => { const storage = Number(convertToBaseValue(pv.spec.capacity.storage)); return sum + storage; }, 0); + +export const isUnlimitedQuota = (quota: StorageQuota) => quota.value === 0; + +export const isValidQuota = ( + quota: StorageQuota, + initialQuota: StorageQuota +): boolean => { + if (isUnlimitedQuota(quota) || !initialQuota) { + return true; + } + return getQuotaValueInGiB(quota) >= getQuotaValueInGiB(initialQuota); +}; + +export const getQuotaValueInGiB = (quota: StorageQuota) => { + const humanizedQuota = humanizeBinaryBytes( + convertToBaseValue(`${quota.value}${quota.unit}`), + `B`, + QuotaSizeUnitOptions[QuotaSize.Gi] + ); + return humanizedQuota.value; +}; diff --git a/packages/shared/src/generic/FieldLevelHelp.tsx b/packages/shared/src/generic/FieldLevelHelp.tsx index 66bd2b3cf..54eb0ef05 100644 --- a/packages/shared/src/generic/FieldLevelHelp.tsx +++ b/packages/shared/src/generic/FieldLevelHelp.tsx @@ -5,7 +5,7 @@ import { useCustomTranslation } from '../useCustomTranslationHook'; import './field-level-help.scss'; export const FieldLevelHelp: React.FC = React.memo( - ({ children, popoverHasAutoWidth, testId }) => { + ({ children, popoverHasAutoWidth, testId, position }) => { const { t } = useCustomTranslation(); if (React.Children.count(children) === 0) { return null; @@ -15,6 +15,7 @@ export const FieldLevelHelp: React.FC = React.memo( aria-label={t('Help')} bodyContent={children} hasAutoWidth={popoverHasAutoWidth} + position={position} >