From 1c0f7438f0618e7ac6cffc1fe18e554705cea22d Mon Sep 17 00:00:00 2001 From: ShradhaGupta31 Date: Tue, 21 Jul 2026 20:04:28 +0530 Subject: [PATCH 1/4] feat(devices): add tabs, product type column, and discovered field - Add All/Activated/Discovered tab group to devices list - Add Product Type column derived from fwSku bitmask (ISM/vPro) - Add discovered field to DeviceInfo model - Show paginator on all tabs using server total count - Add i18n keys for tab labels and Product Type header in all 12 locales - Add unit tests for getProductType and tab filter logic - Fix cdk-overlay-backdrop leak in device.spec.ts cypress test Resolves: #3417 --- cypress/e2e/integration/device/device.spec.ts | 2 +- src/app/devices/devices.component.html | 16 +++- src/app/devices/devices.component.spec.ts | 96 +++++++++++++++++++ src/app/devices/devices.component.ts | 77 ++++++++++++++- src/assets/i18n/ar.json | 16 ++++ src/assets/i18n/de.json | 16 ++++ src/assets/i18n/en.json | 20 +++- src/assets/i18n/es.json | 16 ++++ src/assets/i18n/fi.json | 16 ++++ src/assets/i18n/fr.json | 16 ++++ src/assets/i18n/he.json | 16 ++++ src/assets/i18n/it.json | 16 ++++ src/assets/i18n/ja.json | 16 ++++ src/assets/i18n/nl.json | 16 ++++ src/assets/i18n/ru.json | 16 ++++ src/assets/i18n/sv.json | 16 ++++ src/models/models.ts | 1 + 17 files changed, 380 insertions(+), 8 deletions(-) diff --git a/cypress/e2e/integration/device/device.spec.ts b/cypress/e2e/integration/device/device.spec.ts index 1ee01f3cf..8b8a4498e 100644 --- a/cypress/e2e/integration/device/device.spec.ts +++ b/cypress/e2e/integration/device/device.spec.ts @@ -41,7 +41,7 @@ describe('Test Device Page', () => { if (Cypress.env('ISOLATE').charAt(0).toLowerCase() !== 'n') { cy.myIntercept('GET', '**/devices?tags=Windows&$top=25&$skip=0&$count=true', { statusCode: httpCodes.SUCCESS, - body: devices.getAll.windows.response.data + body: devices.getAll.windows.response }).as('get-windows') cy.goToPage('Devices') diff --git a/src/app/devices/devices.component.html b/src/app/devices/devices.component.html index 4b3923ada..008a7e854 100644 --- a/src/app/devices/devices.component.html +++ b/src/app/devices/devices.component.html @@ -46,6 +46,11 @@

}

} @else { + + + + +
@@ -120,6 +125,15 @@

} + + + {{ + 'devices.table.productType.value' | translate + }} + + {{ getProductType(element) }} + + @@ -203,7 +217,7 @@

diff --git a/src/app/devices/devices.component.spec.ts b/src/app/devices/devices.component.spec.ts index aedb57c83..cdb793904 100644 --- a/src/app/devices/devices.component.spec.ts +++ b/src/app/devices/devices.component.spec.ts @@ -248,4 +248,100 @@ describe('DevicesComponent', () => { component.tagFilterChange(matSelectChange) expect(component.filteredTags()).toBe(mockValue) }) + + describe('getProductType', () => { + it('should return ISM when bit 4 (0x10) is set', () => { + const device = { ...device01, deviceInfo: { fwSku: '16' } } as Device // 0x10 = 16 + expect(component.getProductType(device)).toBe('ISM') + }) + + it('should return vPro when bit 3 (0x08) is set and bit 4 is not', () => { + const device = { ...device01, deviceInfo: { fwSku: '8' } } as Device // 0x08 = 8 + expect(component.getProductType(device)).toBe('vPro') + }) + + it('should return ISM when both bit 4 and bit 3 are set (ISM takes priority)', () => { + const device = { ...device01, deviceInfo: { fwSku: '24' } } as Device // 0x18 = 24 + expect(component.getProductType(device)).toBe('ISM') + }) + + it('should return empty string when neither bit is set', () => { + const device = { ...device01, deviceInfo: { fwSku: '4' } } as Device // 0x04 = 4 + expect(component.getProductType(device)).toBe('') + }) + + it('should return empty string when fwSku is undefined', () => { + const device = { ...device01, deviceInfo: undefined } as Device + expect(component.getProductType(device)).toBe('') + }) + + it('should return empty string when fwSku is not a number', () => { + const device = { ...device01, deviceInfo: { fwSku: 'notanumber' } } as Device + expect(component.getProductType(device)).toBe('') + }) + }) + + describe('onTabChange / applyTabFilter', () => { + beforeEach(() => { + const baseInfo = { fwVersion: '', fwBuild: '', fwSku: '0', features: '', ipAddress: '' } + component.allDevicesData = [ + { ...device01, deviceInfo: { ...baseInfo, currentMode: 'acm', discovered: false } }, + { ...device02, deviceInfo: { ...baseInfo, currentMode: 'not activated', discovered: true } } + ] + }) + + it('should show all devices on tab 0', () => { + component.onTabChange(0) + expect(component.devices.data.length).toBe(2) + }) + + it('should filter to activated devices on tab 1', () => { + component.onTabChange(1) + expect(component.devices.data.length).toBe(1) + expect(component.devices.data[0].guid).toBe(device01.guid) + }) + + it('should filter to discovered devices on tab 2', () => { + component.onTabChange(2) + expect(component.devices.data.length).toBe(1) + expect(component.devices.data[0].guid).toBe(device02.guid) + }) + + it('should set totalCount to serverTotalCount on tab 0', () => { + ;(component as any).serverTotalCount = 42 + component.onTabChange(0) + expect(component.totalCount()).toBe(42) + }) + + it('should set totalCount to filtered length on tab 1', () => { + component.onTabChange(1) + expect(component.totalCount()).toBe(1) + }) + + it('should set totalCount to filtered length on tab 2', () => { + component.onTabChange(2) + expect(component.totalCount()).toBe(1) + }) + }) + + describe('isNoData', () => { + it('should return false when allDevicesData has entries regardless of totalCount', () => { + component.allDevicesData = [device01] + component.isLoading.set(false) + component.totalCount.set(0) // filtered tab has 0 — should not trigger no-data + expect(component.isNoData()).toBeFalse() + }) + + it('should return true only when allDevicesData is empty and not loading', () => { + component.allDevicesData = [] + component.isLoading.set(false) + expect(component.isNoData()).toBeTrue() + }) + + it('should return false when loading even if allDevicesData is empty', () => { + component.allDevicesData = [] + component.isLoading.set(true) + expect(component.isNoData()).toBeFalse() + }) + }) }) diff --git a/src/app/devices/devices.component.ts b/src/app/devices/devices.component.ts index 27d9df77f..5907ba1df 100644 --- a/src/app/devices/devices.component.ts +++ b/src/app/devices/devices.component.ts @@ -47,6 +47,7 @@ import { MatButton, MatIconButton } from '@angular/material/button' import { MatToolbar } from '@angular/material/toolbar' import { MatSort } from '@angular/material/sort' import { MatInput } from '@angular/material/input' +import { MatTabGroup, MatTab } from '@angular/material/tabs' import { TranslatePipe, TranslateService } from '@ngx-translate/core' @Component({ @@ -86,6 +87,8 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core' MatPaginator, MatHint, RouterModule, + MatTabGroup, + MatTab, TranslatePipe ] }) @@ -108,6 +111,62 @@ export class DevicesComponent implements OnInit, AfterViewInit { public powerStates: any public isCloudMode: boolean = environment.cloud + public activeTab = signal(0) + public allDevicesData: Device[] = [] + private serverTotalCount = 0 + + get allCount(): number { + return this.serverTotalCount + } + + get allTabLabel(): string { + return `${this.translate.instant('devices.tabs.all.value')} (${this.allCount})` + } + + get activatedTabLabel(): string { + return `${this.translate.instant('devices.tabs.activated.value')} (${this.activatedCount})` + } + + get discoveredTabLabel(): string { + return `${this.translate.instant('devices.tabs.discovered.value')} (${this.discoveredCount})` + } + + get activatedCount(): number { + return this.allDevicesData.filter( + (d) => d.deviceInfo?.currentMode != null && d.deviceInfo.currentMode !== 'not activated' + ).length + } + + get discoveredCount(): number { + return this.allDevicesData.filter((d) => d.deviceInfo?.discovered === true).length + } + + onTabChange(index: number): void { + this.activeTab.set(index) + this.applyTabFilter() + } + + private applyTabFilter(): void { + let filtered: Device[] + switch (this.activeTab()) { + case 1: + filtered = this.allDevicesData.filter( + (d) => d.deviceInfo?.currentMode != null && d.deviceInfo.currentMode !== 'not activated' + ) + this.totalCount.set(filtered.length) + break + case 2: + filtered = this.allDevicesData.filter((d) => d.deviceInfo?.discovered === true) + this.totalCount.set(filtered.length) + break + default: + filtered = this.allDevicesData + this.totalCount.set(this.serverTotalCount) + break + } + this.devices.data = filtered + } + get deleteDeviceLabel(): string { return this.isCloudMode ? this.translate.instant('devices.actions.deactivateCloud.value') @@ -119,6 +178,7 @@ export class DevicesComponent implements OnInit, AfterViewInit { 'hostname', 'guid', 'status', + 'productType', 'tags', 'actions', 'notification' @@ -140,6 +200,7 @@ export class DevicesComponent implements OnInit, AfterViewInit { this.displayedColumns = [ 'select', 'hostname', + 'productType', 'tags', 'actions', 'notification' @@ -211,6 +272,7 @@ export class DevicesComponent implements OnInit, AfterViewInit { .pipe( switchMap((res) => { this.totalCount.set(res.totalCount) + this.serverTotalCount = res.totalCount if (!environment.cloud) { return of(res.data) // Return as-is for non-cloud @@ -251,7 +313,8 @@ export class DevicesComponent implements OnInit, AfterViewInit { }) ) .subscribe((devices) => { - this.devices.data = devices + this.allDevicesData = devices + this.applyTabFilter() // Restore selection state on data retrieval this.selectedDevices.clear() @@ -335,13 +398,23 @@ export class DevicesComponent implements OnInit, AfterViewInit { } isNoData(): boolean { - return !this.isLoading() && this.totalCount() === 0 + return !this.isLoading() && this.allDevicesData.length === 0 } async navigateTo(path: string): Promise { await this.router.navigate([`/devices/${path}`]) } + getProductType(device: Device): string { + const skuNum = parseInt(device.deviceInfo?.fwSku ?? '', 10) + if (isNaN(skuNum)) return '' + const isISM = (skuNum & 0x10) > 0 + const isVPro = (skuNum & 0x08) > 0 + if (isISM) return 'ISM' + if (isVPro) return 'vPro' + return '' + } + translateConnectionStatus(status?: boolean): string { switch (status) { case false: diff --git a/src/assets/i18n/ar.json b/src/assets/i18n/ar.json index de08542c7..1368112d0 100644 --- a/src/assets/i18n/ar.json +++ b/src/assets/i18n/ar.json @@ -1073,6 +1073,10 @@ "description": "عنوان عمود الجدول لحالة الجهاز", "value": "الحالة" }, + "devices.table.productType": { + "description": "Table column header for product type", + "value": "نوع المنتج" + }, "devices.table.tags": { "description": "عنوان عمود الجدول للعلامات", "value": "العلامات" @@ -1081,6 +1085,18 @@ "description": "تلميح الطاقة للحالة إيقاف", "value": "الطاقة: إيقاف" }, + "devices.tabs.all": { + "description": "Tab label for all devices", + "value": "الكل" + }, + "devices.tabs.activated": { + "description": "Tab label for activated devices", + "value": "مُفعَّل" + }, + "devices.tabs.discovered": { + "description": "Tab label for discovered devices", + "value": "مكتشَف" + }, "deviceToolbar.power.on": { "description": "تلميح الطاقة للحالة تشغيل", "value": "الطاقة: تشغيل" diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 0fade9a0c..0b83319d6 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -1069,6 +1069,10 @@ "description": "Tabellen-Spaltenüberschrift für Gerätestatus", "value": "Status" }, + "devices.table.productType": { + "description": "Table column header for product type", + "value": "Produkttyp" + }, "devices.table.tags": { "description": "Tabellen-Spaltenüberschrift für Tags", "value": "Tags" @@ -1077,6 +1081,18 @@ "description": "Energie-Tooltip für Ausgeschaltet", "value": "Strom: Aus" }, + "devices.tabs.all": { + "description": "Tab label for all devices", + "value": "Alle" + }, + "devices.tabs.activated": { + "description": "Tab label for activated devices", + "value": "Aktiviert" + }, + "devices.tabs.discovered": { + "description": "Tab label for discovered devices", + "value": "Entdeckt" + }, "deviceToolbar.power.on": { "description": "Energie-Tooltip für Eingeschaltet", "value": "Strom: Ein" diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index a19e76dec..29162cafc 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1391,6 +1391,10 @@ "description": "Table column header for device status", "value": "Status" }, + "devices.table.productType": { + "description": "Table column header for product type", + "value": "Product Type" + }, "devices.table.tags": { "description": "Table column header for tags", "value": "Tags" @@ -1399,6 +1403,18 @@ "description": "Power tooltip for Off", "value": "Power: Off" }, + "devices.tabs.all": { + "description": "Tab label for all devices", + "value": "All" + }, + "devices.tabs.activated": { + "description": "Tab label for activated devices", + "value": "Activated" + }, + "devices.tabs.discovered": { + "description": "Tab label for discovered devices", + "value": "Discovered" + }, "deviceToolbar.power.on": { "description": "Power tooltip for On", "value": "Power: On" @@ -1411,10 +1427,6 @@ "description": "Power tooltip for Off", "value": "Power: Off" }, - "deviceToolbar.power.refreshAriaLabel": { - "description": "Aria label for the refresh power status button", - "value": "Refresh power status" - }, "deviceUserConsent.description": { "description": "Description for user consent for devices", "value": "A user consent code generated by Intel AMT is required to access the system." diff --git a/src/assets/i18n/es.json b/src/assets/i18n/es.json index 12b610279..dd10f6ef8 100644 --- a/src/assets/i18n/es.json +++ b/src/assets/i18n/es.json @@ -1069,6 +1069,10 @@ "description": "Encabezado de columna de la tabla para el estado del dispositivo", "value": "Estado" }, + "devices.table.productType": { + "description": "Table column header for product type", + "value": "Tipo de producto" + }, "devices.table.tags": { "description": "Encabezado de columna de tabla para etiquetas", "value": "Etiquetas" @@ -1077,6 +1081,18 @@ "description": "Información de energía para Apagado", "value": "Energía: Apagado" }, + "devices.tabs.all": { + "description": "Tab label for all devices", + "value": "Todos" + }, + "devices.tabs.activated": { + "description": "Tab label for activated devices", + "value": "Activado" + }, + "devices.tabs.discovered": { + "description": "Tab label for discovered devices", + "value": "Descubierto" + }, "deviceToolbar.power.on": { "description": "Información de energía para Encendido", "value": "Energía: Encendido" diff --git a/src/assets/i18n/fi.json b/src/assets/i18n/fi.json index 601fe9c74..36acaf083 100644 --- a/src/assets/i18n/fi.json +++ b/src/assets/i18n/fi.json @@ -1069,6 +1069,10 @@ "description": "Laitteen tilan taulukon sarakkeen otsikko", "value": "Tila" }, + "devices.table.productType": { + "description": "Table column header for product type", + "value": "Tuotetyyppi" + }, "devices.table.tags": { "description": "Taulukon sarakkeen otsikko tunnisteille", "value": "Tunnisteet" @@ -1077,6 +1081,18 @@ "description": "Virtatilan vihjeteksti tilalle pois päältä", "value": "Virta: Pois päältä" }, + "devices.tabs.all": { + "description": "Tab label for all devices", + "value": "Kaikki" + }, + "devices.tabs.activated": { + "description": "Tab label for activated devices", + "value": "Aktivoitu" + }, + "devices.tabs.discovered": { + "description": "Tab label for discovered devices", + "value": "Löydetty" + }, "deviceToolbar.power.on": { "description": "Virtatilan vihjeteksti tilalle päällä", "value": "Virta: Päällä" diff --git a/src/assets/i18n/fr.json b/src/assets/i18n/fr.json index 161d5ad0d..f1c3799b5 100644 --- a/src/assets/i18n/fr.json +++ b/src/assets/i18n/fr.json @@ -1073,6 +1073,10 @@ "description": "En-tête de colonne du tableau pour l'état de l'appareil", "value": "Statut" }, + "devices.table.productType": { + "description": "Table column header for product type", + "value": "Type de produit" + }, "devices.table.tags": { "description": "En-tête de colonne du tableau pour les balises", "value": "Balises" @@ -1081,6 +1085,18 @@ "description": "Info-bulle d'alimentation pour l'état éteint", "value": "Alimentation : Éteint" }, + "devices.tabs.all": { + "description": "Tab label for all devices", + "value": "Tous" + }, + "devices.tabs.activated": { + "description": "Tab label for activated devices", + "value": "Activé" + }, + "devices.tabs.discovered": { + "description": "Tab label for discovered devices", + "value": "Découvert" + }, "deviceToolbar.power.on": { "description": "Info-bulle d'alimentation pour l'état allumé", "value": "Alimentation : Allumé" diff --git a/src/assets/i18n/he.json b/src/assets/i18n/he.json index 9bde55f65..950d01b23 100644 --- a/src/assets/i18n/he.json +++ b/src/assets/i18n/he.json @@ -1065,6 +1065,10 @@ "description": "כותרת עמודות טבלה למצב המכשיר", "value": "סטָטוּס" }, + "devices.table.productType": { + "description": "Table column header for product type", + "value": "סוג מוצר" + }, "devices.table.tags": { "description": "כותרת עמודות טבלה לתגיות", "value": "תגיות" @@ -1073,6 +1077,18 @@ "description": "רמז מצב צריכת חשמל עבור כבוי", "value": "חשמל: כבוי" }, + "devices.tabs.all": { + "description": "Tab label for all devices", + "value": "הכל" + }, + "devices.tabs.activated": { + "description": "Tab label for activated devices", + "value": "מופעל" + }, + "devices.tabs.discovered": { + "description": "Tab label for discovered devices", + "value": "התגלה" + }, "deviceToolbar.power.on": { "description": "רמז מצב צריכת חשמל עבור פועל", "value": "חשמל: פועל" diff --git a/src/assets/i18n/it.json b/src/assets/i18n/it.json index 2fc13d98e..89af0797a 100644 --- a/src/assets/i18n/it.json +++ b/src/assets/i18n/it.json @@ -1065,6 +1065,10 @@ "description": "Intestazione della colonna della tabella per lo stato del dispositivo", "value": "Stato" }, + "devices.table.productType": { + "description": "Table column header for product type", + "value": "Tipo di prodotto" + }, "devices.table.tags": { "description": "Intestazione della colonna della tabella per i tag", "value": "Tag" @@ -1073,6 +1077,18 @@ "description": "Tooltip di alimentazione per spento", "value": "Alimentazione: Spento" }, + "devices.tabs.all": { + "description": "Tab label for all devices", + "value": "Tutti" + }, + "devices.tabs.activated": { + "description": "Tab label for activated devices", + "value": "Attivato" + }, + "devices.tabs.discovered": { + "description": "Tab label for discovered devices", + "value": "Scoperto" + }, "deviceToolbar.power.on": { "description": "Tooltip di alimentazione per acceso", "value": "Alimentazione: Acceso" diff --git a/src/assets/i18n/ja.json b/src/assets/i18n/ja.json index 9e1007124..a57473d7d 100644 --- a/src/assets/i18n/ja.json +++ b/src/assets/i18n/ja.json @@ -1065,6 +1065,10 @@ "description": "デバイス状態のテーブル列見出し", "value": "ステータス" }, + "devices.table.productType": { + "description": "Table column header for product type", + "value": "製品タイプ" + }, "devices.table.tags": { "description": "タグ用テーブル列ヘッダー", "value": "タグ" @@ -1073,6 +1077,18 @@ "description": "電源オフのツールチップ", "value": "電源: オフ" }, + "devices.tabs.all": { + "description": "Tab label for all devices", + "value": "すべて" + }, + "devices.tabs.activated": { + "description": "Tab label for activated devices", + "value": "アクティベート済み" + }, + "devices.tabs.discovered": { + "description": "Tab label for discovered devices", + "value": "検出済み" + }, "deviceToolbar.power.on": { "description": "電源オンのツールチップ", "value": "電源: オン" diff --git a/src/assets/i18n/nl.json b/src/assets/i18n/nl.json index 439175801..0a48a08a4 100644 --- a/src/assets/i18n/nl.json +++ b/src/assets/i18n/nl.json @@ -1073,6 +1073,10 @@ "description": "Tabelkolomkop voor apparaatstatus", "value": "Status" }, + "devices.table.productType": { + "description": "Table column header for product type", + "value": "Producttype" + }, "devices.table.tags": { "description": "Tabelkolomkop voor tags", "value": "Tags" @@ -1081,6 +1085,18 @@ "description": "Energie-tooltip voor uitgeschakeld", "value": "Stroom: Uit" }, + "devices.tabs.all": { + "description": "Tab label for all devices", + "value": "Alle" + }, + "devices.tabs.activated": { + "description": "Tab label for activated devices", + "value": "Geactiveerd" + }, + "devices.tabs.discovered": { + "description": "Tab label for discovered devices", + "value": "Ontdekt" + }, "deviceToolbar.power.on": { "description": "Energie-tooltip voor ingeschakeld", "value": "Stroom: Aan" diff --git a/src/assets/i18n/ru.json b/src/assets/i18n/ru.json index 1e303836c..d25e66b8f 100644 --- a/src/assets/i18n/ru.json +++ b/src/assets/i18n/ru.json @@ -1073,6 +1073,10 @@ "description": "Заголовок столбца таблицы для состояния устройства", "value": "Статус" }, + "devices.table.productType": { + "description": "Table column header for product type", + "value": "Тип продукта" + }, "devices.table.tags": { "description": "Заголовок столбца таблицы для тегов", "value": "Теги" @@ -1081,6 +1085,18 @@ "description": "Подсказка питания для состояния выключено", "value": "Питание: Выключено" }, + "devices.tabs.all": { + "description": "Tab label for all devices", + "value": "Все" + }, + "devices.tabs.activated": { + "description": "Tab label for activated devices", + "value": "Активирован" + }, + "devices.tabs.discovered": { + "description": "Tab label for discovered devices", + "value": "Обнаружен" + }, "deviceToolbar.power.on": { "description": "Подсказка питания для состояния включено", "value": "Питание: Включено" diff --git a/src/assets/i18n/sv.json b/src/assets/i18n/sv.json index 7b5999397..68d55108e 100644 --- a/src/assets/i18n/sv.json +++ b/src/assets/i18n/sv.json @@ -977,6 +977,10 @@ "description": "Tabellkolumnrubrik för enhetsstatus", "value": "Status" }, + "devices.table.productType": { + "description": "Table column header for product type", + "value": "Produkttyp" + }, "devices.table.tags": { "description": "Tabellkolumnrubrik för taggar", "value": "Taggar" @@ -985,6 +989,18 @@ "description": "Strömverktygstips för av", "value": "Ström: Av" }, + "devices.tabs.all": { + "description": "Tab label for all devices", + "value": "Alla" + }, + "devices.tabs.activated": { + "description": "Tab label for activated devices", + "value": "Aktiverad" + }, + "devices.tabs.discovered": { + "description": "Tab label for discovered devices", + "value": "Upptäckt" + }, "deviceToolbar.power.on": { "description": "Strömverktygstips för på", "value": "Ström: På" diff --git a/src/models/models.ts b/src/models/models.ts index 06b395413..c6c532564 100644 --- a/src/models/models.ts +++ b/src/models/models.ts @@ -31,6 +31,7 @@ export interface DeviceInfo { currentMode: string features: string ipAddress: string + discovered?: boolean firstDiscovered?: Date lastSynced?: Date } From 9ba22ed95fc06094ad27ab2426fbdff651b07b1f Mon Sep 17 00:00:00 2001 From: ShradhaGupta31 Date: Mon, 31 Aug 2026 11:33:16 +0530 Subject: [PATCH 2/4] refactor: Address review comments - updated logic to include server side counts Signed-off-by: ShradhaGupta31 --- cypress/e2e/integration/device/paging.spec.ts | 12 +++ package-lock.json | 51 ------------- src/app/devices/devices.component.html | 19 ++--- src/app/devices/devices.component.spec.ts | 74 +++++++++--------- src/app/devices/devices.component.ts | 76 ++++++++++++------- src/app/devices/devices.service.spec.ts | 42 +++++++++- src/app/devices/devices.service.ts | 5 ++ src/assets/i18n/en.json | 4 + src/models/models.ts | 5 ++ 9 files changed, 165 insertions(+), 123 deletions(-) diff --git a/cypress/e2e/integration/device/paging.spec.ts b/cypress/e2e/integration/device/paging.spec.ts index 5210e0698..b6c612cbf 100644 --- a/cypress/e2e/integration/device/paging.spec.ts +++ b/cypress/e2e/integration/device/paging.spec.ts @@ -20,6 +20,17 @@ describe('Test Device Page', () => { body: devices.getAll.forPaging.response }).as('get-devices') + cy.myIntercept('GET', 'api/v1/devices/stats', { + statusCode: httpCodes.SUCCESS, + body: { + totalCount: deviceFixtures.totalCount, + connectedCount: 0, + disconnectedCount: 0, + activatedCount: 0, + discoveredCount: 0 + } + }).as('get-device-stats') + cy.myIntercept('GET', /tags$/, { statusCode: httpCodes.SUCCESS, body: tags.getAll.success.response @@ -31,6 +42,7 @@ describe('Test Device Page', () => { }).as('get-powerstate') cy.goToPage('Devices') + cy.wait('@get-device-stats') }) it('pagination for next page', () => { diff --git a/package-lock.json b/package-lock.json index 063a81c3b..4ed689b15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7220,9 +7220,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7240,9 +7237,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7260,9 +7254,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7280,9 +7271,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7459,9 +7447,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7476,9 +7461,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7493,9 +7475,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7510,9 +7489,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7527,9 +7503,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7544,9 +7517,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7561,9 +7531,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7578,9 +7545,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7595,9 +7559,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7612,9 +7573,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7629,9 +7587,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7646,9 +7601,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7663,9 +7615,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/app/devices/devices.component.html b/src/app/devices/devices.component.html index 008a7e854..452e92ec1 100644 --- a/src/app/devices/devices.component.html +++ b/src/app/devices/devices.component.html @@ -37,6 +37,13 @@ + + + + + + @if (isNoData()) {

@if (!filteredTags().length) { @@ -46,11 +53,6 @@

}

} @else { - - - - -
@@ -127,9 +129,8 @@

- {{ - 'devices.table.productType.value' | translate - }} + + {{ 'devices.table.productType.value' | translate }} {{ getProductType(element) }} @@ -217,7 +218,7 @@

diff --git a/src/app/devices/devices.component.spec.ts b/src/app/devices/devices.component.spec.ts index cdb793904..d4b041b7a 100644 --- a/src/app/devices/devices.component.spec.ts +++ b/src/app/devices/devices.component.spec.ts @@ -66,7 +66,8 @@ describe('DevicesComponent', () => { 'sendPowerAction', 'bulkPowerAction', 'sendDeactivate', - 'sendBulkDeactivate' + 'sendBulkDeactivate', + 'getStats' ]) devicesService.PowerStates.mockReturnValue({ 2: 'On', @@ -82,10 +83,13 @@ describe('DevicesComponent', () => { updateDeviceSpy = devicesService.updateDevice.mockImplementation((device: any) => { return of(device) }) - getTagsSpy = devicesService.getTags.mockReturnValue(of([])) - devicesService.getPowerState.mockReturnValue(of({ powerstate: 2 })) - sendPowerActionSpy = devicesService.sendPowerAction.mockReturnValue(of({ Body: { ReturnValueStr: 'SUCCESS' } })) - sendDeactivateSpy = devicesService.sendDeactivate.mockReturnValue(of({ status: 'SUCCESS' })) + getTagsSpy = devicesService.getTags.mockReturnValue(of([])) + devicesService.getPowerState.mockReturnValue(of({ powerstate: 2 })) + devicesService.getStats.mockReturnValue( + of({ totalCount: 42, connectedCount: 10, disconnectedCount: 5, activatedCount: 7, discoveredCount: 3 }) + ) + sendPowerActionSpy = devicesService.sendPowerAction.and.returnValue(of({ Body: { ReturnValueStr: 'SUCCESS' } })) + sendDeactivateSpy = devicesService.sendDeactivate.and.returnValue(of({ status: 'SUCCESS' })) TestBed.configureTestingModule({ imports: [ BrowserAnimationsModule, @@ -281,65 +285,67 @@ describe('DevicesComponent', () => { }) }) - describe('onTabChange / applyTabFilter', () => { + describe('onTabChange / server-side counts', () => { beforeEach(() => { - const baseInfo = { fwVersion: '', fwBuild: '', fwSku: '0', features: '', ipAddress: '' } - component.allDevicesData = [ - { ...device01, deviceInfo: { ...baseInfo, currentMode: 'acm', discovered: false } }, - { ...device02, deviceInfo: { ...baseInfo, currentMode: 'not activated', discovered: true } } - ] + getDevicesSpy.calls.reset() }) - it('should show all devices on tab 0', () => { + it('should request all devices (no status filter) on tab 0', () => { component.onTabChange(0) - expect(component.devices.data.length).toBe(2) + expect(component.activeTab()).toBe(0) + expect(getDevicesSpy).toHaveBeenCalledWith(jasmine.objectContaining({ status: undefined })) }) - it('should filter to activated devices on tab 1', () => { + it('should request activated devices from the server on tab 1', () => { component.onTabChange(1) - expect(component.devices.data.length).toBe(1) - expect(component.devices.data[0].guid).toBe(device01.guid) + expect(component.activeTab()).toBe(1) + expect(getDevicesSpy).toHaveBeenCalledWith(jasmine.objectContaining({ status: 'activated' })) }) - it('should filter to discovered devices on tab 2', () => { + it('should request discovered devices from the server on tab 2', () => { component.onTabChange(2) - expect(component.devices.data.length).toBe(1) - expect(component.devices.data[0].guid).toBe(device02.guid) + expect(component.activeTab()).toBe(2) + expect(getDevicesSpy).toHaveBeenCalledWith(jasmine.objectContaining({ status: 'discovered' })) }) - it('should set totalCount to serverTotalCount on tab 0', () => { - ;(component as any).serverTotalCount = 42 - component.onTabChange(0) - expect(component.totalCount()).toBe(42) + it('should reset paging to the first page when switching tabs', () => { + component.pageEvent.startsFrom = 50 + component.onTabChange(1) + expect(component.pageEvent.startsFrom).toBe(0) }) - it('should set totalCount to filtered length on tab 1', () => { - component.onTabChange(1) - expect(component.totalCount()).toBe(1) + it('should expose server-provided counts', () => { + expect(component.allCount).toBe(42) + expect(component.activatedCount).toBe(7) + expect(component.discoveredCount).toBe(3) }) - it('should set totalCount to filtered length on tab 2', () => { + it('should set currentTabCount from the active tab', () => { + component.onTabChange(0) + expect(component.currentTabCount).toBe(42) + component.onTabChange(1) + expect(component.currentTabCount).toBe(7) component.onTabChange(2) - expect(component.totalCount()).toBe(1) + expect(component.currentTabCount).toBe(3) }) }) describe('isNoData', () => { - it('should return false when allDevicesData has entries regardless of totalCount', () => { - component.allDevicesData = [device01] + it('should return false when the table has entries regardless of totalCount', () => { + component.devices.data = [device01] component.isLoading.set(false) component.totalCount.set(0) // filtered tab has 0 — should not trigger no-data expect(component.isNoData()).toBeFalse() }) - it('should return true only when allDevicesData is empty and not loading', () => { - component.allDevicesData = [] + it('should return true only when the table is empty and not loading', () => { + component.devices.data = [] component.isLoading.set(false) expect(component.isNoData()).toBeTrue() }) - it('should return false when loading even if allDevicesData is empty', () => { - component.allDevicesData = [] + it('should return false when loading even if the table is empty', () => { + component.devices.data = [] component.isLoading.set(true) expect(component.isNoData()).toBeFalse() }) diff --git a/src/app/devices/devices.component.ts b/src/app/devices/devices.component.ts index 5907ba1df..4d1155066 100644 --- a/src/app/devices/devices.component.ts +++ b/src/app/devices/devices.component.ts @@ -12,7 +12,7 @@ import { MatSnackBar } from '@angular/material/snack-bar' import { Router, RouterModule } from '@angular/router' import { catchError, concatMap, delay, finalize, map, switchMap } from 'rxjs/operators' import { forkJoin, from, Observable, of, throwError } from 'rxjs' -import { Device, PageEventOptions } from '../../models/models' +import { Device, DeviceFilterStatus, PageEventOptions } from '../../models/models' import { AddDeviceComponent } from '../shared/add-device/add-device.component' import SnackbarDefaults from '../shared/config/snackBarDefault' import { DevicesService } from './devices.service' @@ -112,13 +112,26 @@ export class DevicesComponent implements OnInit, AfterViewInit { public isCloudMode: boolean = environment.cloud public activeTab = signal(0) - public allDevicesData: Device[] = [] private serverTotalCount = 0 + private serverActivatedCount = 0 + private serverDiscoveredCount = 0 get allCount(): number { return this.serverTotalCount } + // Count for the currently selected tab, used to drive the paginator length. + get currentTabCount(): number { + switch (this.activeTab()) { + case 1: + return this.serverActivatedCount + case 2: + return this.serverDiscoveredCount + default: + return this.serverTotalCount + } + } + get allTabLabel(): string { return `${this.translate.instant('devices.tabs.all.value')} (${this.allCount})` } @@ -132,39 +145,46 @@ export class DevicesComponent implements OnInit, AfterViewInit { } get activatedCount(): number { - return this.allDevicesData.filter( - (d) => d.deviceInfo?.currentMode != null && d.deviceInfo.currentMode !== 'not activated' - ).length + return this.serverActivatedCount } get discoveredCount(): number { - return this.allDevicesData.filter((d) => d.deviceInfo?.discovered === true).length + return this.serverDiscoveredCount } onTabChange(index: number): void { this.activeTab.set(index) - this.applyTabFilter() + // Different tabs return different result sets, so reset paging to the first page. + this.pageEvent.startsFrom = 0 + if (this.paginator) { + this.paginator.pageIndex = 0 + } + this.getDevices() } - private applyTabFilter(): void { - let filtered: Device[] + private currentTabStatus(): DeviceFilterStatus | undefined { switch (this.activeTab()) { case 1: - filtered = this.allDevicesData.filter( - (d) => d.deviceInfo?.currentMode != null && d.deviceInfo.currentMode !== 'not activated' - ) - this.totalCount.set(filtered.length) - break + return 'activated' case 2: - filtered = this.allDevicesData.filter((d) => d.deviceInfo?.discovered === true) - this.totalCount.set(filtered.length) - break + return 'discovered' default: - filtered = this.allDevicesData - this.totalCount.set(this.serverTotalCount) - break + return undefined } - this.devices.data = filtered + } + + private loadStats(): void { + this.devicesService.getStats().subscribe({ + next: (stats) => { + this.serverTotalCount = stats.totalCount + this.serverActivatedCount = stats.activatedCount + this.serverDiscoveredCount = stats.discoveredCount + this.totalCount.set(this.currentTabCount) + }, + error: (err) => { + console.error('Error loading device stats:', err) + } + }) } get deleteDeviceLabel(): string { @@ -264,16 +284,17 @@ export class DevicesComponent implements OnInit, AfterViewInit { getDevices(): void { this.isLoading.set(true) + // Counts (all/activated/discovered) are computed server-side and shared with + // headless/API consumers, so refresh them alongside the current page. + this.loadStats() + // Store previous selection before making the request const prevSelected = this.selectedDevices.selected.map((d) => d.guid) this.devicesService - .getDevices({ ...this.pageEvent, tags: this.filteredTags() }) + .getDevices({ ...this.pageEvent, tags: this.filteredTags(), status: this.currentTabStatus() }) .pipe( switchMap((res) => { - this.totalCount.set(res.totalCount) - this.serverTotalCount = res.totalCount - if (!environment.cloud) { return of(res.data) // Return as-is for non-cloud } @@ -313,8 +334,7 @@ export class DevicesComponent implements OnInit, AfterViewInit { }) ) .subscribe((devices) => { - this.allDevicesData = devices - this.applyTabFilter() + this.devices.data = devices // Restore selection state on data retrieval this.selectedDevices.clear() @@ -398,7 +418,7 @@ export class DevicesComponent implements OnInit, AfterViewInit { } isNoData(): boolean { - return !this.isLoading() && this.allDevicesData.length === 0 + return !this.isLoading() && this.devices.data.length === 0 } async navigateTo(path: string): Promise { diff --git a/src/app/devices/devices.service.spec.ts b/src/app/devices/devices.service.spec.ts index bb4600df9..3bec2f8e1 100644 --- a/src/app/devices/devices.service.spec.ts +++ b/src/app/devices/devices.service.spec.ts @@ -524,6 +524,40 @@ describe('DevicesService', () => { const req = httpMock.expectOne(`${mockEnvironment.mpsServer}/api/v1/devices?$top=10&$skip=0&$count=true`) req.flush(null, mockError) }) + + it('should append the activated filter when status is activated', () => { + const mockDevices: DataWithCount = { + data: [{ hostname: 'device1', guid: 'guid1', connectionStatus: true }] as any, + totalCount: 1 + } + + service.getDevices({ pageSize: 10, startsFrom: 0, count: 'true', status: 'activated' }).subscribe((response) => { + expect(response).toEqual(mockDevices) + }) + + const req = httpMock.expectOne( + `${mockEnvironment.mpsServer}/api/v1/devices?$top=10&$skip=0&$count=true&activated=true` + ) + expect(req.request.method).toBe('GET') + req.flush(mockDevices) + }) + + it('should append the discovered filter when status is discovered', () => { + const mockDevices: DataWithCount = { + data: [{ hostname: 'device1', guid: 'guid1', connectionStatus: true }] as any, + totalCount: 1 + } + + service.getDevices({ pageSize: 10, startsFrom: 0, count: 'true', status: 'discovered' }).subscribe((response) => { + expect(response).toEqual(mockDevices) + }) + + const req = httpMock.expectOne( + `${mockEnvironment.mpsServer}/api/v1/devices?$top=10&$skip=0&$count=true&discovered=true` + ) + expect(req.request.method).toBe('GET') + req.flush(mockDevices) + }) }) describe('updateDevice', () => { @@ -900,7 +934,13 @@ describe('DevicesService', () => { describe('getStats', () => { it('should fetch device statistics', () => { - const mockResponse: DeviceStats = { totalCount: 100, connectedCount: 80, disconnectedCount: 20 } + const mockResponse: DeviceStats = { + totalCount: 100, + connectedCount: 80, + disconnectedCount: 20, + activatedCount: 60, + discoveredCount: 40 + } service.getStats().subscribe((response) => { expect(response).toEqual(mockResponse) diff --git a/src/app/devices/devices.service.ts b/src/app/devices/devices.service.ts index 57a956124..890ad74ff 100644 --- a/src/app/devices/devices.service.ts +++ b/src/app/devices/devices.service.ts @@ -443,6 +443,11 @@ export class DevicesService { } else { query += `?$top=${pageEvent.pageSize}&$skip=${pageEvent.startsFrom}&$count=${pageEvent.count}` } + if (pageEvent?.status === 'activated') { + query += '&activated=true' + } else if (pageEvent?.status === 'discovered') { + query += '&discovered=true' + } return this.http.get>(query).pipe( catchError((err) => { throw err diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 29162cafc..581cd2cdb 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1427,6 +1427,10 @@ "description": "Power tooltip for Off", "value": "Power: Off" }, + "deviceToolbar.power.refreshAriaLabel": { + "description": "Aria label for the refresh power status button", + "value": "Refresh power status" + }, "deviceUserConsent.description": { "description": "Description for user consent for devices", "value": "A user consent code generated by Intel AMT is required to access the system." diff --git a/src/models/models.ts b/src/models/models.ts index c6c532564..2d706e012 100644 --- a/src/models/models.ts +++ b/src/models/models.ts @@ -39,6 +39,8 @@ export interface DeviceStats { totalCount: number connectedCount: number disconnectedCount: number + activatedCount: number + discoveredCount: number } export interface Domain { profileName: string @@ -350,8 +352,11 @@ export interface PageEventOptions { startsFrom: number count: string tags?: string[] + status?: DeviceFilterStatus } +export type DeviceFilterStatus = 'activated' | 'discovered' + export interface Header { To: string RelatesTo: string From 9a2bfd1fcf615bfeed2a8bf0f943fab8e18e5070 Mon Sep 17 00:00:00 2001 From: ShradhaGupta31 Date: Thu, 3 Sep 2026 10:53:05 +0530 Subject: [PATCH 3/4] fix: prettier formatting in devices.component.spec.ts --- src/app/devices/devices.component.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/devices/devices.component.spec.ts b/src/app/devices/devices.component.spec.ts index d4b041b7a..dc50b42af 100644 --- a/src/app/devices/devices.component.spec.ts +++ b/src/app/devices/devices.component.spec.ts @@ -83,11 +83,11 @@ describe('DevicesComponent', () => { updateDeviceSpy = devicesService.updateDevice.mockImplementation((device: any) => { return of(device) }) - getTagsSpy = devicesService.getTags.mockReturnValue(of([])) - devicesService.getPowerState.mockReturnValue(of({ powerstate: 2 })) - devicesService.getStats.mockReturnValue( - of({ totalCount: 42, connectedCount: 10, disconnectedCount: 5, activatedCount: 7, discoveredCount: 3 }) - ) + getTagsSpy = devicesService.getTags.mockReturnValue(of([])) + devicesService.getPowerState.mockReturnValue(of({ powerstate: 2 })) + devicesService.getStats.mockReturnValue( + of({ totalCount: 42, connectedCount: 10, disconnectedCount: 5, activatedCount: 7, discoveredCount: 3 }) + ) sendPowerActionSpy = devicesService.sendPowerAction.and.returnValue(of({ Body: { ReturnValueStr: 'SUCCESS' } })) sendDeactivateSpy = devicesService.sendDeactivate.and.returnValue(of({ status: 'SUCCESS' })) TestBed.configureTestingModule({ From 63aed0ed3187023a6080423316964ef3eeaf6c81 Mon Sep 17 00:00:00 2001 From: ShradhaGupta31 Date: Thu, 3 Sep 2026 11:05:01 +0530 Subject: [PATCH 4/4] fix: replace jasmine-style test syntax with vitest equivalents in devices.component.spec.ts --- src/app/devices/devices.component.spec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/devices/devices.component.spec.ts b/src/app/devices/devices.component.spec.ts index dc50b42af..f1bf5a6cc 100644 --- a/src/app/devices/devices.component.spec.ts +++ b/src/app/devices/devices.component.spec.ts @@ -88,8 +88,8 @@ describe('DevicesComponent', () => { devicesService.getStats.mockReturnValue( of({ totalCount: 42, connectedCount: 10, disconnectedCount: 5, activatedCount: 7, discoveredCount: 3 }) ) - sendPowerActionSpy = devicesService.sendPowerAction.and.returnValue(of({ Body: { ReturnValueStr: 'SUCCESS' } })) - sendDeactivateSpy = devicesService.sendDeactivate.and.returnValue(of({ status: 'SUCCESS' })) + sendPowerActionSpy = devicesService.sendPowerAction.mockReturnValue(of({ Body: { ReturnValueStr: 'SUCCESS' } })) + sendDeactivateSpy = devicesService.sendDeactivate.mockReturnValue(of({ status: 'SUCCESS' })) TestBed.configureTestingModule({ imports: [ BrowserAnimationsModule, @@ -287,25 +287,25 @@ describe('DevicesComponent', () => { describe('onTabChange / server-side counts', () => { beforeEach(() => { - getDevicesSpy.calls.reset() + getDevicesSpy.mockClear() }) it('should request all devices (no status filter) on tab 0', () => { component.onTabChange(0) expect(component.activeTab()).toBe(0) - expect(getDevicesSpy).toHaveBeenCalledWith(jasmine.objectContaining({ status: undefined })) + expect(getDevicesSpy).toHaveBeenCalledWith(expect.objectContaining({ status: undefined })) }) it('should request activated devices from the server on tab 1', () => { component.onTabChange(1) expect(component.activeTab()).toBe(1) - expect(getDevicesSpy).toHaveBeenCalledWith(jasmine.objectContaining({ status: 'activated' })) + expect(getDevicesSpy).toHaveBeenCalledWith(expect.objectContaining({ status: 'activated' })) }) it('should request discovered devices from the server on tab 2', () => { component.onTabChange(2) expect(component.activeTab()).toBe(2) - expect(getDevicesSpy).toHaveBeenCalledWith(jasmine.objectContaining({ status: 'discovered' })) + expect(getDevicesSpy).toHaveBeenCalledWith(expect.objectContaining({ status: 'discovered' })) }) it('should reset paging to the first page when switching tabs', () => { @@ -335,19 +335,19 @@ describe('DevicesComponent', () => { component.devices.data = [device01] component.isLoading.set(false) component.totalCount.set(0) // filtered tab has 0 — should not trigger no-data - expect(component.isNoData()).toBeFalse() + expect(component.isNoData()).toBe(false) }) it('should return true only when the table is empty and not loading', () => { component.devices.data = [] component.isLoading.set(false) - expect(component.isNoData()).toBeTrue() + expect(component.isNoData()).toBe(true) }) it('should return false when loading even if the table is empty', () => { component.devices.data = [] component.isLoading.set(true) - expect(component.isNoData()).toBeFalse() + expect(component.isNoData()).toBe(false) }) }) })