diff --git a/frontend/__tests__/components/resource-quota.spec.tsx b/frontend/__tests__/components/resource-quota.spec.tsx index 4386c8b59b3..0b3920c0139 100644 --- a/frontend/__tests__/components/resource-quota.spec.tsx +++ b/frontend/__tests__/components/resource-quota.spec.tsx @@ -1,6 +1,56 @@ import * as React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { UsageIcon, ResourceUsageRow } from '../../public/components/resource-quota'; +import { + UsageIcon, + ResourceUsageRow, + getACRQResourceUsage, +} from '../../public/components/resource-quota'; + +// We don't render ResourceUsageRows for cluster-only data, but use it in a Gauge chart +describe('Check getResourceUsage for AppliedClusterResourceQuota', () => { + const quota = { + apiVersion: 'quota.openshift.io/v1', + kind: 'AppliedClusterResourceQuota', + metadata: { name: 'example' }, + spec: { quota: { hard: { 'limits.cpu': '2' } } }, + status: { + namespaces: [ + { + namespace: 'test-namespace', + status: { used: { 'limits.cpu': '0' }, hard: { 'limits.cpu': '2' } }, + }, + { + namespace: 'test-namespace2', + status: { used: { 'limits.cpu': '1' }, hard: { 'limits.cpu': '2' } }, + }, + ], + total: { hard: { 'limits.cpu': '2' }, used: { 'limits.cpu': '1' } }, + }, + }; + + it('Provides correct cluster-only data', () => { + expect(getACRQResourceUsage(quota, 'limits.cpu')).toEqual({ + used: { cluster: '1', namespace: 0 }, + totalUsed: '1', + max: '2', + percent: { namespace: 0, otherNamespaces: 50, unused: 50 }, + }); + }); + it('Provides correct namespaced data', () => { + expect(getACRQResourceUsage(quota, 'limits.cpu', 'test-namespace')).toEqual({ + used: { cluster: '1', namespace: '0' }, + totalUsed: '1', + max: '2', + percent: { namespace: 0, otherNamespaces: 50, unused: 50 }, + }); + expect(getACRQResourceUsage(quota, 'limits.cpu', 'test-namespace2')).toEqual({ + used: { cluster: '1', namespace: '1' }, + totalUsed: '1', + max: '2', + percent: { namespace: 50, otherNamespaces: 0, unused: 50 }, + }); + }); +}); describe('UsageIcon', () => { let wrapper: ShallowWrapper; @@ -98,3 +148,49 @@ describe('Check cluster quota table columns by ResourceUsageRow', () => { expect(col3.text()).toBe('2'); }); }); + +describe('Check applied cluster quota table columns by ResourceUsageRow', () => { + let wrapper: ShallowWrapper; + const quota = { + apiVersion: 'quota.openshift.io/v1', + kind: 'AppliedClusterResourceQuota', + metadata: { name: 'example' }, + spec: { quota: { hard: { 'limits.cpu': 2 } } }, + status: { + namespaces: [ + { + namespace: 'test-namespace', + status: { used: { 'limits.cpu': 0 }, hard: { 'limits.cpu': 2 } }, + }, + { + namespace: 'test-namespace2', + status: { used: { 'limits.cpu': 1 }, hard: { 'limits.cpu': 2 } }, + }, + ], + total: { hard: { 'limits.cpu': 2 }, used: { 'limits.cpu': 1 } }, + }, + }; + + beforeEach(() => { + wrapper = shallow( + , + ); + }); + + it('renders ResourceUsageRow for each columns', () => { + const col0 = wrapper.childAt(0); + expect(col0.text()).toBe('limits.cpu'); + + const col1 = wrapper.childAt(1); + expect(col1.find('.co-resource-quota-icon').exists()).toBe(true); + + const col2 = wrapper.childAt(2); + expect(col2.text()).toBe('0'); + + const col3 = wrapper.childAt(3); + expect(col3.text()).toBe('1'); + + const col4 = wrapper.childAt(4); + expect(col4.text()).toBe('2'); + }); +}); diff --git a/frontend/integration-tests/tests/dashboards/project-dashboard.scenario.ts b/frontend/integration-tests/tests/dashboards/project-dashboard.scenario.ts index a1d4161acd3..a53f9c4e96c 100644 --- a/frontend/integration-tests/tests/dashboards/project-dashboard.scenario.ts +++ b/frontend/integration-tests/tests/dashboards/project-dashboard.scenario.ts @@ -199,8 +199,11 @@ describe('Project Dashboard', () => { it('shows Resource Quotas', async () => { expect(projectDashboardView.resourceQuotasCard.isDisplayed()).toBe(true); expect( - await projectDashboardView.resourceQuotasCard.$('.co-dashboard-card__body').getText(), - ).toEqual('No resource quotas'); + await projectDashboardView.resourceQuotasCard + .$('.co-dashboard-card__body') + .$('.co-dashboard-card__body--top-margin') + .getText(), + ).toEqual('No ResourceQuotas'); createResource(resourceQuota); addLeakableResource(leakedResources, resourceQuota); diff --git a/frontend/packages/console-shared/locales/en/console-shared.json b/frontend/packages/console-shared/locales/en/console-shared.json index 5824fbc43c0..7c454def91c 100644 --- a/frontend/packages/console-shared/locales/en/console-shared.json +++ b/frontend/packages/console-shared/locales/en/console-shared.json @@ -14,7 +14,7 @@ "There are no ongoing activities.": "There are no ongoing activities.", "Ongoing": "Ongoing", "Not available": "Not available", - "No resource quotas": "No resource quotas", + "No ResourceQuotas": "No ResourceQuotas", "View details": "View details", "Alerts could not be loaded.": "Alerts could not be loaded.", "({{operatorStatusLength}} installed)": "({{operatorStatusLength}} installed)", diff --git a/frontend/packages/console-shared/src/components/dashboard/resource-quota-card/AppliedClusterResourceQuotaItem.tsx b/frontend/packages/console-shared/src/components/dashboard/resource-quota-card/AppliedClusterResourceQuotaItem.tsx new file mode 100644 index 00000000000..b273abcd569 --- /dev/null +++ b/frontend/packages/console-shared/src/components/dashboard/resource-quota-card/AppliedClusterResourceQuotaItem.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { + getQuotaResourceTypes, + QuotaScopesInline, + QuotaGaugeCharts, +} from '@console/internal/components/resource-quota'; +import { ResourceLink } from '@console/internal/components/utils/resource-link'; +import { AppliedClusterResourceQuotaModel } from '@console/internal/models'; +import { referenceForModel, AppliedClusterResourceQuotaKind } from '@console/internal/module/k8s'; + +import './resource-quota-card.scss'; + +const AppliedClusterResourceQuotaItem: React.FC = ({ + resourceQuota, + namespace, +}) => { + const resourceTypes = getQuotaResourceTypes(resourceQuota); + const scopes = resourceQuota?.spec?.quota?.scopes; + return ( + <> +
+ + {scopes && } +
+ + + ); +}; + +export default AppliedClusterResourceQuotaItem; + +type AppliedClusterResourceQuotaItemProps = { + resourceQuota: AppliedClusterResourceQuotaKind; + namespace: string; +}; diff --git a/frontend/packages/console-shared/src/components/dashboard/resource-quota-card/ResourceQuotaBody.tsx b/frontend/packages/console-shared/src/components/dashboard/resource-quota-card/ResourceQuotaBody.tsx index 6654128dc26..1796c429fd1 100644 --- a/frontend/packages/console-shared/src/components/dashboard/resource-quota-card/ResourceQuotaBody.tsx +++ b/frontend/packages/console-shared/src/components/dashboard/resource-quota-card/ResourceQuotaBody.tsx @@ -3,7 +3,12 @@ import { useTranslation } from 'react-i18next'; import './resource-quota-card.scss'; -const ResourceQuotaBody: React.FC = ({ error, isLoading, children }) => { +const ResourceQuotaBody: React.FC = ({ + error, + isLoading, + noText, + children, +}) => { let body: React.ReactNode; const { t } = useTranslation(); if (error) { @@ -11,7 +16,7 @@ const ResourceQuotaBody: React.FC = ({ error, isLoading, } else if (isLoading) { body =
; } else if (!React.Children.count(children)) { - body =
{t('console-shared~No resource quotas')}
; + body =
{noText || t('console-shared~No ResourceQuotas')}
; } return
{body || children}
; @@ -22,4 +27,5 @@ export default ResourceQuotaBody; type ResourceQuotaBodyProps = { error: boolean; isLoading: boolean; + noText?: string; }; diff --git a/frontend/packages/integration-tests-cypress/tests/crud/quotas.spec.ts b/frontend/packages/integration-tests-cypress/tests/crud/quotas.spec.ts new file mode 100644 index 00000000000..071f28dafb2 --- /dev/null +++ b/frontend/packages/integration-tests-cypress/tests/crud/quotas.spec.ts @@ -0,0 +1,140 @@ +import { safeLoad, safeDump } from 'js-yaml'; +import * as _ from 'lodash'; +import { checkErrors, testName } from '../../support'; +import { projectDropdown } from '../../views/common'; +import { detailsPage } from '../../views/details-page'; +import { errorMessage } from '../../views/form'; +import { listPage } from '../../views/list-page'; +import { modal } from '../../views/modal'; +import { nav } from '../../views/nav'; +import * as yamlEditor from '../../views/yaml-editor'; + +const quotaName = 'example-resource-quota'; +const clusterQuotaName = 'example-cluster-resource-quota'; +const allProjectsDropdownLabel = 'All Projects'; + +const createExampleQuotas = () => { + cy.log('create quota instance'); + nav.sidenav.clickNavLink(['Administration', 'ResourceQuotas']); + projectDropdown.selectProject(testName); + projectDropdown.shouldContain(testName); + listPage.clickCreateYAMLbutton(); + // sidebar needs to be fully loaded, else it sometimes overlays the Create button + cy.byTestID('resource-sidebar').should('exist'); + yamlEditor.isLoaded(); + let newContent; + yamlEditor.getEditorContent().then((content) => { + newContent = _.defaultsDeep({}, { metadata: { name: quotaName } }, safeLoad(content)); + yamlEditor.setEditorContent(safeDump(newContent)).then(() => { + yamlEditor.clickSaveCreateButton(); + cy.get(errorMessage).should('not.exist'); + }); + }); + detailsPage.breadcrumb(0).click(); + + cy.log('create cluster quota instance'); + listPage.clickCreateYAMLbutton(); + cy.byTestID('resource-sidebar').should('exist'); + yamlEditor.isLoaded(); + yamlEditor.getEditorContent().then((content) => { + newContent = _.defaultsDeep( + {}, + { + kind: 'ClusterResourceQuota', + apiVersion: 'quota.openshift.io/v1', + metadata: { name: clusterQuotaName }, + spec: { + quota: { + hard: { + pods: '10', + secrets: '10', + }, + }, + selector: { + labels: { + matchLabels: { + 'kubernetes.io/metadata.name': testName, + }, + }, + }, + }, + }, + safeLoad(content), + ); + yamlEditor.setEditorContent(safeDump(newContent)).then(() => { + yamlEditor.clickSaveCreateButton(); + cy.get(errorMessage).should('not.exist'); + }); + }); +}; + +const deleteClusterExamples = () => { + cy.log('delete ClusterResourceQuota instance'); + projectDropdown.selectProject(allProjectsDropdownLabel); + nav.sidenav.clickNavLink(['Administration', 'ResourceQuotas']); + listPage.rows.shouldBeLoaded(); + listPage.filter.byName(clusterQuotaName); + listPage.rows.clickRowByName(clusterQuotaName); + detailsPage.isLoaded(); + detailsPage.clickPageActionFromDropdown('Delete ClusterResourceQuota'); + modal.shouldBeOpened(); + modal.submit(); + modal.shouldBeClosed(); + detailsPage.isLoaded(); +}; + +describe('Quotas', () => { + before(() => { + cy.login(); + cy.createProject(testName); + createExampleQuotas(); + }); + + afterEach(() => { + checkErrors(); + }); + + after(() => { + deleteClusterExamples(); + cy.deleteProject(testName); + cy.logout(); + }); + + it(`'All Projects' shows ResourceQuotas`, () => { + nav.sidenav.clickNavLink(['Administration', 'ResourceQuotas']); + projectDropdown.selectProject(allProjectsDropdownLabel); + listPage.rows.shouldBeLoaded(); + listPage.filter.byName(quotaName); + listPage.rows.shouldExist(quotaName); + }); + + it(`'All Projects' shows ClusterResourceQuotas`, () => { + nav.sidenav.clickNavLink(['Administration', 'ResourceQuotas']); + projectDropdown.selectProject(allProjectsDropdownLabel); + listPage.rows.shouldBeLoaded(); + listPage.filter.byName(clusterQuotaName); + listPage.rows.shouldExist(clusterQuotaName); + listPage.rows.clickRowByName(clusterQuotaName); + detailsPage.isLoaded(); + detailsPage.breadcrumb(0).contains('ClusterResourceQuota'); + }); + + it(`Test namespace shows ResourceQuotas`, () => { + nav.sidenav.clickNavLink(['Administration', 'ResourceQuotas']); + projectDropdown.selectProject(testName); + listPage.rows.shouldBeLoaded(); + listPage.filter.byName(quotaName); + listPage.rows.shouldExist(quotaName); + }); + + it(`Test namespace shows AppliedClusterResourceQuotas`, () => { + nav.sidenav.clickNavLink(['Administration', 'ResourceQuotas']); + projectDropdown.selectProject(testName); + listPage.rows.shouldBeLoaded(); + listPage.filter.byName(clusterQuotaName); + listPage.rows.shouldExist(clusterQuotaName); + listPage.rows.clickRowByName(clusterQuotaName); + detailsPage.isLoaded(); + detailsPage.breadcrumb(0).contains('AppliedClusterResourceQuota'); + }); +}); diff --git a/frontend/public/components/dashboard/project-dashboard/resource-quota-card.tsx b/frontend/public/components/dashboard/project-dashboard/resource-quota-card.tsx index e4c9ba44e30..f70b6bc7564 100644 --- a/frontend/public/components/dashboard/project-dashboard/resource-quota-card.tsx +++ b/frontend/public/components/dashboard/project-dashboard/resource-quota-card.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import * as _ from 'lodash-es'; import { useTranslation } from 'react-i18next'; import DashboardCard from '@console/shared/src/components/dashboard/dashboard-card/DashboardCard'; @@ -8,11 +7,13 @@ import DashboardCardHeader from '@console/shared/src/components/dashboard/dashbo import DashboardCardTitle from '@console/shared/src/components/dashboard/dashboard-card/DashboardCardTitle'; import ResourceQuotaBody from '@console/shared/src/components/dashboard/resource-quota-card/ResourceQuotaBody'; import ResourceQuotaItem from '@console/shared/src/components/dashboard/resource-quota-card/ResourceQuotaItem'; +import AppliedClusterResourceQuotaItem from '@console/shared/src/components/dashboard/resource-quota-card/AppliedClusterResourceQuotaItem'; import { getQuotaResourceTypes, hasComputeResources } from '../../resource-quota'; -import { FirehoseResult } from '../../utils'; -import { ResourceQuotaModel } from '../../../models'; +import { FirehoseResult, FirehoseResource } from '../../utils'; +import { AppliedClusterResourceQuotaModel, ResourceQuotaModel } from '../../../models'; import { withDashboardResources, DashboardItemProps } from '../with-dashboard-resources'; import { ProjectDashboardContext } from './project-dashboard-context'; +import { referenceForModel } from '../../../module/k8s'; const getResourceQuota = (namespace: string) => ({ kind: ResourceQuotaModel.kind, @@ -21,6 +22,13 @@ const getResourceQuota = (namespace: string) => ({ prop: 'resourceQuotas', }); +const getAppliedClusterResourceQuota = (namespace: string): FirehoseResource => ({ + kind: referenceForModel(AppliedClusterResourceQuotaModel), + namespace, + isList: true, + prop: 'appliedClusterResourceQuotas', +}); + export const ResourceQuotaCard = withDashboardResources( ({ watchK8sResource, stopWatchK8sResource, resources }: DashboardItemProps) => { const { obj } = React.useContext(ProjectDashboardContext); @@ -29,10 +37,19 @@ export const ResourceQuotaCard = withDashboardResources( watchK8sResource(resourceQuota); return () => stopWatchK8sResource(resourceQuota); }, [obj.metadata.name, watchK8sResource, stopWatchK8sResource]); + React.useEffect(() => { + const appliedClusterResourceQuota = getAppliedClusterResourceQuota(obj.metadata.name); + watchK8sResource(appliedClusterResourceQuota); + return () => stopWatchK8sResource(appliedClusterResourceQuota); + }, [obj.metadata.name, watchK8sResource, stopWatchK8sResource]); + + const quotas = (resources.resourceQuotas?.data || []) as FirehoseResult['data']; + const clusterQuotas = (resources.appliedClusterResourceQuotas?.data || + []) as FirehoseResult['data']; + const { loaded: rqLoaded, loadError: rqLoadError } = resources.resourceQuotas ?? {}; + const { loaded: acrqLoaded, loadError: acrqLoadError } = + resources.appliedClusterResourceQuotas ?? {}; - const quotas = _.get(resources.resourceQuotas, 'data', []) as FirehoseResult['data']; - const loaded = _.get(resources.resourceQuotas, 'loaded'); - const error = _.get(resources.resourceQuotas, 'loadError'); const { t } = useTranslation(); return ( @@ -41,13 +58,28 @@ export const ResourceQuotaCard = withDashboardResources( {t('public~ResourceQuotas')} - + {quotas .filter((rq) => hasComputeResources(getQuotaResourceTypes(rq))) .map((rq) => ( ))} + + {clusterQuotas + .filter((rq) => hasComputeResources(getQuotaResourceTypes(rq))) + .map((rq) => ( + + ))} + ); diff --git a/frontend/public/components/dashboard/with-dashboard-resources.tsx b/frontend/public/components/dashboard/with-dashboard-resources.tsx index f56df09498d..0d4750d697c 100644 --- a/frontend/public/components/dashboard/with-dashboard-resources.tsx +++ b/frontend/public/components/dashboard/with-dashboard-resources.tsx @@ -18,7 +18,7 @@ import { } from '../../actions/dashboards'; import { RootState } from '../../redux'; import { Firehose, FirehoseResource, FirehoseResult } from '../utils'; -import { K8sResourceKind } from '../../module/k8s'; +import { K8sResourceKind, AppliedClusterResourceQuotaKind } from '../../module/k8s'; import { PrometheusResponse } from '../graphs'; const mapDispatchToProps: DispatchToProps = (dispatch) => ({ @@ -221,6 +221,9 @@ export type DashboardItemProps = { watchK8sResource: WatchK8sResource; stopWatchK8sResource: StopWatchK8sResource; resources?: { - [key: string]: FirehoseResult | FirehoseResult; + [key: string]: + | FirehoseResult + | FirehoseResult + | FirehoseResult; }; }; diff --git a/frontend/public/components/factory/list-page.tsx b/frontend/public/components/factory/list-page.tsx index ab2aab37c36..69e64f57774 100644 --- a/frontend/public/components/factory/list-page.tsx +++ b/frontend/public/components/factory/list-page.tsx @@ -100,6 +100,7 @@ type ListPageWrapperProps = { reduxIDs?: string[]; textFilter?: string; nameFilterPlaceholder?: string; + namespace?: string; labelFilterPlaceholder?: string; label?: string; staticFilters?: { key: string; value: string }[]; @@ -590,6 +591,7 @@ export const MultiListPage: React.FC = (props) => { nameFilterPlaceholder={nameFilterPlaceholder} labelFilterPlaceholder={labelFilterPlaceholder} nameFilter={nameFilter} + namespace={namespace} /> diff --git a/frontend/public/components/graphs/donut.tsx b/frontend/public/components/graphs/donut.tsx new file mode 100644 index 00000000000..40bed2e6b20 --- /dev/null +++ b/frontend/public/components/graphs/donut.tsx @@ -0,0 +1,89 @@ +/* eslint-disable camelcase */ +import * as React from 'react'; +import { ChartDonut } from '@patternfly/react-charts'; +import { + chart_color_black_100, + chart_color_green_300, + chart_color_green_500, + chart_color_gold_400, + chart_color_gold_500, +} from '@patternfly/react-tokens'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; + +import { PrometheusGraph, PrometheusGraphLink } from './prometheus-graph'; +import { useRefWidth } from '../utils'; +import { DataPoint } from '.'; + +export const DonutChart: React.FC = ({ + data, + query = '', + title, + ariaChartLinkLabel, + ariaChartTitle, + ariaDescription, + usedLabel, + // Don't sort, Uses previously declared props + label, + secondaryTitle, + className, +}) => { + const { t } = useTranslation(); + const [ref, width] = useRefWidth(); + + const usedLabelText = usedLabel || t('public~used'); + const secondaryTitleText = secondaryTitle || usedLabelText; + const labelText = label || t('No data'); + + const labels = ({ datum: { x, y } }) => t('public~{{x}}: {{y}}%', { x, y }); + + const namespaceData = data.filter((datum) => datum.x === 'Namespace'); + + return ( + + + + + + ); +}; + +type DonutChartProps = { + data: { x: string; y: DataPoint }[]; + label: string; + query?: string; + secondaryTitle?: string; + title?: string; + ariaChartLinkLabel?: string; + ariaChartTitle?: string; + ariaDescription?: string; + usedLabel?: string; + className?: string; +}; diff --git a/frontend/public/components/resource-pages.ts b/frontend/public/components/resource-pages.ts index 4ef351504b7..0e8ad26b67d 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.AppliedClusterResourceQuotasDetailsPage, + ), + ) .set(referenceForModel(LimitRangeModel), () => import('./limit-range' /* webpackChunkName: "limit-range" */).then( (m) => m.LimitRangeDetailsPage, @@ -519,6 +525,11 @@ export const baseListPages = ImmutableMap() (m) => m.ResourceQuotasPage, ), ) + .set(referenceForModel(AppliedClusterResourceQuotaModel), () => + import('./resource-quota' /* webpackChunkName: "resource-quota" */).then( + (m) => m.AppliedClusterResourceQuotasPage, + ), + ) .set(referenceForModel(LimitRangeModel), () => import('./limit-range' /* webpackChunkName: "limit-range" */).then((m) => m.LimitRangeListPage), ) diff --git a/frontend/public/components/resource-quota.jsx b/frontend/public/components/resource-quota.jsx index 15e380bc7fa..094b2c9ae66 100644 --- a/frontend/public/components/resource-quota.jsx +++ b/frontend/public/components/resource-quota.jsx @@ -22,15 +22,26 @@ import { ResourceSummary, convertToBaseValue, FieldLevelHelp, + useAccessReview, + LabelList, + Selector, + Timestamp, + DetailsItem, } from './utils'; import { connectToFlags } from '../reducers/connectToFlags'; import { flagPending } from '../reducers/features'; import { GaugeChart } from './graphs/gauge'; +import { DonutChart } from './graphs/donut'; import { LoadingBox } from './utils/status-box'; -import { referenceForModel } from '../module/k8s'; -import { ResourceQuotaModel, ClusterResourceQuotaModel } from '../models'; +import { referenceFor, referenceForModel } from '../module/k8s'; +import { + AppliedClusterResourceQuotaModel, + ResourceQuotaModel, + ClusterResourceQuotaModel, +} from '../models'; const { common } = Kebab.factory; + const resourceQuotaMenuActions = [ ...Kebab.getExtensionsActionsForKind(ResourceQuotaModel), ...common, @@ -39,15 +50,48 @@ const clusterResourceQuotaMenuActions = [ ...Kebab.getExtensionsActionsForKind(ClusterResourceQuotaModel), ...common, ]; +const appliedClusterResourceQuotaMenuActions = (namespace) => [ + ...Kebab.getExtensionsActionsForKind(ClusterResourceQuotaModel), + Kebab.factory.ModifyLabels, + Kebab.factory.ModifyAnnotations, + (kind, obj) => { + return { + // t('public~Edit AppliedClusterResourceQuota') + labelKey: 'public~Edit AppliedClusterResourceQuota', + href: `/k8s/ns/${namespace}/${referenceForModel(AppliedClusterResourceQuotaModel)}/${ + obj.metadata.name + }/yaml`, + accessReview: { + group: kind.apiGroup, + resource: kind.plural, + name: obj.metadata.name, + namespace, + verb: 'update', + }, + }; + }, + Kebab.factory.Delete, +]; const isClusterQuota = (quota) => !quota.metadata.namespace; -const quotaKind = (quota) => - isClusterQuota(quota) - ? referenceForModel(ClusterResourceQuotaModel) - : referenceForModel(ResourceQuotaModel); -const quotaActions = (quota) => - quota.metadata.namespace ? resourceQuotaMenuActions : clusterResourceQuotaMenuActions; +const clusterQuotaReference = referenceForModel(ClusterResourceQuotaModel); +const appliedClusterQuotaReference = referenceForModel(AppliedClusterResourceQuotaModel); + +const quotaActions = (quota, customData = undefined) => { + if (quota.metadata.namespace) { + return resourceQuotaMenuActions; + } + + if (quota.kind === 'ClusterResourceQuota') { + return clusterResourceQuotaMenuActions; + } + + if (quota.kind === 'AppliedClusterResourceQuota') { + return appliedClusterResourceQuotaMenuActions(customData.namespace); + } +}; + const gaugeChartThresholds = [{ value: 90 }, { value: 101 }]; export const getQuotaResourceTypes = (quota) => { @@ -57,7 +101,41 @@ export const getQuotaResourceTypes = (quota) => { return _.keys(specHard).sort(); }; -const getResourceUsage = (quota, resourceType) => { +export const getACRQResourceUsage = (quota, resourceType, namespace) => { + let used; + if (namespace) { + const allNamespaceData = quota.status?.namespaces; + const currentNamespaceData = allNamespaceData.filter((ns) => ns.namespace === namespace); + used = { + namespace: currentNamespaceData[0]?.status?.used[resourceType], + cluster: quota.status?.total?.used[resourceType], + }; + } else { + used = { namespace: 0, cluster: quota.status?.total?.used[resourceType] }; + } + const totalUsed = quota.status?.total?.used[resourceType]; + const max = quota.status?.total?.hard[resourceType] || quota.spec?.quota?.hard[resourceType]; + const percentNamespace = + !max || !used.namespace + ? 0 + : (convertToBaseValue(used.namespace) / convertToBaseValue(max)) * 100; + const percentCluster = + !max || !used.cluster ? 0 : (convertToBaseValue(used.cluster) / convertToBaseValue(max)) * 100; + const percentOtherNamespaces = percentCluster - percentNamespace; + + return { + used, + totalUsed, + max, + percent: { + namespace: percentNamespace, + otherNamespaces: percentOtherNamespaces, + unused: 100 - (percentNamespace + percentOtherNamespaces), + }, + }; +}; + +export const getResourceUsage = (quota, resourceType) => { const isCluster = isClusterQuota(quota); const statusPath = isCluster ? ['status', 'total', 'hard'] : ['status', 'hard']; const specPath = isCluster ? ['spec', 'quota', 'hard'] : ['spec', 'hard']; @@ -66,6 +144,7 @@ const getResourceUsage = (quota, resourceType) => { _.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, max, @@ -73,7 +152,16 @@ const getResourceUsage = (quota, resourceType) => { }; }; -const tableColumnClasses = ['', '', Kebab.columnClass]; +const tableColumnClasses = [ + '', + '', + 'pf-m-hidden pf-m-visible-on-lg', + 'pf-m-hidden pf-m-visible-on-lg', + 'pf-m-hidden pf-m-visible-on-lg', + Kebab.columnClass, +]; + +const acrqTableColumnClasses = ['', '', '', '', Kebab.columnClass]; export const UsageIcon = ({ percent }) => { let usageIcon = ; @@ -91,7 +179,24 @@ export const UsageIcon = ({ percent }) => { return usageIcon; }; -export const ResourceUsageRow = ({ quota, resourceType }) => { +export const ResourceUsageRow = ({ quota, resourceType, namespace = undefined }) => { + const reference = referenceFor(quota); + const isACRQ = reference === appliedClusterQuotaReference; + if (isACRQ) { + const { used, totalUsed, max, percent } = getACRQResourceUsage(quota, resourceType, namespace); + return ( +
+
{resourceType}
+
+ +
+
{used.namespace}
+
{totalUsed}
+
{max}
+
+ ); + } + const { used, max, percent } = getResourceUsage(quota, resourceType); return (
@@ -105,7 +210,7 @@ export const ResourceUsageRow = ({ quota, resourceType }) => { ); }; -const NoQuotaGuage = ({ title, className }) => { +const NoQuotaGauge = ({ title, className }) => { const { t } = useTranslation(); return ( { ); }; -export const QuotaGaugeCharts = ({ quota, resourceTypes, chartClassName = null }) => { +export const QuotaGaugeCharts = ({ + quota, + resourceTypes, + chartClassName = null, + namespace = undefined, +}) => { + const reference = referenceFor(quota); + const isACRQ = reference === appliedClusterQuotaReference; const resourceTypesSet = new Set(resourceTypes); + const { t } = useTranslation(); + + if (isACRQ) { + const cpuRequestUsagePercent = getACRQResourceUsage( + quota, + resourceTypesSet.has('requests.cpu') ? 'requests.cpu' : 'cpu', + namespace, + ).percent; + const cpuLimitUsagePercent = getACRQResourceUsage(quota, 'limits.cpu', namespace).percent; + const memoryRequestUsagePercent = getACRQResourceUsage( + quota, + resourceTypesSet.has('requests.memory') ? 'requests.memory' : 'memory', + namespace, + ).percent; + const memoryLimitUsagePercent = getACRQResourceUsage(quota, 'limits.memory', namespace).percent; + + return ( +
+ {resourceTypesSet.has('requests.cpu') || resourceTypesSet.has('cpu') ? ( +
+ +
+ ) : ( +
+ +
+ )} + {resourceTypesSet.has('limits.cpu') ? ( +
+ +
+ ) : ( +
+ +
+ )} + {resourceTypesSet.has('requests.memory') || resourceTypesSet.has('memory') ? ( +
+ +
+ ) : ( +
+ +
+ )} + {resourceTypesSet.has('limits.memory') ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ ); + } const cpuRequestUsagePercent = getResourceUsage( quota, resourceTypesSet.has('requests.cpu') ? 'requests.cpu' : 'cpu', @@ -129,7 +382,7 @@ export const QuotaGaugeCharts = ({ quota, resourceTypes, chartClassName = null } resourceTypesSet.has('requests.memory') ? 'requests.memory' : 'memory', ).percent; const memoryLimitUsagePercent = getResourceUsage(quota, 'limits.memory').percent; - const { t } = useTranslation(); + return (
{resourceTypesSet.has('requests.cpu') || resourceTypesSet.has('cpu') ? ( @@ -146,7 +399,7 @@ export const QuotaGaugeCharts = ({ quota, resourceTypes, chartClassName = null }
) : (
- +
)} {resourceTypesSet.has('limits.cpu') ? ( @@ -160,7 +413,7 @@ export const QuotaGaugeCharts = ({ quota, resourceTypes, chartClassName = null }
) : (
- +
)} {resourceTypesSet.has('requests.memory') || resourceTypesSet.has('memory') ? ( @@ -177,7 +430,7 @@ export const QuotaGaugeCharts = ({ quota, resourceTypes, chartClassName = null }
) : (
- +
)} {resourceTypesSet.has('limits.memory') ? ( @@ -191,7 +444,7 @@ export const QuotaGaugeCharts = ({ quota, resourceTypes, chartClassName = null } ) : (
- +
)} @@ -253,22 +506,68 @@ export const hasComputeResources = (resourceTypes) => { return _.intersection(resourceTypes, chartResourceTypes).length > 0; }; -const Details = ({ obj: rq }) => { +const Details = ({ obj: rq, match }) => { 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 scopes = rq.spec?.scopes ?? rq.spec?.quota?.scopes; + const reference = referenceFor(rq); + const isACRQ = reference === appliedClusterQuotaReference; + const namespace = match?.params?.ns; + let text; + switch (reference) { + case appliedClusterQuotaReference: + text = t('public~AppliedClusterResourceQuota details'); + break; + case clusterQuotaReference: + text = t('public~ClusterResourceQuota details'); + break; + default: + text = t('public~ResourceQuota details'); + } + const canListCRQ = useAccessReview({ + group: ClusterResourceQuotaModel.apiGroup, + resource: ClusterResourceQuotaModel.plural, + verb: 'list', + }); + return ( <>
- {showChartRow && } + {showChartRow && ( + + )}
- + + {canListCRQ && ( + + + + )} + + + + + + +
{scopes && (
@@ -304,12 +603,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 +632,20 @@ const Details = ({ obj: rq }) => { ); }; -const ResourceQuotaTableRow = ({ obj: rq }) => { +const ResourceQuotaTableRow = ({ obj: rq, customData }) => { const { t } = useTranslation(); + const actions = quotaActions(rq, customData); return ( <> @@ -340,8 +659,61 @@ const ResourceQuotaTableRow = ({ obj: rq }) => { t('public~None') )} - - + + + + + + + + + + + + + + ); +}; + +const AppliedClusterResourceQuotaTableRow = ({ obj: rq, customData }) => { + const actions = quotaActions(rq, customData); + return ( + <> + + + + + + + + + + + + + + ); @@ -365,18 +737,83 @@ export const ResourceQuotasList = (props) => { id: 'namespace', }, { - title: '', + title: t('public~Label selector'), + sortField: 'spec.selector.labels.matchLabels', + transforms: [sortable], props: { className: tableColumnClasses[2] }, }, + { + title: t('public~Project annotations'), + sortField: 'spec.selector.annotations', + transforms: [sortable], + props: { className: tableColumnClasses[3] }, + }, + { + title: t('public~Created'), + sortField: 'metadata.creationTimestamp', + transforms: [sortable], + props: { className: tableColumnClasses[4] }, + }, + { + title: '', + props: { className: tableColumnClasses[5] }, + }, ]; }; return ( + ); +}; + +export const AppliedClusterResourceQuotasList = (props) => { + const { t } = useTranslation(); + const AppliedClusterResourceQuotaTableHeader = () => { + return [ + { + title: t('public~Name'), + sortField: 'metadata.name', + transforms: [sortable], + props: { className: acrqTableColumnClasses[0] }, + }, + { + title: t('public~Label selector'), + sortField: 'spec.selector.labels.matchLabels', + transforms: [sortable], + props: { className: acrqTableColumnClasses[1] }, + }, + { + title: t('public~Project annotations'), + sortField: 'spec.selector.annotations', + transforms: [sortable], + props: { className: acrqTableColumnClasses[2] }, + }, + { + title: t('public~Created'), + sortField: 'metadata.creationTimestamp', + transforms: [sortable], + props: { className: acrqTableColumnClasses[3] }, + }, + { + title: '', + props: { className: acrqTableColumnClasses[4] }, + }, + ]; + }; + return ( +
); }; @@ -401,11 +838,21 @@ export const ResourceQuotasPage = connectToFlags(FLAGS.OPENSHIFT)( return ; } if (flags[FLAGS.OPENSHIFT]) { - resources.push({ - kind: referenceForModel(ClusterResourceQuotaModel), - namespaced: false, - optional: true, - }); + if (!namespace) { + resources.push({ + kind: referenceForModel(ClusterResourceQuotaModel), + namespaced: false, + optional: true, + }); + } else { + resources.push({ + kind: referenceForModel(AppliedClusterResourceQuotaModel), + namespaced: true, + namespace, + optional: true, + }); + } + rowFilters = [ { filterGroupName: t('public~Role'), @@ -415,13 +862,13 @@ export const ResourceQuotasPage = connectToFlags(FLAGS.OPENSHIFT)( { id: 'cluster', title: t('public~Cluster-wide {{resource}}', { - resource: ResourceQuotaModel.labelPlural, + resource: t(ResourceQuotaModel.labelPluralKey), }), }, { id: 'namespace', title: t('public~Namespace {{resource}}', { - resource: ResourceQuotaModel.labelPlural, + resource: t(ResourceQuotaModel.labelPluralKey), }), }, ], @@ -453,10 +900,49 @@ export const ResourceQuotasPage = connectToFlags(FLAGS.OPENSHIFT)( }, ); -export const ResourceQuotasDetailsPage = (props) => ( - -); +export const AppliedClusterResourceQuotasPage = ({ namespace, mock, showTitle }) => { + const { t } = useTranslation(); + const resources = [ + { + kind: referenceForModel(AppliedClusterResourceQuotaModel), + namespaced: true, + namespace, + optional: true, + }, + ]; + + return ( + + ); +}; + +export const ResourceQuotasDetailsPage = (props) => { + return ( + + ); +}; + +export const AppliedClusterResourceQuotasDetailsPage = (props) => { + const { match } = props; + const actions = appliedClusterResourceQuotaMenuActions(match?.params?.ns); + return ( + + ); +}; diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index 9faa87808c1..c2e12e0da30 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -399,6 +399,7 @@ "Inventory": "Inventory", "Launcher": "Launcher", "ResourceQuotas": "ResourceQuotas", + "No AppliedClusterResourceQuotas": "No AppliedClusterResourceQuotas", "Utilization": "Utilization", "{{kindLabel}} details": "{{kindLabel}} details", "Start rollout": "Start rollout", @@ -525,9 +526,10 @@ "Manage columns": "Manage columns", "Column management": "Column management", "{{value}} at {{date}}": "{{value}} at {{date}}", - "Loading": "Loading", "used": "used", "No data": "No data", + "{{x}}: {{y}}%": "{{x}}: {{y}}%", + "Loading": "Loading", "available": "available", "No datapoints found.": "No datapoints found.", "total limit": "total limit", @@ -1332,22 +1334,32 @@ "{{statusReplicas}} of {{specReplicas}} pods": "{{statusReplicas}} of {{specReplicas}} pods", "Tech Preview": "Tech Preview", "Select Resource": "Select Resource", + "Edit AppliedClusterResourceQuota": "Edit AppliedClusterResourceQuota", "No quota": "No quota", + "Percentage of CPU used by current namespace vs. other namespaces": "Percentage of CPU used by current namespace vs. other namespaces", "CPU request": "CPU request", + "Percentage of CPU limit used by current namespace vs. other namespaces": "Percentage of CPU limit used by current namespace vs. other namespaces", "CPU limit": "CPU limit", + "Percentage of memory requests used by current namespace vs. other namespaces": "Percentage of memory requests used by current namespace vs. other namespaces", "Memory request": "Memory request", + "Percentage of memory limits used by current namespace vs. other namespaces": "Percentage of memory limits used by current namespace vs. other namespaces", "Memory limit": "Memory limit", "Affects pods that have an active deadline. These pods usually include builds, deployers, and jobs.": "Affects pods that have an active deadline. These pods usually include builds, deployers, and jobs.", "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", + "Project annotations": "Project annotations", "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", + "AppliedClusterResourceQuotas": "AppliedClusterResourceQuotas", "Cluster-wide {{resource}}": "Cluster-wide {{resource}}", "Namespace {{resource}}": "Namespace {{resource}}", "Create ResourceQuota": "Create ResourceQuota", @@ -1730,8 +1742,8 @@ "PersistentVolume": "PersistentVolume", "StatefulSet": "StatefulSet", "ResourceQuota": "ResourceQuota", - "ClusterResourceQuota": "ClusterResourceQuota", "ClusterResourceQuotas": "ClusterResourceQuotas", + "AppliedClusterResourceQuota": "AppliedClusterResourceQuota", "NetworkPolicy": "NetworkPolicy", "CustomResourceDefinition": "CustomResourceDefinition", "CronJob": "CronJob", diff --git a/frontend/public/models/index.ts b/frontend/public/models/index.ts index 09fdd3d846d..761876dc1f7 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') diff --git a/frontend/public/module/k8s/types.ts b/frontend/public/module/k8s/types.ts index 115c1a43820..01c3639ce86 100644 --- a/frontend/public/module/k8s/types.ts +++ b/frontend/public/module/k8s/types.ts @@ -62,7 +62,6 @@ export type Toleration = { // or status, weakening type checking. export type K8sResourceKind = K8sResourceCommon & { spec?: { - selector?: Selector | MatchLabels; [key: string]: any; }; status?: { [key: string]: any }; @@ -326,6 +325,29 @@ export type DeploymentKind = { }; } & K8sResourceCommon; +export type AppliedClusterResourceQuotaKind = { + spec?: { + selector?: { + labels?: Selector; + annotations?: MatchLabels; + }; + quota?: { + hard?: { [key: string]: number }; + scopes?: string[]; + scopeSelector?: { + matchExpressions?: { scopeName: string; operator: string; values?: string[] }[]; + }; + }; + }; + status?: { + namespaces?: { + namespace: string; + status: { used?: { [key: string]: number }; hard?: { [key: string]: number } }; + }[]; + total?: { hard?: { [key: string]: number }; used?: { [key: string]: number } }; + }; +} & K8sResourceCommon; + type CurrentObject = { averageUtilization?: number; averageValue?: string;