This is an automated email from the ASF dual-hosted git repository.

wilfreds pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/yunikorn-web.git


The following commit(s) were added to refs/heads/master by this push:
     new 1a51966  [YUNIKORN-1362] Filtering node list on UI (#153)
1a51966 is described below

commit 1a51966f637852b8e1ef53e85744cdf88883cb4b
Author: wusamzong <[email protected]>
AuthorDate: Thu Jan 25 14:16:55 2024 +1100

    [YUNIKORN-1362] Filtering node list on UI (#153)
    
    Filter nodes based on the node attributes:
    * Attributes are exposed in the node table (detail mode)
    * A filter input has been introduced
    * Nodes not matching filter are hidden
    * The filter results are highlighted
    
    Two nodes have been explicitly added to db.json for testing and validating
    the filter functionality.
    
    Closes: #153
    
    Signed-off-by: Wilfred Spiegelenburg <[email protected]>
---
 json-db.json                                       | 130 +++++++++++++++++++++
 src/app/app.module.ts                              |   2 +
 .../nodes-view/highlighttable-search.pipe.ts}      |  31 +++--
 .../nodes-view/nodes-view.component.html           |  48 ++++++--
 .../nodes-view/nodes-view.component.scss           |  56 ++++++---
 .../nodes-view/nodes-view.component.spec.ts        |   8 ++
 .../components/nodes-view/nodes-view.component.ts  |  71 ++++++++++-
 src/app/models/node-info.model.ts                  |   7 +-
 src/app/services/scheduler/scheduler.service.ts    |   3 +-
 src/styles.scss                                    |   5 -
 10 files changed, 306 insertions(+), 55 deletions(-)

diff --git a/json-db.json b/json-db.json
index d495481..4dede38 100644
--- a/json-db.json
+++ b/json-db.json
@@ -589,6 +589,20 @@
   "nodes": [
     {
       "nodeID": "lima-rancher-desktop",
+      "attributes":{
+        "beta.kubernetes.io/arch":"amd64",
+        "beta.kubernetes.io/os":"linux",
+        "kubernetes.io/arch":"amd64",
+        "kubernetes.io/hostname":"lima-rancher-desktop",
+        "kubernetes.io/os":"linux",
+        "node-role.kubernetes.io/control-plane":"",
+        "node.kubernetes.io/exclude-from-external-load-balancers":"",
+        "ready":"true",
+        "si.io/hostname":"lima-rancher-desktop",
+        "si.io/rackname":"/rack-default",
+        "si/instance-type":"",
+        "si/node-partition":"[mycluster]default"
+      },
       "hostName": "",
       "rackName": "",
       "capacity": {
@@ -749,6 +763,122 @@
       "schedulable": true,
       "isReserved": false,
       "reservations": []
+    },
+    {
+      "nodeID": "lima-rancher-desktop2",
+      "attributes":{
+        "beta.kubernetes.io/arch":"arm64",
+        "beta.kubernetes.io/os":"linux",
+        "kubernetes.io/arch":"arm64",
+        "kubernetes.io/hostname":"lima-rancher-desktop2",
+        "kubernetes.io/os":"linux",
+        "node-role.kubernetes.io/control-plane":"",
+        "node.kubernetes.io/exclude-from-external-load-balancers":"",
+        "ready":"true",
+        "si.io/hostname":"lima-rancher-desktop2",
+        "si.io/rackname":"/rack-default",
+        "si/instance-type":"",
+        "si/node-partition":"[mycluster]default"
+      },
+      "hostName": "",
+      "rackName": "",
+      "capacity": {
+        "ephemeral-storage": 99833802265,
+        "hugepages-1Gi": 0,
+        "hugepages-2Mi": 0,
+        "hugepages-32Mi": 0,
+        "hugepages-64Ki": 0,
+        "memory": 4110970880,
+        "pods": 110,
+        "vcore": 2000
+      },
+      "allocated": {
+        "memory": 0,
+        "pods": 0,
+        "vcore": 0
+      },
+      "occupied": {
+        "memory": 0,
+        "pods": 0,
+        "vcore": 0
+      },
+      "available": {
+        "ephemeral-storage": 99833802265,
+        "hugepages-1Gi": 0,
+        "hugepages-2Mi": 0,
+        "hugepages-32Mi": 0,
+        "hugepages-64Ki": 0,
+        "memory": 4110970880,
+        "pods": 110,
+        "vcore": 2000
+      },
+      "utilized": {
+        "memory": 0,
+        "pods": 0,
+        "vcore": 0
+      },
+      "allocations": [],
+      "schedulable": true,
+      "isReserved": false,
+      "reservations": []
+    },
+    {
+      "nodeID": "lima-rancher-desktop3",
+      "attributes":{
+        "beta.kubernetes.io/arch":"arm64",
+        "beta.kubernetes.io/os":"linux",
+        "kubernetes.io/arch":"arm64",
+        "kubernetes.io/hostname":"lima-rancher-desktop3",
+        "kubernetes.io/os":"linux",
+        "node-role.kubernetes.io/control-plane":"",
+        "node.kubernetes.io/exclude-from-external-load-balancers":"",
+        "ready":"true",
+        "si.io/hostname":"lima-rancher-desktop3",
+        "si.io/rackname":"/rack-default",
+        "si/instance-type":"",
+        "si/node-partition":"[mycluster]default"
+      },
+      "hostName": "",
+      "rackName": "",
+      "capacity": {
+        "ephemeral-storage": 99833802265,
+        "hugepages-1Gi": 0,
+        "hugepages-2Mi": 0,
+        "hugepages-32Mi": 0,
+        "hugepages-64Ki": 0,
+        "memory": 4110970880,
+        "pods": 110,
+        "vcore": 2000
+      },
+      "allocated": {
+        "memory": 0,
+        "pods": 0,
+        "vcore": 0
+      },
+      "occupied": {
+        "memory": 0,
+        "pods": 0,
+        "vcore": 0
+      },
+      "available": {
+        "ephemeral-storage": 99833802265,
+        "hugepages-1Gi": 0,
+        "hugepages-2Mi": 0,
+        "hugepages-32Mi": 0,
+        "hugepages-64Ki": 0,
+        "memory": 4110970880,
+        "pods": 110,
+        "vcore": 2000
+      },
+      "utilized": {
+        "memory": 0,
+        "pods": 0,
+        "vcore": 0
+      },
+      "allocations": [],
+      "schedulable": true,
+      "isReserved": false,
+      "reservations": []
     }
   ],
   "node-utilization": {
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 6cc8fc8..a325942 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -55,6 +55,7 @@ import { ContainerHistoryComponent } from 
'@app/components/container-history/con
 import { QueueRackComponent } from 
'@app/components/queue-rack/queue-rack.component';
 import { AppsViewComponent } from 
'@app/components/apps-view/apps-view.component';
 import { NodesViewComponent } from 
'@app/components/nodes-view/nodes-view.component';
+import { HighlightSearchPipe } from 
'@app/components/nodes-view/highlighttable-search.pipe';
 import { ErrorViewComponent } from 
'@app/components/error-view/error-view.component';
 import { StatusViewComponent } from 
'@app/components/status-view/status-view.component';
 import { HealthchecksComponent } from 
'@app/components/healthchecks/healthchecks.component';
@@ -75,6 +76,7 @@ import { BarChartComponent } from 
'@app/components/bar-chart/bar-chart.component
     QueueRackComponent,
     AppsViewComponent,
     NodesViewComponent,
+    HighlightSearchPipe,
     ErrorViewComponent,
     StatusViewComponent,
     HealthchecksComponent,
diff --git a/src/app/models/node-info.model.ts 
b/src/app/components/nodes-view/highlighttable-search.pipe.ts
similarity index 50%
copy from src/app/models/node-info.model.ts
copy to src/app/components/nodes-view/highlighttable-search.pipe.ts
index 41b1bb6..3f10da9 100644
--- a/src/app/models/node-info.model.ts
+++ b/src/app/components/nodes-view/highlighttable-search.pipe.ts
@@ -16,24 +16,21 @@
  * limitations under the License.
  */
 
-import { AllocationInfo } from './alloc-info.model';
+import { Pipe, PipeTransform } from '@angular/core';
 
-export class NodeInfo {
-  isSelected = false;
-  constructor(
-    public nodeId: string,
-    public hostName: string,
-    public rackName: string,
-    public partitionName: string,
-    public capacity: string,
-    public allocated: string,
-    public occupied: string,
-    public available: string,
-    public utilized: string,
-    public allocations: AllocationInfo[] | null
-  ) {}
+@Pipe({
+  name: 'highlightSearch'
+})
+export class HighlightSearchPipe implements PipeTransform {
 
-  setAllocations(allocs: AllocationInfo[]) {
-    this.allocations = allocs;
+  transform(value: string, search: string): string {
+    const valueStr = String(value); // Ensure numeric values are converted to 
strings
+    // (?![^&;]+;) - to ensure that there are no HTML entities (such as &amp; 
or &lt;) 
+    //               ex. value = "&lt;span&gt;" search = "span", The word 
'span' will not be included in the matches
+    // (?!<[^<>]*) - to ensure that there are no HTML tags (<...>)
+    //               ex. value = "<span>" search = "span", The word 'span' 
will not be included in the matches
+    return valueStr.replace(new RegExp('(?![^&;]+;)(?!<[^<>]*)(' + search + 
')(?![^<>]*>)(?![^&;]+;)', 'gi'), '<strong>$1</strong>');
   }
+
 }
+
diff --git a/src/app/components/nodes-view/nodes-view.component.html 
b/src/app/components/nodes-view/nodes-view.component.html
index 2b9ea57..17b8f8b 100644
--- a/src/app/components/nodes-view/nodes-view.component.html
+++ b/src/app/components/nodes-view/nodes-view.component.html
@@ -30,11 +30,22 @@
       </mat-select>
     </mat-form-field>
   </div>
-  <div class="btn-wrapper">
-    <button class="btn" (click)="toggle()" 
[style.background-color]="detailToggle ? '#313d54' : '#f5f5f5'">
-      <i class="material-icons __icon" [style.color]="detailToggle ? '#f5f5f5' 
: '#313d54' ">more_horiz</i>
-    </button>
+
+  <div class="right-wrapper">
+    <div class="filter">
+      <mat-form-field>
+        <mat-label>Filter</mat-label>
+        <input matInput (keyup)="applyFilter($event)" placeholder="Ex. amd64" 
#input>
+      </mat-form-field>
+    </div>
+    
+    <div class="btn-wrapper">
+      <button class="btn" (click)="toggle()" 
[style.background-color]="detailToggle ? '#313d54' : '#f5f5f5'">
+        <i class="material-icons __icon" [style.color]="detailToggle ? 
'#f5f5f5' : '#313d54' ">more_horiz</i>
+      </button>
+    </div>
   </div>
+  
 </div>
 <div class="nodes-view">
   <div class="mat-elevation-z8">
@@ -53,24 +64,37 @@
               <ng-container 
*ngIf="columnDef.colFormatter(element[columnDef.colId]) as colValue">
                   <ul class="mat-res-ul">
                     <ng-container *ngFor="let resource of 
formatResources(colValue); let i = index">
-                      <li class="mat-res-li" *ngIf="i<2">
-                        {{resource}}
-                      </li>
-                      <li class="mat-res-li" *ngIf="i>=2 && detailToggle">
-                        {{resource}}
-                      </li>
+                      <li class="mat-res-li" *ngIf="i<2" 
+                          [innerHTML]="resource | highlightSearch: 
filterValue"></li>
+                      <li class="mat-res-li" *ngIf="i>=2 && detailToggle"
+                          [innerHTML]="resource | highlightSearch: 
filterValue"></li>
                     </ng-container>
                   </ul>
               </ng-container>
             </ng-container>
             <ng-template #showNodeRawData>
-              <span>{{ element[columnDef.colId] }}</span>
+              <span [innerHTML]="element[columnDef.colId] | highlightSearch: 
filterValue"></span>
             </ng-template>
           </mat-cell>
         </ng-container>
 
         <ng-template #renderNext_1>
-          <mat-cell *matCellDef="let element">{{ element[columnDef.colId] || 
'n/a' }}</mat-cell>
+            
+            <ng-container *ngIf="columnDef.colId!=='attributes'">
+              <mat-cell *matCellDef="let element">
+                <span [innerHTML]="element[columnDef.colId] || 'n/a' | 
highlightSearch: filterValue"></span>
+              </mat-cell>
+            </ng-container>
+            
+            <ng-container *ngIf="columnDef.colId=='attributes'">
+              <mat-cell *matCellDef="let element" class="mat-attr">
+                <ul class="mat-attr-ul">
+                  <ng-container *ngFor="let attribute of 
formatAttribute(element[columnDef.colId]); let i = index">
+                    <li class="mat-attr-li" [innerHTML]="attribute | 
highlightSearch: filterValue"></li>
+                  </ng-container>      
+                </ul>
+              </mat-cell>
+            </ng-container>
         </ng-template>
       </ng-container>
 
diff --git a/src/app/components/nodes-view/nodes-view.component.scss 
b/src/app/components/nodes-view/nodes-view.component.scss
index 545d27b..2dd019f 100644
--- a/src/app/components/nodes-view/nodes-view.component.scss
+++ b/src/app/components/nodes-view/nodes-view.component.scss
@@ -29,26 +29,41 @@
       margin-right: 10px;
     }
   }
-  .btn-wrapper {
-    filter: drop-shadow(0px 2px 1px rgba(90, 90, 90, 0.5));
-    &:hover{
-      filter: drop-shadow(0px 3px 3px rgba(90, 90, 90, 0.5));
-    }
-    :hover{
-      cursor: pointer;
-    }
-    .btn{
-      display: block;
-      border: none;
-      padding: 13px 24px;
-      border-radius: 5px;
-      font-size: 24px;
-      transform: translateY(-13px);
+  .right-wrapper{
+    display: flex;
+    flex-direction: row;
+    justify-content: flex-end;
+    align-items: center;
+    width: 35%;
+    .filter{
+      width: 100%;
+      margin: 0 30px;
+      .mat-mdc-form-field{
+        width: 100%;
+      }
     }
-    .material-icons{
-      transform: translateY(2px);
+    .btn-wrapper {
+      filter: drop-shadow(0px 2px 1px rgba(90, 90, 90, 0.5));
+      &:hover{
+        filter: drop-shadow(0px 3px 3px rgba(90, 90, 90, 0.5));
+      }
+      :hover{
+        cursor: pointer;
+      }
+      .btn{
+        display: block;
+        border: none;
+        padding: 13px 24px;
+        border-radius: 5px;
+        font-size: 24px;
+        transform: translateY(-13px);
+      }
+      .material-icons{
+        transform: translateY(2px);
+      }
     }
   }
+  
 }
 
 .nodes-view {
@@ -79,6 +94,13 @@
         color: #8d00d4;
       }
     }
+    .mat-attr-ul{
+      padding: 0;
+      margin: 0;
+      .mat-attr-li{
+        list-style-type: none;
+      }
+    }
   }
   .mat-mdc-row {
     &:hover {
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 9f61f99..17ca95c 100644
--- a/src/app/components/nodes-view/nodes-view.component.spec.ts
+++ b/src/app/components/nodes-view/nodes-view.component.spec.ts
@@ -29,6 +29,9 @@ import { MatPaginatorModule } from 
'@angular/material/paginator';
 import { MatDividerModule } from '@angular/material/divider';
 import { MatSortModule } from '@angular/material/sort';
 import { MatSelectModule } from '@angular/material/select';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { ReactiveFormsModule } from '@angular/forms';
 
 describe('NodesViewComponent', () => {
   let component: NodesViewComponent;
@@ -54,6 +57,11 @@ describe('NodesViewComponent', () => {
   });
 
   beforeEach(() => {
+    TestBed.configureTestingModule({
+      declarations: [NodesViewComponent],
+      imports: [MatFormFieldModule, MatInputModule, ReactiveFormsModule],
+    })
+    .compileComponents();
     fixture = TestBed.createComponent(NodesViewComponent);
     component = fixture.componentInstance;
     fixture.detectChanges();
diff --git a/src/app/components/nodes-view/nodes-view.component.ts 
b/src/app/components/nodes-view/nodes-view.component.ts
index 081dbb2..a9c77d7 100644
--- a/src/app/components/nodes-view/nodes-view.component.ts
+++ b/src/app/components/nodes-view/nodes-view.component.ts
@@ -22,7 +22,7 @@ import { MatSelectChange } from '@angular/material/select';
 import { MatSort } from '@angular/material/sort';
 import { MatTableDataSource } from '@angular/material/table';
 import { NgxSpinnerService } from 'ngx-spinner';
-import { finalize, flatMap } from 'rxjs/operators';
+import { finalize } from 'rxjs/operators';
 
 import { SchedulerService } from '@app/services/scheduler/scheduler.service';
 import { NodeInfo } from '@app/models/node-info.model';
@@ -56,6 +56,7 @@ export class NodesViewComponent implements OnInit {
   partitionSelected = '';
 
   detailToggle: boolean = false;
+  filterValue: string = '';
 
   constructor(private scheduler: SchedulerService, private spinner: 
NgxSpinnerService) {}
 
@@ -69,6 +70,7 @@ export class NodesViewComponent implements OnInit {
       { colId: 'nodeId', colName: 'Node ID' },
       { colId: 'rackName', colName: 'Rack Name' },
       { colId: 'hostName', colName: 'Host Name' },
+      { colId: 'attributes', colName: 'Attributes' },
       { colId: 'capacity', colName: 'Capacity', colFormatter: 
CommonUtil.resourceColumnFormatter },
       { colId: 'occupied', colName: 'Used', colFormatter: 
CommonUtil.resourceColumnFormatter },
       {
@@ -139,6 +141,7 @@ export class NodesViewComponent implements OnInit {
       )
       .subscribe((data) => {
         this.nodeDataSource.data = data;
+        this.formatColumn();
       });
   }
 
@@ -189,6 +192,28 @@ export class NodesViewComponent implements OnInit {
     return this.allocDataSource.data && this.allocDataSource.data.length === 0;
   }
 
+  formatColumn(){
+    if(this.nodeDataSource.data.length==0){
+      return
+    }
+    this.nodeColumnIds.forEach((colId)=>{
+      let emptyCell=this.nodeDataSource.data.filter((node: NodeInfo)=>{
+        if (colId === 'indicatorIcon'){
+          return false;
+        }
+        if (!(colId in node)) {
+          console.error(`Property '${colId}' does not exist on Node.`);
+          return false;
+        }
+        return (node as any)[colId]==="" || (node as any)[colId]==="n/a";
+      })
+      if (emptyCell.length==this.nodeDataSource.data.length){
+        this.nodeColumnIds = this.nodeColumnIds.filter(el => el!==colId);
+        this.nodeColumnIds = this.nodeColumnIds.filter(colId => 
colId!=="attributes");
+      }
+    })
+  }
+
   onPartitionSelectionChanged(selected: MatSelectChange) {
     this.partitionSelected = selected.value;
     this.clearRowSelection();
@@ -196,7 +221,7 @@ export class NodesViewComponent implements OnInit {
   }
 
   formatResources(colValue:string):string[]{
-    const arr:string[]=colValue.split("<br/>")
+    const arr:string[]=colValue.split("<br/>");
     // Check if there are "cpu" or "Memory" elements in the array
     const hasCpu = arr.some((item) => item.toLowerCase().includes("cpu"));
     const hasMemory = arr.some((item) => 
item.toLowerCase().includes("memory"));
@@ -215,7 +240,49 @@ export class NodesViewComponent implements OnInit {
     return result;
   }
 
+  formatAttribute(attributes:any):string[]{
+    let result:string[]=[];
+    Object.entries(attributes).forEach(entry=>{
+      const [key, value] = entry;
+      if (value==="" || key.includes("si")){
+        return
+      }
+      result.push(key+':'+value);
+    })
+    return result;
+  }
+
   toggle(){
     this.detailToggle = !this.detailToggle;
+    this.displayAttribute(this.detailToggle);
+  }
+
+  displayAttribute(toggle:boolean) {
+    if (toggle){
+      this.nodeColumnIds = [
+        ...this.nodeColumnIds.slice(0, 1),
+        "attributes",
+        ...this.nodeColumnIds.slice(1)
+    ];
+    }else{
+      this.nodeColumnIds=this.nodeColumnIds.filter(colId => 
colId!=="attributes");
+    }
+  }
+
+  filterPredicate: ((data: NodeInfo, filter: string) => boolean) = (data: 
NodeInfo, filter: string): boolean => {
+    // a deep copy of the NodeInfo with formatted attributes for filtering
+    const deepCopy = JSON.parse(JSON.stringify(data));
+    Object.entries(deepCopy.attributes).forEach(entry=>{
+      const [key, value] = entry;
+      deepCopy.attributes[key]= `${key}:${value}`
+    })
+    const objectString = JSON.stringify(deepCopy).toLowerCase();
+    return objectString.includes(filter);
+  };
+
+  applyFilter(event: Event): void {
+    this.filterValue = (event.target as 
HTMLInputElement).value.trim().toLowerCase();
+    this.nodeDataSource.filter = this.filterValue;
+    this.nodeDataSource.filterPredicate = this.filterPredicate;
   }
 }
diff --git a/src/app/models/node-info.model.ts 
b/src/app/models/node-info.model.ts
index 41b1bb6..3c5858a 100644
--- a/src/app/models/node-info.model.ts
+++ b/src/app/models/node-info.model.ts
@@ -30,10 +30,15 @@ export class NodeInfo {
     public occupied: string,
     public available: string,
     public utilized: string,
-    public allocations: AllocationInfo[] | null
+    public allocations: AllocationInfo[] | null,
+    public attributes: Attributes,
   ) {}
 
   setAllocations(allocs: AllocationInfo[]) {
     this.allocations = allocs;
   }
 }
+
+export interface Attributes{
+  [key: string]: string;
+}
diff --git a/src/app/services/scheduler/scheduler.service.ts 
b/src/app/services/scheduler/scheduler.service.ts
index 993c2c1..012f307 100644
--- a/src/app/services/scheduler/scheduler.service.ts
+++ b/src/app/services/scheduler/scheduler.service.ts
@@ -205,7 +205,8 @@ export class SchedulerService {
               this.formatResource(node['occupied'] as SchedulerResourceInfo),
               this.formatResource(node['available'] as SchedulerResourceInfo),
               this.formatPercent(node['utilized'] as SchedulerResourceInfo),
-              []
+              [],
+              node['attributes'],
             );
 
             const allocations = node['allocations'];
diff --git a/src/styles.scss b/src/styles.scss
index ec0c605..650a4bb 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -95,11 +95,6 @@ body {
   color: #666;
 }
 
-strong {
-  font-weight: 500;
-  color: #333;
-}
-
 p {
   margin: 10px 0;
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to