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)']