diff --git a/frontend/packages/console-shared/src/constants/common.ts b/frontend/packages/console-shared/src/constants/common.ts index 58df54d8c364..96ef9e6110cb 100644 --- a/frontend/packages/console-shared/src/constants/common.ts +++ b/frontend/packages/console-shared/src/constants/common.ts @@ -65,6 +65,7 @@ export enum FLAGS { CAN_LIST_NODE = 'CAN_LIST_NODE', CAN_LIST_PV = 'CAN_LIST_PV', CAN_LIST_CRD = 'CAN_LIST_CRD', + CAN_LIST_CLUSTER_QUOTA = 'CAN_LIST_CLUSTER_QUOTA', CAN_LIST_CHARGEBACK_REPORTS = 'CAN_LIST_CHARGEBACK_REPORTS', CAN_LIST_USERS = 'CAN_LIST_USERS', CAN_LIST_GROUPS = 'CAN_LIST_GROUPS', diff --git a/frontend/public/actions/features.ts b/frontend/public/actions/features.ts index 62eb4441dad9..154ed4d49938 100644 --- a/frontend/public/actions/features.ts +++ b/frontend/public/actions/features.ts @@ -122,6 +122,14 @@ const ssarChecks = [ verb: 'list', }, }, + { + flag: FLAGS.CAN_LIST_CLUSTER_QUOTA, + resourceAttributes: { + group: 'quota.openshift.io', + resource: 'clusterresourcequota', + verb: 'list', + }, + }, { // TODO: Move into OLM plugin flag: FLAGS.CAN_LIST_OPERATOR_GROUP, diff --git a/frontend/public/components/default-resource.tsx b/frontend/public/components/default-resource.tsx index 83a5efb7c48e..880855adf791 100644 --- a/frontend/public/components/default-resource.tsx +++ b/frontend/public/components/default-resource.tsx @@ -8,6 +8,7 @@ import { Conditions } from './conditions'; import { DetailsPage, ListPage, Table, TableData, TableProps, RowFunctionArgs } from './factory'; import { referenceFor, + referenceForModel, kindForReference, K8sResourceKind, modelFor, @@ -23,6 +24,7 @@ import { SectionHeading, Timestamp, } from './utils'; +import { AppliedClusterResourceQuotaModel } from '../models'; const { common } = Kebab.factory; @@ -102,9 +104,16 @@ export const DefaultList: React.FC = (props) = ]; }; - const TableRowForKind: React.FC> = ({ obj, customData }) => { + const TableRowForKind: React.FC> = ({ + obj, + customData, + namespace, + }) => { const kind = referenceFor(obj) || customData.kind; const menuActions = [...Kebab.getExtensionsActionsForKind(kindObj(kind)), ...common]; + const appliedClusterQuotaReference = referenceForModel(AppliedClusterResourceQuotaModel); + const resourceNamespace = + kind === appliedClusterQuotaReference ? namespace : obj.metadata.namespace; return ( <> @@ -112,12 +121,12 @@ export const DefaultList: React.FC = (props) = - {obj.metadata.namespace ? ( - + {resourceNamespace ? ( + ) : ( t('public~None') )} @@ -176,4 +185,7 @@ export const DefaultDetailsPage: React.FC; }; + +export type TableRowForKindProps = K8sResourceKind & { namespace?: string }; + DefaultDetailsPage.displayName = 'DefaultDetailsPage'; diff --git a/frontend/public/components/factory/list-page.tsx b/frontend/public/components/factory/list-page.tsx index d0fa76856b62..617b1dbbb43b 100644 --- a/frontend/public/components/factory/list-page.tsx +++ b/frontend/public/components/factory/list-page.tsx @@ -98,6 +98,7 @@ type ListPageWrapperProps = { reduxIDs?: string[]; textFilter?: string; nameFilterPlaceholder?: string; + namespace?: string; labelFilterPlaceholder?: string; label?: string; staticFilters?: { key: string; value: string }[]; @@ -573,6 +574,7 @@ export const MultiListPage: React.FC = (props) => { columnLayout={columnLayout} nameFilterPlaceholder={nameFilterPlaceholder} labelFilterPlaceholder={labelFilterPlaceholder} + namespace={namespace} /> diff --git a/frontend/public/components/factory/table.tsx b/frontend/public/components/factory/table.tsx index b6ca45e29235..554d0c10066d 100644 --- a/frontend/public/components/factory/table.tsx +++ b/frontend/public/components/factory/table.tsx @@ -270,6 +270,7 @@ const VirtualBody: React.FC = (props) => { scrollTop, width, getRowProps, + namespace, } = props; const cellMeasurementCache = new CellMeasurerCache({ @@ -283,6 +284,7 @@ const VirtualBody: React.FC = (props) => { obj: data[index], columns, customData, + namespace, }; // do not render non visible elements (this excludes overscan) @@ -331,6 +333,7 @@ export type RowFunctionArgs = { obj: T; columns: any[]; customData?: C; + namespace?: string; }; export type VirtualBodyProps = { @@ -345,6 +348,7 @@ export type VirtualBodyProps = { width: number; expand: boolean; getRowProps?: (obj: D) => Partial>; + namespace?: string; }; type HeaderFunc = (componentProps: ComponentProps) => any[]; @@ -415,6 +419,7 @@ export const Table: React.FC = ({ expand, label, mock, + namespace, selectedResourcesForKind, 'aria-label': ariaLabel, virtualize = true, @@ -570,6 +575,7 @@ export const Table: React.FC = ({ width={width} expand={expand} getRowProps={getRowProps} + namespace={namespace} /> )} @@ -672,6 +678,7 @@ export type TableProps = Partial> & { expand?: boolean; scrollElement?: HTMLElement | (() => HTMLElement); getRowProps?: VirtualBodyProps['getRowProps']; + namespace?: string; }; export type ComponentProps = { diff --git a/frontend/public/components/resource-pages.ts b/frontend/public/components/resource-pages.ts index 4ef351504b78..1ec5e0762885 100644 --- a/frontend/public/components/resource-pages.ts +++ b/frontend/public/components/resource-pages.ts @@ -8,6 +8,7 @@ import { ReportReference, ReportGenerationQueryReference } from './chargeback'; import { referenceForModel, GroupVersionKind, referenceForExtensionModel } from '../module/k8s'; import { AlertmanagerModel, + AppliedClusterResourceQuotaModel, BuildConfigModel, BuildModel, ClusterOperatorModel, @@ -275,6 +276,11 @@ export const baseDetailsPages = ImmutableMap() (m) => m.ResourceQuotasDetailsPage, ), ) + .set(referenceForModel(AppliedClusterResourceQuotaModel), () => + import('./resource-quota' /* webpackChunkName: "resource-quota" */).then( + (m) => m.ResourceQuotasDetailsPage, + ), + ) .set(referenceForModel(LimitRangeModel), () => import('./limit-range' /* webpackChunkName: "limit-range" */).then( (m) => m.LimitRangeDetailsPage, diff --git a/frontend/public/components/resource-quota.jsx b/frontend/public/components/resource-quota.jsx index 15e380bc7fa2..3a59d43cfb9b 100644 --- a/frontend/public/components/resource-quota.jsx +++ b/frontend/public/components/resource-quota.jsx @@ -12,6 +12,7 @@ import { import { useTranslation } from 'react-i18next'; import { FLAGS, YellowExclamationTriangleIcon } from '@console/shared'; +import { LAST_NAMESPACE_NAME_LOCAL_STORAGE_KEY } from '@console/shared/src/constants'; import { DetailsPage, MultiListPage, Table, TableData } from './factory'; import { Kebab, @@ -22,13 +23,18 @@ import { ResourceSummary, convertToBaseValue, FieldLevelHelp, + Selector, } from './utils'; import { connectToFlags } from '../reducers/connectToFlags'; import { flagPending } from '../reducers/features'; import { GaugeChart } from './graphs/gauge'; import { LoadingBox } from './utils/status-box'; import { referenceForModel } from '../module/k8s'; -import { ResourceQuotaModel, ClusterResourceQuotaModel } from '../models'; +import { + AppliedClusterResourceQuotaModel, + ResourceQuotaModel, + ClusterResourceQuotaModel, +} from '../models'; const { common } = Kebab.factory; const resourceQuotaMenuActions = [ @@ -42,10 +48,18 @@ const clusterResourceQuotaMenuActions = [ const isClusterQuota = (quota) => !quota.metadata.namespace; -const quotaKind = (quota) => - isClusterQuota(quota) - ? referenceForModel(ClusterResourceQuotaModel) - : referenceForModel(ResourceQuotaModel); +const resourceQuotaReference = referenceForModel(ResourceQuotaModel); +const clusterQuotaReference = referenceForModel(ClusterResourceQuotaModel); +const appliedClusterQuotaReference = referenceForModel(AppliedClusterResourceQuotaModel); + +const quotaKind = (quota, canListClusterQuota) => { + if (quota.metadata.namespace) { + return resourceQuotaReference; + } + + return canListClusterQuota ? clusterQuotaReference : appliedClusterQuotaReference; +}; + const quotaActions = (quota) => quota.metadata.namespace ? resourceQuotaMenuActions : clusterResourceQuotaMenuActions; const gaugeChartThresholds = [{ value: 90 }, { value: 101 }]; @@ -57,17 +71,39 @@ export const getQuotaResourceTypes = (quota) => { return _.keys(specHard).sort(); }; -const getResourceUsage = (quota, resourceType) => { +const getResourceUsage = (quota, resourceType, isACRQ = false) => { const isCluster = isClusterQuota(quota); const statusPath = isCluster ? ['status', 'total', 'hard'] : ['status', 'hard']; const specPath = isCluster ? ['spec', 'quota', 'hard'] : ['spec', 'hard']; const usedPath = isCluster ? ['status', 'total', 'used'] : ['status', 'used']; + let totalUsedPath; + let totalUsed; + let used; + + // ACRQ is a cluster-scoped resource that behaves like a namespaced resource + // It has cluster-scoped data as well as namespaced data + // "used" is the data for the current namespace and "totalUsed" is the cluster-scoped data + if (isACRQ) { + used = _.get(quota, ['status', 'namespaces']); + const currentNamespace = sessionStorage.getItem(LAST_NAMESPACE_NAME_LOCAL_STORAGE_KEY); + used = used.filter((ns) => ns.namespace === currentNamespace); + used = _.get(used[0], ['status', 'used', resourceType]); + totalUsedPath = ['status', 'total', 'used']; + totalUsed = _.get(quota, [...totalUsedPath, resourceType]); + } else { + used = _.get(quota, [...usedPath, resourceType]); + if (used === undefined) { + used = 0; + } + } + const max = _.get(quota, [...statusPath, resourceType]) || _.get(quota, [...specPath, resourceType]); - const used = _.get(quota, [...usedPath, resourceType]); const percent = !max || !used ? 0 : (convertToBaseValue(used) / convertToBaseValue(max)) * 100; + return { used, + totalUsed, max, percent, }; @@ -91,8 +127,22 @@ export const UsageIcon = ({ percent }) => { return usageIcon; }; -export const ResourceUsageRow = ({ quota, resourceType }) => { - const { used, max, percent } = getResourceUsage(quota, resourceType); +export const ResourceUsageRow = ({ quota, resourceType, isACRQ = false }) => { + const { used, totalUsed, max, percent } = getResourceUsage(quota, resourceType, isACRQ); + if (isACRQ) { + return ( +
+
{resourceType}
+
+ +
+
{used}
+ {isACRQ &&
{totalUsed}
} +
{max}
+
+ ); + } + return (
{resourceType}
@@ -253,14 +303,25 @@ export const hasComputeResources = (resourceTypes) => { return _.intersection(resourceTypes, chartResourceTypes).length > 0; }; -const Details = ({ obj: rq }) => { +const Details_ = ({ obj: rq, flags }) => { const { t } = useTranslation(); const resourceTypes = getQuotaResourceTypes(rq); const showChartRow = hasComputeResources(resourceTypes); const scopes = _.get(rq, ['spec', 'scopes']); - const text = isClusterQuota(rq) - ? t('public~ClusterResourceQuota details') - : t('public~ResourceQuota details'); + const kind = quotaKind(rq, flags[FLAGS.CAN_LIST_CLUSTER_QUOTA]); + const isACRQ = kind === appliedClusterQuotaReference; + let text; + switch (kind) { + case appliedClusterQuotaReference: + text = t('public~AppliedClusterResourceQuota details'); + break; + case clusterQuotaReference: + text = t('public~ClusterResourceQuota details'); + break; + default: + text = t('public~ResourceQuota details'); + } + return ( <>
@@ -268,7 +329,29 @@ const Details = ({ obj: rq }) => { {showChartRow && }
- + + {isACRQ && ( + <> +
{t('public~ClusterResourceQuota')}
+
+ +
+ + )} + {(isACRQ || kind === clusterQuotaReference) && ( + <> +
{t('public~Selector')}
+
+ +
+ + )} +
{scopes && (
@@ -304,12 +387,27 @@ const Details = ({ obj: rq }) => {
{t('public~Resource type')}
{t('public~Capacity')}
-
{t('public~Used')}
-
{t('public~Max')}
+
+ {t('public~Used')} +
+ {isACRQ &&
{t('public~Total used')}
} +
+ {t('public~Max')} +
{resourceTypes.map((type) => ( - + ))}
@@ -318,15 +416,21 @@ const Details = ({ obj: rq }) => { ); }; -const ResourceQuotaTableRow = ({ obj: rq }) => { +const Details = connectToFlags(FLAGS.CAN_LIST_CLUSTER_QUOTA)(Details_); + +const ResourceQuotaTableRow_ = ({ obj: rq, flags, namespace }) => { const { t } = useTranslation(); return ( <> @@ -341,12 +445,18 @@ const ResourceQuotaTableRow = ({ obj: rq }) => { )} - + ); }; +const ResourceQuotaTableRow = connectToFlags(FLAGS.CAN_LIST_CLUSTER_QUOTA)(ResourceQuotaTableRow_); + export const ResourceQuotasList = (props) => { const { t } = useTranslation(); const ResourceQuotaTableHeader = () => { @@ -373,7 +483,7 @@ export const ResourceQuotasList = (props) => { return ( { // Split each resource quota into one row per subject export const flatten = (resources) => _.flatMap(resources, (resource) => _.compact(resource.data)); -export const ResourceQuotasPage = connectToFlags(FLAGS.OPENSHIFT)( - ({ namespace, flags, mock, showTitle }) => { - const { t } = useTranslation(); - const resources = [{ kind: 'ResourceQuota', namespaced: true }]; - let rowFilters = null; +export const ResourceQuotasPage = connectToFlags( + FLAGS.OPENSHIFT, + FLAGS.CAN_LIST_CLUSTER_QUOTA, +)(({ namespace, flags, mock, showTitle }) => { + const { t } = useTranslation(); + const resources = [{ kind: 'ResourceQuota', namespaced: true }]; + let rowFilters = null; - if (flagPending(flags[FLAGS.OPENSHIFT])) { - return ; - } - if (flags[FLAGS.OPENSHIFT]) { + if (flagPending(flags[FLAGS.OPENSHIFT]) || flagPending(flags[FLAGS.CAN_LIST_CLUSTER_QUOTA])) { + return ; + } + if (flags[FLAGS.OPENSHIFT]) { + if (flags[FLAGS.CAN_LIST_CLUSTER_QUOTA]) { resources.push({ kind: referenceForModel(ClusterResourceQuotaModel), namespaced: false, optional: true, }); - rowFilters = [ - { - filterGroupName: t('public~Role'), - type: 'role-kind', - reducer: quotaType, - items: [ - { - id: 'cluster', - title: t('public~Cluster-wide {{resource}}', { - resource: ResourceQuotaModel.labelPlural, - }), - }, - { - id: 'namespace', - title: t('public~Namespace {{resource}}', { - resource: ResourceQuotaModel.labelPlural, - }), - }, - ], - }, - ]; + } else { + resources.push({ + kind: referenceForModel(AppliedClusterResourceQuotaModel), + namespaced: true, + namespace, + optional: true, + }); } - const createNS = namespace || 'default'; - const accessReview = { - model: ResourceQuotaModel, - namespace: createNS, - }; - return ( - - ); - }, -); - -export const ResourceQuotasDetailsPage = (props) => ( - -); + + rowFilters = [ + { + filterGroupName: t('public~Role'), + type: 'role-kind', + reducer: quotaType, + items: [ + { + id: 'cluster', + title: t('public~Cluster-wide {{resource}}', { + resource: t(ResourceQuotaModel.labelPluralKey), + }), + }, + { + id: 'namespace', + title: t('public~Namespace {{resource}}', { + resource: t(ResourceQuotaModel.labelPluralKey), + }), + }, + ], + }, + ]; + } + const createNS = namespace || 'default'; + const accessReview = { + model: ResourceQuotaModel, + namespace: createNS, + }; + return ( + + ); +}); + +export const ResourceQuotasDetailsPage = (props) => { + return ( + + ); +}; diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index 53411c01563a..314a2aee25b1 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -1291,13 +1291,16 @@ "Affects pods that do not have an active deadline. These pods usually include your applications.": "Affects pods that do not have an active deadline. These pods usually include your applications.", "Affects pods that do not have resource limits set. These pods have a best effort quality of service.": "Affects pods that do not have resource limits set. These pods have a best effort quality of service.", "Affects pods that have at least one resource limit set. These pods do not have a best effort quality of service.": "Affects pods that have at least one resource limit set. These pods do not have a best effort quality of service.", + "AppliedClusterResourceQuota details": "AppliedClusterResourceQuota details", "ClusterResourceQuota details": "ClusterResourceQuota details", "ResourceQuota details": "ResourceQuota details", + "ClusterResourceQuota": "ClusterResourceQuota", "Scopes": "Scopes", "Requests are the amount of resources you expect to use. These are used when establishing if the cluster can fulfill your Request.": "Requests are the amount of resources you expect to use. These are used when establishing if the cluster can fulfill your Request.", "Limits are a maximum amount of a resource you can consume. Applications consuming more than the Limit may be terminated.": "Limits are a maximum amount of a resource you can consume. Applications consuming more than the Limit may be terminated.", "A cluster administrator can establish limits on both the amount you can request and your limits with a ResourceQuota.": "A cluster administrator can establish limits on both the amount you can request and your limits with a ResourceQuota.", "Resource type": "Resource type", + "Total used": "Total used", "Cluster-wide {{resource}}": "Cluster-wide {{resource}}", "Namespace {{resource}}": "Namespace {{resource}}", "Create ResourceQuota": "Create ResourceQuota", @@ -1675,8 +1678,9 @@ "PersistentVolume": "PersistentVolume", "StatefulSet": "StatefulSet", "ResourceQuota": "ResourceQuota", - "ClusterResourceQuota": "ClusterResourceQuota", "ClusterResourceQuotas": "ClusterResourceQuotas", + "AppliedClusterResourceQuota": "AppliedClusterResourceQuota", + "AppliedClusterResourceQuotas": "AppliedClusterResourceQuotas", "NetworkPolicy": "NetworkPolicy", "CustomResourceDefinition": "CustomResourceDefinition", "CronJob": "CronJob", diff --git a/frontend/public/models/index.ts b/frontend/public/models/index.ts index 94de8acc8915..8ed5147d3a74 100644 --- a/frontend/public/models/index.ts +++ b/frontend/public/models/index.ts @@ -724,6 +724,23 @@ export const ClusterResourceQuotaModel: K8sKind = { crd: true, }; +export const AppliedClusterResourceQuotaModel: K8sKind = { + label: 'AppliedClusterResourceQuota', + // t('public~AppliedClusterResourceQuota') + labelKey: 'public~AppliedClusterResourceQuota', + apiGroup: 'quota.openshift.io', + apiVersion: 'v1', + plural: 'appliedclusterresourcequotas', + abbr: 'ACRQ', + namespaced: true, + kind: 'AppliedClusterResourceQuota', + id: 'appliedclusterresourcequota', + labelPlural: 'AppliedClusterResourceQuotas', + // t('public~AppliedClusterResourceQuotas') + labelPluralKey: 'public~AppliedClusterResourceQuotas', + crd: true, +}; + export const NetworkPolicyModel: K8sKind = { label: 'NetworkPolicy', // t('public~NetworkPolicy')