From 6cac541d54c54aab621ae9503c275b8995b2a198 Mon Sep 17 00:00:00 2001 From: Yu-Lin Chen Date: Fri, 2 Feb 2024 12:22:51 -0600 Subject: [PATCH] [YUNIKORN-2325] Add a chart to display multi-type resource utilization (#160) Closes: #160 Signed-off-by: Craig Condit --- json-db.json | 470 ++++++++++++++++++ json-routes.json | 1 + src/app/app.module.ts | 4 + .../app-node-utilizations.component.html | 30 ++ .../app-node-utilizations.component.scss | 17 + .../app-node-utilizations.component.spec.ts | 114 +++++ .../app-node-utilizations.component.ts | 150 ++++++ .../dashboard/dashboard.component.html | 2 +- .../dashboard/dashboard.component.ts | 2 +- .../nodes-view/nodes-view.component.html | 5 + .../nodes-view/nodes-view.component.spec.ts | 9 +- .../vertical-bar-chart.component.html | 23 + .../vertical-bar-chart.component.scss | 22 + .../vertical-bar-chart.component.spec.ts | 38 ++ .../vertical-bar-chart.component.ts | 201 ++++++++ src/app/models/chart-data.model.ts | 18 + src/app/models/node-utilization.model.ts | 16 +- .../services/scheduler/scheduler.service.ts | 29 +- src/app/testing/mocks.ts | 2 +- src/app/utils/common.util.ts | 18 + src/app/utils/constants.ts | 3 +- 21 files changed, 1142 insertions(+), 32 deletions(-) create mode 100644 src/app/components/app-node-utilizations/app-node-utilizations.component.html create mode 100644 src/app/components/app-node-utilizations/app-node-utilizations.component.scss create mode 100644 src/app/components/app-node-utilizations/app-node-utilizations.component.spec.ts create mode 100644 src/app/components/app-node-utilizations/app-node-utilizations.component.ts create mode 100644 src/app/components/vertical-bar-chart/vertical-bar-chart.component.html create mode 100644 src/app/components/vertical-bar-chart/vertical-bar-chart.component.scss create mode 100644 src/app/components/vertical-bar-chart/vertical-bar-chart.component.spec.ts create mode 100644 src/app/components/vertical-bar-chart/vertical-bar-chart.component.ts diff --git a/json-db.json b/json-db.json index 4dede388..cdecd82c 100644 --- a/json-db.json +++ b/json-db.json @@ -963,6 +963,476 @@ } ] }, + "node-utilizations": [ + { + "clusterId": "mycluster", + "partition": "default", + "utilizations": [ + { + "type": "ephemeral-storagesss", + "utilization": [ + { + "bucketName": "0-10%", + "numOfNodes": 1, + "nodeNames": [ + "node-01" + ] + }, + { + "bucketName": "10-20%", + "numOfNodes": 2, + "nodeNames": [ + "node-02", + "node-03" + ] + }, + { + "bucketName": "20-30%", + "numOfNodes": 3, + "nodeNames": [ + "node-04", + "node-05", + "node-06" + ] + }, + { + "bucketName": "30-40%", + "numOfNodes": 4, + "nodeNames": [ + "node-07", + "node-08", + "node-09", + "node-10" + ] + }, + { + "bucketName": "40-50%" + }, + { + "bucketName": "50-60%" + }, + { + "bucketName": "60-70%" + }, + { + "bucketName": "70-80%" + }, + { + "bucketName": "80-90%" + }, + { + "bucketName": "90-100%" + } + ] + }, + { + "type": "hugepages-1Gi", + "utilization": [ + { + "bucketName": "0-10%" + }, + { + "bucketName": "10-20%", + "numOfNodes": 1, + "nodeNames": [ + "node-01" + ] + }, + { + "bucketName": "20-30%", + "numOfNodes": 2, + "nodeNames": [ + "node-02", + "node-03" + ] + }, + { + "bucketName": "30-40%", + "numOfNodes": 3, + "nodeNames": [ + "node-04", + "node-05", + "node-06" + ] + }, + { + "bucketName": "40-50%", + "numOfNodes": 4, + "nodeNames": [ + "node-07", + "node-08", + "node-09", + "node-10" + ] + }, + { + "bucketName": "50-60%" + }, + { + "bucketName": "60-70%" + }, + { + "bucketName": "70-80%" + }, + { + "bucketName": "80-90%" + }, + { + "bucketName": "90-100%" + } + ] + }, + { + "type": "hugepages-2Mi", + "utilization": [ + { + "bucketName": "0-10%" + }, + { + "bucketName": "10-20%" + }, + { + "bucketName": "20-30%", + "numOfNodes": 1, + "nodeNames": [ + "node-01" + ] + }, + { + "bucketName": "30-40%", + "numOfNodes": 2, + "nodeNames": [ + "node-02", + "node-03" + ] + }, + { + "bucketName": "40-50%", + "numOfNodes": 3, + "nodeNames": [ + "node-04", + "node-05", + "node-06" + ] + }, + { + "bucketName": "50-60%", + "numOfNodes": 4, + "nodeNames": [ + "node-07", + "node-08", + "node-09", + "node-10" + ] + }, + { + "bucketName": "60-70%" + }, + { + "bucketName": "70-80%" + }, + { + "bucketName": "80-90%" + }, + { + "bucketName": "90-100%" + } + ] + }, + { + "type": "memory", + "utilization": [ + { + "bucketName": "0-10%" + }, + { + "bucketName": "10-20%" + }, + { + "bucketName": "20-30%" + }, + { + "bucketName": "30-40%", + "numOfNodes": 1, + "nodeNames": [ + "node-01" + ] + }, + { + "bucketName": "40-50%", + "numOfNodes": 2, + "nodeNames": [ + "node-02", + "node-03" + ] + }, + { + "bucketName": "50-60%", + "numOfNodes": 3, + "nodeNames": [ + "node-04", + "node-05", + "node-06" + ] + }, + { + "bucketName": "60-70%", + "numOfNodes": 4, + "nodeNames": [ + "node-07", + "node-08", + "node-09", + "node-10" + ] + }, + { + "bucketName": "70-80%" + }, + { + "bucketName": "80-90%" + }, + { + "bucketName": "90-100%" + } + ] + }, + { + "type": "pods", + "utilization": [ + { + "bucketName": "0-10%" + }, + { + "bucketName": "10-20%" + }, + { + "bucketName": "20-30%" + }, + { + "bucketName": "30-40%" + }, + { + "bucketName": "40-50%" + }, + { + "bucketName": "50-60%", + "numOfNodes": 1, + "nodeNames": [ + "node-01" + ] + }, + { + "bucketName": "60-70%", + "numOfNodes": 2, + "nodeNames": [ + "node-02", + "node-03" + ] + }, + { + "bucketName": "70-80%", + "numOfNodes": 3, + "nodeNames": [ + "node-04", + "node-05", + "node-06" + ] + }, + { + "bucketName": "80-90%", + "numOfNodes": 4, + "nodeNames": [ + "node-07", + "node-08", + "node-09", + "node-10" + ] + }, + { + "bucketName": "90-100%" + } + ] + }, + { + "type": "vcore", + "utilization": [ + { + "bucketName": "0-10%" + }, + { + "bucketName": "10-20%" + }, + { + "bucketName": "20-30%" + }, + { + "bucketName": "30-40%" + }, + { + "bucketName": "40-50%" + }, + { + "bucketName": "50-60%" + }, + { + "bucketName": "60-70%", + "numOfNodes": 1, + "nodeNames": [ + "node-01" + ] + }, + { + "bucketName": "70-80%", + "numOfNodes": 2, + "nodeNames": [ + "node-02", + "node-03" + ] + }, + { + "bucketName": "80-90%", + "numOfNodes": 3, + "nodeNames": [ + "node-04", + "node-05", + "node-06" + ] + }, + { + "bucketName": "90-100%", + "numOfNodes": 4, + "nodeNames": [ + "node-07", + "node-08", + "node-09", + "node-10" + ] + } + ] + } + ] + }, + { + "clusterId": "mycluster", + "partition": "cluster-2", + "utilizations": [ + { + "type": "memory", + "utilization": [ + { + "bucketName": "0-10%" + }, + { + "bucketName": "10-20%" + }, + { + "bucketName": "20-30%" + }, + { + "bucketName": "30-40%", + "numOfNodes": 1, + "nodeNames": [ + "node-01" + ] + }, + { + "bucketName": "40-50%", + "numOfNodes": 2, + "nodeNames": [ + "node-02", + "node-03" + ] + }, + { + "bucketName": "50-60%", + "numOfNodes": 3, + "nodeNames": [ + "node-04", + "node-05", + "node-06" + ] + }, + { + "bucketName": "60-70%", + "numOfNodes": 4, + "nodeNames": [ + "node-07", + "node-08", + "node-09", + "node-10" + ] + }, + { + "bucketName": "70-80%" + }, + { + "bucketName": "80-90%" + }, + { + "bucketName": "90-100%" + } + ] + }, + { + "type": "pods", + "utilization": [ + { + "bucketName": "0-10%" + }, + { + "bucketName": "10-20%" + }, + { + "bucketName": "20-30%" + }, + { + "bucketName": "30-40%" + }, + { + "bucketName": "40-50%" + }, + { + "bucketName": "50-60%", + "numOfNodes": 1, + "nodeNames": [ + "node-01" + ] + }, + { + "bucketName": "60-70%", + "numOfNodes": 2, + "nodeNames": [ + "node-02", + "node-03" + ] + }, + { + "bucketName": "70-80%", + "numOfNodes": 3, + "nodeNames": [ + "node-04", + "node-05", + "node-06" + ] + }, + { + "bucketName": "80-90%", + "numOfNodes": 4, + "nodeNames": [ + "node-07", + "node-08", + "node-09", + "node-10" + ] + }, + { + "bucketName": "90-100%" + } + ] + } + ] + } + ], "partitions": [ { "clusterId": "mycluster", diff --git a/json-routes.json b/json-routes.json index 7b2086ac..068091bc 100644 --- a/json-routes.json +++ b/json-routes.json @@ -1,5 +1,6 @@ { "/ws/v1/scheduler/node-utilization": "/node-utilization", + "/ws/v1/scheduler/node-utilizations": "/node-utilizations", "/ws/v1/*": "/$1", "/history/apps": "/appHistory", "/history/containers": "/containerHistory", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index a3259427..ced1b06b 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -61,6 +61,8 @@ import { StatusViewComponent } from '@app/components/status-view/status-view.com import { HealthchecksComponent } from '@app/components/healthchecks/healthchecks.component'; import { AppNodeUtilizationComponent } from '@app/components/app-node-utilization/app-node-utilization.component'; import { BarChartComponent } from '@app/components/bar-chart/bar-chart.component'; +import { AppNodeUtilizationsComponent } from '@app/components/app-node-utilizations/app-node-utilizations.component'; +import { VerticalBarChartComponent } from '@app/components/vertical-bar-chart/vertical-bar-chart.component'; @NgModule({ declarations: [ @@ -82,6 +84,8 @@ import { BarChartComponent } from '@app/components/bar-chart/bar-chart.component HealthchecksComponent, AppNodeUtilizationComponent, BarChartComponent, + AppNodeUtilizationsComponent, + VerticalBarChartComponent, ], imports: [ BrowserModule, diff --git a/src/app/components/app-node-utilizations/app-node-utilizations.component.html b/src/app/components/app-node-utilizations/app-node-utilizations.component.html new file mode 100644 index 00000000..69f1f65d --- /dev/null +++ b/src/app/components/app-node-utilizations/app-node-utilizations.component.html @@ -0,0 +1,30 @@ + + + + + Node Resource Utilization + + +
+
+ +
+
+
+
\ No newline at end of file diff --git a/src/app/components/app-node-utilizations/app-node-utilizations.component.scss b/src/app/components/app-node-utilizations/app-node-utilizations.component.scss new file mode 100644 index 00000000..a3f036cf --- /dev/null +++ b/src/app/components/app-node-utilizations/app-node-utilizations.component.scss @@ -0,0 +1,17 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ \ No newline at end of file diff --git a/src/app/components/app-node-utilizations/app-node-utilizations.component.spec.ts b/src/app/components/app-node-utilizations/app-node-utilizations.component.spec.ts new file mode 100644 index 00000000..08e749f3 --- /dev/null +++ b/src/app/components/app-node-utilizations/app-node-utilizations.component.spec.ts @@ -0,0 +1,114 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MatCardModule } from '@angular/material/card'; +import { AppNodeUtilizationsComponent } from '@app/components/app-node-utilizations/app-node-utilizations.component'; +import { VerticalBarChartComponent } from '@app/components/vertical-bar-chart/vertical-bar-chart.component'; +import { CHART_COLORS } from '@app/utils/constants'; + +describe('AppNodeUtilizationsComponent', () => { + let component: AppNodeUtilizationsComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, MatCardModule], + declarations: [AppNodeUtilizationsComponent, VerticalBarChartComponent] + }); + + fixture = TestBed.createComponent(AppNodeUtilizationsComponent); + component = fixture.componentInstance; + }); + + it('test AppNodeUtilizationsComponent.calculateAvgUtilization()', () => { + type TestCase = { + description: string; + nodeNumInBuckets: number[]; + expected: number; + }; + const testCases: TestCase[] = [ + { + description: 'Test 2 nodes, 1 node in 0~10%, 1 node in 10~20%', + nodeNumInBuckets: [1, 1, 0, 0, 0, 0, 0, 0, 0, 0], + expected: 0.1 + }, + { + description: 'Test 10 nodes, 1 node in each bucket', + nodeNumInBuckets: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + expected: 0.5 + }, + { + description: 'Test zero node in buckets', + nodeNumInBuckets: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + expected: 0 + }, + ] + + testCases.forEach((testCase: TestCase) => { + const result = component.calculateAvgUtilization(testCase.nodeNumInBuckets); + expect(result).toEqual(testCase.expected); + }); + }); + + it('test AppNodeUtilizationsComponent.generateColorMapping()', () => { + const types = [ + 'type03', 'type01', 'type02', 'type04', 'type05', + 'type06', 'type07', 'type08', 'type09', 'type10', 'type11' + ]; + const colorMapping = component.generateColorMapping(types); + + expect(colorMapping.size).toBe(11); + expect(colorMapping.get('type01')).toBe(CHART_COLORS[0]); + expect(colorMapping.get('type02')).toBe(CHART_COLORS[1]); + expect(colorMapping.get('type03')).toBe(CHART_COLORS[2]); + expect(colorMapping.get('type11')).toBe(CHART_COLORS[0]); + }); + + it('test AppNodeUtilizationsComponent.getBarDescription()', () => { + type TestCase = { + description: string; + nodeNames: string[]; + expected: string; + }; + const testCases: TestCase[] = [ + { + description: 'Test single node', + nodeNames: [""], + expected: "" + }, + { + description: 'Test unordered multi-nodes', + nodeNames: ["node02", "node01"], + expected: "node01\nnode02" + }, + { + description: 'Test over than MAX_NODES_IN_DESCRIPTION nodes', + nodeNames: ["node01", "node02", "node03", "node04", "node05", "node06", "node07", "node08", "node09", "node10", "node11", "node12", "node13", "node14", "node15", "node16"], + expected: "node01\nnode02\nnode03\nnode04\nnode05\nnode06\nnode07\nnode08\nnode09\nnode10\nnode11\nnode12\nnode13\nnode14\nnode15\n...1 more" + }, + ] + + testCases.forEach((testCase: TestCase) => { + const result = component.getBarDescription(testCase.nodeNames); + expect(result).toEqual(testCase.expected); + }); + }); +}); diff --git a/src/app/components/app-node-utilizations/app-node-utilizations.component.ts b/src/app/components/app-node-utilizations/app-node-utilizations.component.ts new file mode 100644 index 00000000..2f16df21 --- /dev/null +++ b/src/app/components/app-node-utilizations/app-node-utilizations.component.ts @@ -0,0 +1,150 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { BarChartDataSet } from '@app/models/chart-data.model'; +import { CHART_COLORS, DEFAULT_BAR_COLOR } from '@app/utils/constants'; +import { CommonUtil } from '@app/utils/common.util'; +import { NodeUtilization, NodeUtilizationsInfo } from '@app/models/node-utilization.model'; +import { SchedulerService } from '@app/services/scheduler/scheduler.service'; + + +@Component({ + selector: 'app-node-utilizations', + templateUrl: './app-node-utilizations.component.html', + styleUrls: ['./app-node-utilizations.component.scss'] +}) +export class AppNodeUtilizationsComponent implements OnInit, OnChanges { + nodeUtilizations: NodeUtilization[] = []; + + // input data for vertical bar chart, key is resource type + bucketList: string[] = []; // one bucket list for all resource types, length should be exactly 10 + barChartDataSets: BarChartDataSet[] = new Array(); // one dataset for each type + + @Input() partitionSelected: string = ""; + + constructor( + private scheduler: SchedulerService + ) { } + + ngOnInit() { + this.reloadBarChartData() + } + + ngOnChanges(changes: SimpleChanges) { + if ( + changes['partitionSelected'] + ) { + this.reloadBarChartData() + } + } + + reloadBarChartData() { + this.scheduler.fetchNodeUtilizationsInfo().subscribe((data) => { + let nodeUtilizationsInfo: NodeUtilizationsInfo[] = data + for (let i = 0; i < nodeUtilizationsInfo.length; i++) { + if (nodeUtilizationsInfo[i].partition === this.partitionSelected) { + let nodeUtilizations = nodeUtilizationsInfo[i].utilizations + this.fetchBarChartData(nodeUtilizations) + break; + } + } + }); + } + + fetchBarChartData(nodeUtilizations: NodeUtilization[]) { + let barChartDataSets = new Array(); + if (nodeUtilizations.length === 0) { + // clean bar chart data + this.barChartDataSets = barChartDataSets; + return; + } + + let colorMapping = this.generateColorMapping( + nodeUtilizations.map((nodeUtilization) => (nodeUtilization.type)) + ); + + for (let i = 0; i < nodeUtilizations.length; i++) { + let type = nodeUtilizations[i].type; + let utilization = nodeUtilizations[i].utilization + let borderWidth = 1 + + if (i === 0) { + // get bucketList only from the first type of node utilization + // should always be 10 buckets. (ranging from 0% to 100%). + this.bucketList = utilization.map((item) => item.bucketName); + } + let bucketValues = utilization.map((item) => item.numOfNodes); + barChartDataSets.push(new BarChartDataSet( + type, + bucketValues, + this.calculateAvgUtilization(bucketValues), + colorMapping.get(type) ?? DEFAULT_BAR_COLOR, + borderWidth, + utilization.map((item) => this.getBarDescription(item.nodeNames)) + )) + } + + // sort by resource type first, then sort by avg utilization rate + barChartDataSets.sort((a, b) => CommonUtil.resourcesCompareFn(a.label, b.label)); + barChartDataSets.sort((a, b) => b.avgUtilizationRate - a.avgUtilizationRate); + barChartDataSets = barChartDataSets.slice(0, 10); // only show top 10 resources + + // refresh bar chart data + this.barChartDataSets = barChartDataSets; + } + + calculateAvgUtilization(nodeNumInBuckets: number[]): number { + // Calculates the average utilization of nodes based on a distribution of node utilizations. + // Note: It not a precise average. + // value of nodeCounts[0] means node count of 0%~10%, take 5% as the utilization of node in bucket + // value of nodeCounts[1] means node count of 10%~20, take 15% as the utilization of node in bucket + // value of nodeCounts[9] means node count of 90%~100%, take 95% as the utilization of node in bucket + let totalNodes = 0; + let weightedSum = 0; + for (let i = 0; i < 10; i++) { //buckets have fixed length 10 + if (nodeNumInBuckets[i] != undefined) { + totalNodes += nodeNumInBuckets[i]; + weightedSum += nodeNumInBuckets[i] * (5 + 10 * i); + } + } + return totalNodes ? weightedSum / totalNodes / 100 : 0; + } + + generateColorMapping(types: string[]): Map { + // give each resource type a color based on its index after lexicographically sorting + types.sort(); + let colorMapping = new Map(); + for (let i = 0; i < types.length; i++) { + colorMapping.set(types[i], CHART_COLORS[i % 10]) + } + return colorMapping + } + + getBarDescription(nodeNames: string[] | null): string { + let MAX_NODES_IN_DESCRIPTION = 15; + let description: string | undefined; + if (nodeNames && nodeNames.length > MAX_NODES_IN_DESCRIPTION) { + // only put MAX_NODES_IN_DESCRIPTION nodes in description, others will be replaced by '...N more' + description = nodeNames.slice(0, MAX_NODES_IN_DESCRIPTION).sort().join("\n") + "\n..." + (nodeNames.length - MAX_NODES_IN_DESCRIPTION) + " more"; + } else { + description = nodeNames ? nodeNames.sort().join("\n") : undefined; + } + return description || "" + } +} diff --git a/src/app/components/dashboard/dashboard.component.html b/src/app/components/dashboard/dashboard.component.html index b493d925..e1a5f3bd 100644 --- a/src/app/components/dashboard/dashboard.component.html +++ b/src/app/components/dashboard/dashboard.component.html @@ -95,7 +95,7 @@
- +
diff --git a/src/app/components/dashboard/dashboard.component.ts b/src/app/components/dashboard/dashboard.component.ts index d73279a6..c02083c6 100644 --- a/src/app/components/dashboard/dashboard.component.ts +++ b/src/app/components/dashboard/dashboard.component.ts @@ -109,7 +109,7 @@ export class DashboardComponent implements OnInit { this.appHistoryData = this.getAreaChartData(data); }); - this.scheduler.fetchClusterNodeUtilization().subscribe((data) => { + this.scheduler.fetchNodeUtilization().subscribe((data) => { let nodeUtilization = new NodeUtilization(data.type, data.utilization); this.nodeUtilizationChartData = nodeUtilization.toNodeUtilizationChartData(); }); diff --git a/src/app/components/nodes-view/nodes-view.component.html b/src/app/components/nodes-view/nodes-view.component.html index 17b8f8b7..66c2f812 100644 --- a/src/app/components/nodes-view/nodes-view.component.html +++ b/src/app/components/nodes-view/nodes-view.component.html @@ -197,4 +197,9 @@

Allocations

> +
+
+ +
+
diff --git a/src/app/components/nodes-view/nodes-view.component.spec.ts b/src/app/components/nodes-view/nodes-view.component.spec.ts index 17ca95cb..97a147f4 100644 --- a/src/app/components/nodes-view/nodes-view.component.spec.ts +++ b/src/app/components/nodes-view/nodes-view.component.spec.ts @@ -21,9 +21,13 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NgxSpinnerService } from 'ngx-spinner'; import { NodesViewComponent } from './nodes-view.component'; +import { AppNodeUtilizationsComponent } from '@app/components/app-node-utilizations/app-node-utilizations.component'; +import { VerticalBarChartComponent } from '@app/components/vertical-bar-chart/vertical-bar-chart.component'; + import { SchedulerService } from '@app/services/scheduler/scheduler.service'; import { HAMMER_LOADER } from '@angular/platform-browser'; import { MockSchedulerService, MockNgxSpinnerService } from '@app/testing/mocks'; +import { MatCardModule } from '@angular/material/card'; import { MatTableModule } from '@angular/material/table'; import { MatPaginatorModule } from '@angular/material/paginator'; import { MatDividerModule } from '@angular/material/divider'; @@ -58,13 +62,12 @@ describe('NodesViewComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - declarations: [NodesViewComponent], - imports: [MatFormFieldModule, MatInputModule, ReactiveFormsModule], + declarations: [NodesViewComponent, AppNodeUtilizationsComponent, VerticalBarChartComponent], + imports: [MatFormFieldModule, MatInputModule, ReactiveFormsModule, MatCardModule], }) .compileComponents(); fixture = TestBed.createComponent(NodesViewComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create the component', () => { diff --git a/src/app/components/vertical-bar-chart/vertical-bar-chart.component.html b/src/app/components/vertical-bar-chart/vertical-bar-chart.component.html new file mode 100644 index 00000000..c5cf1e2d --- /dev/null +++ b/src/app/components/vertical-bar-chart/vertical-bar-chart.component.html @@ -0,0 +1,23 @@ + + +
+
+ +
+
\ No newline at end of file diff --git a/src/app/components/vertical-bar-chart/vertical-bar-chart.component.scss b/src/app/components/vertical-bar-chart/vertical-bar-chart.component.scss new file mode 100644 index 00000000..ebd91ee7 --- /dev/null +++ b/src/app/components/vertical-bar-chart/vertical-bar-chart.component.scss @@ -0,0 +1,22 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.canvas-div { + height: 50vh; + width: 100%; +} diff --git a/src/app/components/vertical-bar-chart/vertical-bar-chart.component.spec.ts b/src/app/components/vertical-bar-chart/vertical-bar-chart.component.spec.ts new file mode 100644 index 00000000..2293138e --- /dev/null +++ b/src/app/components/vertical-bar-chart/vertical-bar-chart.component.spec.ts @@ -0,0 +1,38 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { VerticalBarChartComponent } from './vertical-bar-chart.component'; + +describe('VerticalBarChartComponent', () => { + let component: VerticalBarChartComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [VerticalBarChartComponent] + }); + fixture = TestBed.createComponent(VerticalBarChartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/vertical-bar-chart/vertical-bar-chart.component.ts b/src/app/components/vertical-bar-chart/vertical-bar-chart.component.ts new file mode 100644 index 00000000..424e655a --- /dev/null +++ b/src/app/components/vertical-bar-chart/vertical-bar-chart.component.ts @@ -0,0 +1,201 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AfterViewInit, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { BarChartDataSet } from '@app/models/chart-data.model'; +import { EventBusService, EventMap } from '@app/services/event-bus/event-bus.service'; +import { CommonUtil } from '@app/utils/common.util'; +import { Chart, Tooltip } from 'chart.js'; +import { Subject, takeUntil } from 'rxjs'; + +Chart.register(Tooltip); + +@Component({ + selector: 'app-vertical-bar-chart', + templateUrl: './vertical-bar-chart.component.html', + styleUrls: ['./vertical-bar-chart.component.scss'] +}) +export class VerticalBarChartComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { + destroy$ = new Subject(); + chartContainerId = ''; + barChart: Chart<'bar' | 'line', number[], string> | undefined; + + @Input() bucketList: string[] = []; // one bucket list for all resource types, length should be exactly 10 + @Input() barChartDataSets: BarChartDataSet[] = new Array(); // one dataset for each type + + constructor(private eventBus: EventBusService) { } + + ngOnInit() { + this.chartContainerId = CommonUtil.createUniqId('vertical_bar_chart_'); + this.eventBus + .getEvent(EventMap.WindowResizedEvent) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.renderChart(this.bucketList, this.barChartDataSets); + }); + } + + ngOnDestroy() { + this.destroy$.next(true); + this.destroy$.unsubscribe(); + } + + ngAfterViewInit() { + if (this.barChartDataSets) { + this.renderChart(this.bucketList, this.barChartDataSets); + } + } + + ngOnChanges(changes: SimpleChanges) { + if ( + changes['barChartDataSets'] && + changes['barChartDataSets'].currentValue + ) { + this.barChartDataSets = changes['barChartDataSets'].currentValue; + this.renderChart(this.bucketList, this.barChartDataSets); + } + } + + renderChart(bucketList: string[], barChartDataSets: BarChartDataSet[]) { + if (!this.chartContainerId) { + return; + } + const ctx = (document.getElementById(this.chartContainerId) as HTMLCanvasElement).getContext( + '2d' + ); + + if (this.barChart) { + this.barChart.destroy(); + } + + this.barChart = new Chart(ctx!, { + type: 'bar', + data: { + labels: this.bucketList, + datasets: barChartDataSets.map((item, index) => { + return { + label: item.label, + data: item.data, + backgroundColor: item.backgroundColor, + borderWidth: item.borderWidth, + } + }) + }, + options: { + responsive: true, + plugins: { + legend: { + display: true, + position: 'left', + align: 'start', + onClick: (e) => {}, // disable legend click event + onHover: (event, legendItem, legend) => { + let datasetIndex = legendItem.datasetIndex + if (this.barChart != undefined && datasetIndex !== undefined) { + this.barChart.data.datasets[datasetIndex].backgroundColor = this.adjustOpacity(this.barChartDataSets[datasetIndex].backgroundColor, 0.5); + } + this.barChart?.update("resize"); + }, + onLeave: (event, legendItem, legend) => { + let datasetIndex = legendItem.datasetIndex + if (this.barChart != undefined && datasetIndex !== undefined) { + this.barChart.data.datasets[datasetIndex].backgroundColor = this.barChartDataSets[datasetIndex].backgroundColor; + } + this.barChart?.update("resize"); + }, + }, + title: { + display: false, + }, + tooltip: { + enabled: true, + position: 'nearest', + callbacks: { + label: function (context) { + return barChartDataSets[context.datasetIndex].label; + }, + labelColor: function (context) { + return { + borderColor: barChartDataSets[context.datasetIndex].backgroundColor, + backgroundColor: barChartDataSets[context.datasetIndex].backgroundColor, + }; + }, + footer: function (context) { + // show bar description on tooltip footer + let datasetIndex = context[0].datasetIndex; + let dataIndex = context[0].dataIndex; + let nodeCount = context[0].parsed.y + let unit = nodeCount > 1 ? 'nodes' : 'node'; + return "Total: " + nodeCount + " " + unit + "\n\n" + barChartDataSets[datasetIndex].description[dataIndex]; + } + } + }, + }, + scales: { + y: { + ticks: { + stepSize: 1, + precision: 0 + } + } + }, + onHover: (event, chartElement) => { + if (this.barChartDataSets.length > 0) { + if (this.barChart != undefined) { + if (chartElement.length > 0) { + // Reset datasets background color + this.barChart?.data.datasets?.forEach((dataset, i) => { + this.barChartDataSets.forEach((item, index) => { + if (this.barChart != undefined) { + this.barChart.data.datasets[index].backgroundColor = this.barChartDataSets[index].backgroundColor; + } + }); + }); + //Update the target dataset's background color + const datasetIndex = chartElement[0].datasetIndex; + if (this.barChart != undefined) { + this.barChart.data.datasets[datasetIndex].backgroundColor = this.adjustOpacity(this.barChartDataSets[datasetIndex].backgroundColor, 0.5); + } + } else { + // Reset datasets background color + this.barChart?.data.datasets?.forEach((dataset, i) => { + this.barChartDataSets.forEach((item, datasetIndex) => { + if (this.barChart != undefined) { + this.barChart.data.datasets[datasetIndex].backgroundColor = this.barChartDataSets[datasetIndex].backgroundColor; + } + }); + }); + } + this.barChart?.update("resize"); + } + } + }, + }, + }); + this.barChart.update(); + } + + adjustOpacity(rgba: string, opacity: number) { + const rgbaValues = rgba.match(/[\d.]+/g); + if (!rgbaValues || rgbaValues.length < 4) { + return rgba; + } + rgbaValues[3] = String(opacity); + return `rgba(${rgbaValues.join(',')})`; + } +} diff --git a/src/app/models/chart-data.model.ts b/src/app/models/chart-data.model.ts index b1f180e8..81fd3a62 100644 --- a/src/app/models/chart-data.model.ts +++ b/src/app/models/chart-data.model.ts @@ -29,3 +29,21 @@ export class ChartDataItem { this.description = description; } } + +export class BarChartDataSet { + label: string; + data: number[]; + avgUtilizationRate: number; + backgroundColor: string; + borderWidth: number; + description: string[]; + + constructor(label: string, data: number[], avgUtilizationRate: number, backgroundColor: string, borderWidth: number, description: string[]) { + this.label = label; + this.data = data; + this.avgUtilizationRate = avgUtilizationRate; + this.backgroundColor = backgroundColor; + this.borderWidth = borderWidth; + this.description = description; + } +} diff --git a/src/app/models/node-utilization.model.ts b/src/app/models/node-utilization.model.ts index a21c4ed1..d56a7225 100644 --- a/src/app/models/node-utilization.model.ts +++ b/src/app/models/node-utilization.model.ts @@ -27,10 +27,10 @@ export class NodeUtilization { numOfNodes: number; nodeNames: null | string[]; }[], - ) {} + ) { } // transform NodeUtilization to NodeUtilizationChartData for NodeUtilization bar chart - toNodeUtilizationChartData(){ + toNodeUtilizationChartData() { const MAX_NODES_IN_DESCRIPTION = 15; const backgroundColor = DEFAULT_BAR_COLOR; let type = this.type; @@ -42,9 +42,9 @@ export class NodeUtilization { let description: string | undefined; if (nodeNames && nodeNames.length > MAX_NODES_IN_DESCRIPTION) { // only put MAX_NODES_IN_DESCRIPTION nodes in description, others will be replaced by '...N more' - description = nodeNames.slice(0, MAX_NODES_IN_DESCRIPTION).sort().join("\n") + "\n..."+ (nodeNames.length-MAX_NODES_IN_DESCRIPTION) +" more"; + description = nodeNames.slice(0, MAX_NODES_IN_DESCRIPTION).sort().join("\n") + "\n..." + (nodeNames.length - MAX_NODES_IN_DESCRIPTION) + " more"; } else { - description = nodeNames?nodeNames.sort().join("\n"): undefined; + description = nodeNames ? nodeNames.sort().join("\n") : undefined; } chartDataItems.push(new ChartDataItem( bucketName, @@ -57,6 +57,14 @@ export class NodeUtilization { } } +export class NodeUtilizationsInfo { + constructor( + public clusterId: string, + public partition: string, + public utilizations: NodeUtilization[], + ) { } +} + export class NodeUtilizationChartData { type: string; chartDataItems: ChartDataItem[]; diff --git a/src/app/services/scheduler/scheduler.service.ts b/src/app/services/scheduler/scheduler.service.ts index 012f307d..f848bb31 100644 --- a/src/app/services/scheduler/scheduler.service.ts +++ b/src/app/services/scheduler/scheduler.service.ts @@ -23,7 +23,7 @@ import {AppInfo} from '@app/models/app-info.model'; import {ClusterInfo} from '@app/models/cluster-info.model'; import {HistoryInfo} from '@app/models/history-info.model'; import {NodeInfo} from '@app/models/node-info.model'; -import {NodeUtilization, NodeUtilizationChartData} from '@app/models/node-utilization.model'; +import {NodeUtilization, NodeUtilizationsInfo} from '@app/models/node-utilization.model'; import {Partition} from '@app/models/partition-info.model'; import {QueueInfo, QueuePropertyItem} from '@app/models/queue-info.model'; @@ -255,11 +255,16 @@ export class SchedulerService { ); } - fetchClusterNodeUtilization(): Observable{ + fetchNodeUtilization(): Observable{ const nodeUtilizationUrl = `${this.envConfig.getSchedulerWebAddress()}/ws/v1/scheduler/node-utilization`; return this.httpClient.get(nodeUtilizationUrl).pipe(map((data: any) => data as NodeUtilization)); } + fetchNodeUtilizationsInfo(): Observable{ + const nodeUtilizationsUrl = `${this.envConfig.getSchedulerWebAddress()}/ws/v1/scheduler/node-utilizations`; + return this.httpClient.get(nodeUtilizationsUrl).pipe(map((data: any) => data as NodeUtilizationsInfo[])); + } + fecthHealthchecks(): Observable { const healthCheckUrl = `${this.envConfig.getSchedulerWebAddress()}/ws/v1/scheduler/healthcheck`; return this.httpClient.get(healthCheckUrl).pipe(map((data: any) => data as SchedulerHealthInfo)); @@ -329,7 +334,7 @@ export class SchedulerService { const formatted: string[] = []; if (resource) { // Object.keys() didn't guarantee the order of keys, sort it before iterate. - Object.keys(resource).sort(this.resourcesCompareFn).forEach((key) => { + Object.keys(resource).sort(CommonUtil.resourcesCompareFn).forEach((key) => { let value = resource[key]; let formattedKey = key; let formattedValue : string; @@ -364,24 +369,6 @@ export class SchedulerService { return formatted.join(', '); } - private resourcesCompareFn(a: string, b: string): number { - // define the order of resources - const resourceOrder: { [key: string]: number } = { - "memory": 1, - "vcore": 2, - "pods": 3, - "ephemeral-storage": 4 - }; - const orderA = a in resourceOrder ? resourceOrder[a] : Number.MAX_SAFE_INTEGER; - const orderB = b in resourceOrder ? resourceOrder[b] : Number.MAX_SAFE_INTEGER; - - if (orderA !== orderB) { - return orderA - orderB; // Resources in the order defined above - } else { - return a.localeCompare(b); // Other resources will be in lexicographic order - } - } - private formatPercent(resource: SchedulerResourceInfo): string { const formatted = []; diff --git a/src/app/testing/mocks.ts b/src/app/testing/mocks.ts index 1e0f4917..805dadb6 100644 --- a/src/app/testing/mocks.ts +++ b/src/app/testing/mocks.ts @@ -31,7 +31,7 @@ export const MockSchedulerService = { fetchAppHistory: () => of([]), fetchContainerHistory: () => of([]), fetchNodeList: () => of([]), - fetchClusterNodeUtilization: () => of([]), + fetchNodeUtilization: () => of([]), fecthHealthchecks: () => of([]), }; diff --git a/src/app/utils/common.util.ts b/src/app/utils/common.util.ts index 4c71722b..98f2a188 100644 --- a/src/app/utils/common.util.ts +++ b/src/app/utils/common.util.ts @@ -97,4 +97,22 @@ export class CommonUtil { } return NOT_AVAILABLE; } + + static resourcesCompareFn(a: string, b: string): number { + // define the order of resources + const resourceOrder: { [key: string]: number } = { + "memory": 1, + "vcore": 2, + "pods": 3, + "ephemeral-storage": 4 + }; + const orderA = a in resourceOrder ? resourceOrder[a] : Number.MAX_SAFE_INTEGER; + const orderB = b in resourceOrder ? resourceOrder[b] : Number.MAX_SAFE_INTEGER; + + if (orderA !== orderB) { + return orderA - orderB; // Resources in the order defined above + } else { + return a.localeCompare(b); // Other resources will be in lexicographic order + } + } } diff --git a/src/app/utils/constants.ts b/src/app/utils/constants.ts index 643e9eb1..da3d13de 100644 --- a/src/app/utils/constants.ts +++ b/src/app/utils/constants.ts @@ -20,4 +20,5 @@ export const DEFAULT_PARTITION_VALUE = ''; export const DEFAULT_PROTOCOL = 'http:'; export const NOT_AVAILABLE = 'n/a'; export const PARTITION_DEFAULT = 'default'; -export const DEFAULT_BAR_COLOR = '#4285f4'; \ No newline at end of file +export const DEFAULT_BAR_COLOR = 'rgba(66, 133, 244, 1)'; +export const CHART_COLORS = ['rgba(66, 133, 244, 1)', 'rgb(219, 68, 55, 1)', 'rgb(244, 180, 0, 1)', 'rgb(15, 157, 88, 1)', 'rgb(255, 109, 0, 1)', 'rgb(57, 73, 171, 1)', 'rgb(250, 204, 84, 1)', 'rgb(38, 187, 240, 1)', 'rgb(204, 97, 100, 1)', 'rgb(96, 206, 165, 1)']