[YUNIKORN-2140] Improve presentation of resources (#146)

Format resources with units instead of simple integers.
CPU: m, (bare), k, M, G, T, P, E (powers of 10, no units)
Memory: B, KiB, MiB, GiB, TiB, PiB, EiB (powers of 2, byte units)
Ephemeral storage: B, KB, MB, GB, TB, PB, EB (powers of 10, byte units)
All others: (bare), k, M, G, T, P, E (powers of 10, no units)

Replace n/a with 0 for values that are not given. Use n/a if the
resource is not set at all.

Closes: #146

Signed-off-by: Wilfred Spiegelenburg <wilfreds@apache.org>
diff --git a/src/app/services/scheduler/scheduler.service.spec.ts b/src/app/services/scheduler/scheduler.service.spec.ts
index 3e10742..2a73c3a 100644
--- a/src/app/services/scheduler/scheduler.service.spec.ts
+++ b/src/app/services/scheduler/scheduler.service.spec.ts
@@ -20,25 +20,129 @@
 import {TestBed} from '@angular/core/testing';
 import {EnvconfigService} from '@app/services/envconfig/envconfig.service';
 import {MockEnvconfigService} from '@app/testing/mocks';
-import {configureTestSuite} from 'ng-bullet';
 
 import {SchedulerService} from './scheduler.service';
+import {SchedulerResourceInfo} from '@app/models/resource-info.model';
+import {NOT_AVAILABLE} from '@app/utils/constants';
 
 describe('SchedulerService', () => {
   let service: SchedulerService;
 
-  configureTestSuite(() => {
+  beforeEach(() => {
     TestBed.configureTestingModule({
       imports: [HttpClientTestingModule],
       providers: [SchedulerService, { provide: EnvconfigService, useValue: MockEnvconfigService }],
     });
-  });
-
-  beforeEach(() => {
     service = TestBed.inject(SchedulerService);
   });
 
   it('should create the service', () => {
     expect(service).toBeTruthy();
+    
+  });
+
+  it('should format SchedulerResourceInfo correctly', () => {
+    type TestCase = {
+      description: string;
+      schedulerResourceInfo: SchedulerResourceInfo;
+      expected: string;
+    };
+  
+    const testCases: TestCase[] = [
+      {
+        description: 'test simple resourceInfo',
+        schedulerResourceInfo: {
+          'memory': 1024,
+          'vcore': 2,
+        },
+        expected: 'Memory: 1 KiB, CPU: 2m'
+      },
+      {
+        description: 'test undefined resourceInfo',
+        schedulerResourceInfo : undefined as any,
+        expected: `${NOT_AVAILABLE}`
+      },
+      {
+        description: 'test empty resourceInfo',
+        schedulerResourceInfo : {} as any,
+        expected: `${NOT_AVAILABLE}`
+      },
+      {
+        description: 'Test zero values',
+        schedulerResourceInfo: {
+          'memory': 0,
+          'vcore': 0,
+          'ephemeral-storage': 0,
+          'hugepages-2Mi': 0,
+          'hugepages-1Gi': 0,
+          'pods': 0
+        },
+        expected: 'Memory: 0 B, CPU: 0, pods: 0, ephemeral-storage: 0 B, hugepages-1Gi: 0 B, hugepages-2Mi: 0 B'
+      },
+      {
+        description: 'Test resource ordering',
+        schedulerResourceInfo: {
+          'ephemeral-storage': 2048,
+          'memory': 1024,
+          'vcore': 2,
+          'TPU': 30000,
+          'GPU': 40000,
+          'hugepages-2Mi':2097152,
+          'hugepages-1Gi':1073741824,
+          'pods': 10000
+        },
+        expected: 'Memory: 1 KiB, CPU: 2m, pods: 10k, ephemeral-storage: 2.05 kB, GPU: 40k, hugepages-1Gi: 1 GiB, hugepages-2Mi: 2 MiB, TPU: 30k'
+      }
+    ];
+
+    testCases.forEach((testCase: TestCase) => {
+      const result = (service as any).formatResource(testCase.schedulerResourceInfo); // ignore type typecheck to access private method
+      expect(result).toEqual(testCase.expected);
+    });
+  });
+
+
+  it('should format SchedulerResourceInfo percentage correctly', () => {
+    type TestCase = {
+      description: string;
+      schedulerResourceInfo: SchedulerResourceInfo;
+      expected: string;
+    };
+  
+    const testCases: TestCase[] = [
+      {
+        description: 'test simple resourceInfo',
+        schedulerResourceInfo: {
+          'memory': 10,
+          'vcore': 50,
+        },
+        expected: 'Memory: 10%, CPU: 50%'
+      },
+      {
+        description: 'test undefined resourceInfo',
+        schedulerResourceInfo : undefined as any,
+        expected: `${NOT_AVAILABLE}`
+      },
+      {
+        description: 'test empty resourceInfo',
+        schedulerResourceInfo : {} as any,
+        expected: `${NOT_AVAILABLE}`
+      },
+      {
+        description: 'Test zero values and will only show memory and cpu',
+        schedulerResourceInfo: {
+          'memory': 0,
+          'vcore': 0,
+          'pods': 0,
+          'ephemeral-storage': 0,
+        },
+        expected: 'Memory: 0%, CPU: 0%'
+      }
+    ];
+
+    testCases.forEach((testCase: TestCase) => {
+      const result = (service as any).formatPercent(testCase.schedulerResourceInfo); // ignore type typecheck to access private method
+      expect(result).toEqual(testCase.expected);
+    });
   });
 });
diff --git a/src/app/services/scheduler/scheduler.service.ts b/src/app/services/scheduler/scheduler.service.ts
index 3d14f91..044ea3b 100644
--- a/src/app/services/scheduler/scheduler.service.ts
+++ b/src/app/services/scheduler/scheduler.service.ts
@@ -325,65 +325,76 @@
   }
 
   private formatResource(resource: SchedulerResourceInfo): string {
-    const formatted = [];
+    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) => {
+        let value = resource[key];
+        let formattedKey = key;
+        let formattedValue : string;
 
-    if (resource && resource.memory !== undefined) {
-      formatted.push(`Memory: ${CommonUtil.formatBytes(resource.memory)}`);
-    } else {
-      formatted.push(`Memory: ${NOT_AVAILABLE}`);
-    }
-
-    if (resource && resource.vcore !== undefined) {
-      formatted.push(`CPU: ${CommonUtil.formatCount(resource.vcore)}`);
-    } else {
-      formatted.push(`CPU: ${NOT_AVAILABLE}`);
-    }
-
-    if (resource){
-      Object.keys(resource).forEach((key) => {
         switch(key){
           case "memory":
-          case "vcore":{
+            formattedKey = "Memory";
+            formattedValue = CommonUtil.formatMemoryBytes(value);
             break;
-          }
-          case "ephemeral-storage":{
-            if (resource[`ephemeral-storage`] == 0) {
-              formatted.push(`ephemeral-storage: ${NOT_AVAILABLE}`);
-            }else{
-              formatted.push(`ephemeral-storage: ${CommonUtil.formatBytes(resource[key])}`);
+          case "vcore":
+            formattedKey = "CPU";
+            formattedValue = CommonUtil.formatCpuCore(value);
+            break;
+          case "ephemeral-storage":
+            formattedValue = CommonUtil.formatEphemeralStorageBytes(value);
+            break;
+          default:
+            if (key.startsWith('hugepages-')) {
+              formattedValue = CommonUtil.formatMemoryBytes(value);
+            } else{
+              formattedValue = CommonUtil.formatOtherResource(value);
             }
             break;
-          }
-          default:{
-            if (resource[key] == 0) {
-              formatted.push(`${key}: ${NOT_AVAILABLE}`);
-            }else{
-              formatted.push(`${key}: ${CommonUtil.formatOtherResource(resource[key])}`);
-            }
-            break;
-          }
-        }
+         }
+        formatted.push(`${formattedKey}: ${formattedValue}`);
       });
     }
-    
+
+    if (formatted.length === 0) {
+      return NOT_AVAILABLE;
+    }
     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 = [];
 
-    if (resource && resource.memory !== undefined) {
-      formatted.push(`Memory: ${CommonUtil.formatPercent(resource.memory)}`);
-    } else {
-      formatted.push(`Memory: ${NOT_AVAILABLE}`);
+    if (resource) {
+      if (resource.memory !== undefined){
+        formatted.push(`Memory: ${CommonUtil.formatPercent(resource.memory)}`);
+      }
+      if (resource.vcore !== undefined){
+        formatted.push(`CPU: ${CommonUtil.formatPercent(resource.vcore)}`);
+      }
     }
-
-    if (resource && resource.vcore !== undefined) {
-      formatted.push(`CPU: ${CommonUtil.formatPercent(resource.vcore)}`);
-    } else {
-      formatted.push(`CPU: ${NOT_AVAILABLE}`);
+    if (formatted.length === 0) {
+      return NOT_AVAILABLE;
     }
-
     return formatted.join(', ');
   }
 
diff --git a/src/app/utils/common.util.spec.ts b/src/app/utils/common.util.spec.ts
index ddf25eb..831e057 100644
--- a/src/app/utils/common.util.spec.ts
+++ b/src/app/utils/common.util.spec.ts
@@ -23,16 +23,39 @@
     expect(CommonUtil.createUniqId).toBeTruthy();
   });
 
-  it('should have formatBytes method', () => {
-    expect(CommonUtil.formatBytes).toBeTruthy();
+  it('checking formatMemoryBytes method result', () => {
+    const inputs: number[] = [0, 100, 1100, 1200000, 1048576000, 1300000000, 1400000000000, 1500000000000000, 1500000000000000000];
+    const expected: string[] = ['0 B', '100 B', '1.07 KiB', '1.14 MiB', '1,000 MiB', '1.21 GiB', '1.27 TiB', '1.33 PiB', '1.3 EiB'];
+    for (let index = 0; index < inputs.length; index = index + 1) {
+      expect(CommonUtil.formatMemoryBytes(inputs[index])).toEqual(expected[index]);
+      expect(CommonUtil.formatMemoryBytes(inputs[index].toString())).toEqual(expected[index]);
+    }
   });
 
-  it('checking formatBytes method result', () => {
-    const inputs: number[] = [100, 1100, 1200000, 1300000000, 1400000000000, 1500000000000000];
-    const expected: string[] = ['100.0 bytes', '1.1 kB', '1.2 MB', '1.3 GB', '1.4 TB', '1.5 PB'];
+  it('checking formatEphemeralStorageBytes method result', () => {
+    const inputs: number[] = [0, 100, 1100, 1200000, 1048576000, 1300000000, 1400000000000, 1500000000000000, 1500000000000000000];
+    const expected: string[] = ['0 B', '100 B', '1.1 kB', '1.2 MB', '1.05 GB', '1.3 GB', '1.4 TB', '1.5 PB', '1.5 EB'];
     for (let index = 0; index < inputs.length; index = index + 1) {
-      expect(CommonUtil.formatBytes(inputs[index])).toEqual(expected[index]);
-      expect(CommonUtil.formatBytes(inputs[index].toString())).toEqual(expected[index]);
+      expect(CommonUtil.formatEphemeralStorageBytes(inputs[index])).toEqual(expected[index]);
+      expect(CommonUtil.formatEphemeralStorageBytes(inputs[index].toString())).toEqual(expected[index]);
+    }
+  });
+
+  it('checking formatCpuCore method result', () => {
+    const inputs: number[] = [0, 100, 1000, 1555, 1555555, 1555555555, 1555555555555, 1555555555555555, 1555555555555555555, 1555555555555555555555];
+    const expected: string[] = ['0','100m', '1', '1.56', '1.56k', '1.56M', '1.56G', '1.56T', '1.56P', '1.56E'];
+    for (let index = 0; index < inputs.length; index = index + 1) {
+      expect(CommonUtil.formatCpuCore(inputs[index])).toEqual(expected[index]);
+      expect(CommonUtil.formatCpuCore(inputs[index].toString())).toEqual(expected[index]);
+    }
+  });
+
+  it('checking formatOtherResource method result', () => {
+    const inputs: number[] = [0, 100, 1000, 1555, 1555555, 1555555555, 1555555555555, 1555555555555555, 1555555555555555555];
+    const expected: string[] = ['0','100', '1k', '1.56k', '1.56M', '1.56G', '1.56T', '1.56P', '1.56E'];
+    for (let index = 0; index < inputs.length; index = index + 1) {
+      expect(CommonUtil.formatOtherResource(inputs[index])).toEqual(expected[index]);
+      expect(CommonUtil.formatOtherResource(inputs[index].toString())).toEqual(expected[index]);
     }
   });
 });
diff --git a/src/app/utils/common.util.ts b/src/app/utils/common.util.ts
index fc1e094..4c71722 100644
--- a/src/app/utils/common.util.ts
+++ b/src/app/utils/common.util.ts
@@ -30,34 +30,55 @@
     return uniqid;
   }
 
-  static formatBytes(value: number | string): string {
-    const units: readonly string[] = ['kB', 'MB', 'GB', 'TB', 'PB'];
-    let unit: string = 'bytes';
-    let toValue = +value
+  static formatMemoryBytes(value: number | string): string {
+    const units: readonly string[] = ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
+    let unit: string = 'B';
+    let toValue = +value;
+    for (let i = 0, unitslen = units.length; toValue / 1024 >= 1 && i < unitslen;i = i + 1) {
+      toValue = toValue / 1024;
+      unit = units[i];
+    }
+    return `${toValue.toLocaleString(undefined, { maximumFractionDigits: 2 })} ${unit}`;
+  }
+
+  static formatEphemeralStorageBytes(value: number | string): string {
+    const units: readonly string[] = ['kB', 'MB', 'GB', 'TB', 'PB', 'EB'];
+    let unit: string = 'B';
+    let toValue = +value;
     for (let i = 0, unitslen = units.length; toValue / 1000 >= 1 && i < unitslen;i = i + 1) {
       toValue = toValue / 1000;
       unit = units[i];
     }
-    return `${toValue.toFixed(1)} ${unit}`;
+    return `${toValue.toLocaleString(undefined, { maximumFractionDigits: 2 })} ${unit}`;
   }
 
   static isEmpty(arg: object | any[]): boolean {
     return Object.keys(arg).length === 0;
   }
 
-  static formatCount(value: number | string): string {
-    const unit = 'K';
-    const toValue = +value;
-    if (toValue >= 10000) {
-      return `${(toValue / 1000).toLocaleString(undefined, { maximumFractionDigits: 1 })} ${unit}`;
+  static formatCpuCore(value: number | string): string {
+    const units: readonly string[] = ['m', '', 'k', 'M', 'G', 'T', 'P', 'E'];
+    let unit: string = '';
+    let toValue = +value;
+    if (toValue > 0) {
+      unit = units[0];
     }
-
-    return toValue.toLocaleString();
+    for (let i = 1, unitslen = units.length; toValue / 1000 >= 1 && i < unitslen;i = i + 1) {
+      toValue = toValue / 1000;
+      unit = units[i];
+    }
+    return `${toValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}${unit}`;
   }
 
   static formatOtherResource(value: number | string): string {
-    const toValue = +value;
-    return toValue.toLocaleString();
+    const units: readonly string[] = ['k', 'M', 'G', 'T', 'P', 'E'];
+    let unit: string = '';
+    let toValue = +value;
+    for (let i = 0, unitslen = units.length; toValue / 1000 >= 1 && i < unitslen;i = i + 1) {
+      toValue = toValue / 1000;
+      unit = units[i];
+    }
+    return `${toValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}${unit}`;
   }
 
   static resourceColumnFormatter(value: string): string {