From 84ec99b017efe545fc454db3fb8e66d3fd934b11 Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Thu, 23 Sep 2021 16:22:03 -0400 Subject: [PATCH] CONSOLE-922: Support AppliedClusterResourceQuota for normal users Added support for list and details pages, as well as other pages like Search. We now also display ACRQs in the ResourceQuotas table for projects. Fixes https://issues.redhat.com/browse/CONSOLE-922 --- .../components/resource-quota.spec.tsx | 98 ++- .../dashboards/project-dashboard.scenario.ts | 7 +- .../locales/en/console-shared.json | 2 +- .../AppliedClusterResourceQuotaItem.tsx | 46 ++ .../resource-quota-card/ResourceQuotaBody.tsx | 10 +- .../tests/crud/quotas.spec.ts | 140 +++++ .../project-dashboard/resource-quota-card.tsx | 46 +- .../dashboard/with-dashboard-resources.tsx | 7 +- .../public/components/factory/list-page.tsx | 2 + frontend/public/components/graphs/donut.tsx | 89 +++ frontend/public/components/resource-pages.ts | 11 + frontend/public/components/resource-quota.jsx | 584 ++++++++++++++++-- frontend/public/locales/en/public.json | 16 +- frontend/public/models/index.ts | 17 + frontend/public/module/k8s/types.ts | 24 +- 15 files changed, 1032 insertions(+), 67 deletions(-) create mode 100644 frontend/packages/console-shared/src/components/dashboard/resource-quota-card/AppliedClusterResourceQuotaItem.tsx create mode 100644 frontend/packages/integration-tests-cypress/tests/crud/quotas.spec.ts create mode 100644 frontend/public/components/graphs/donut.tsx 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;