Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions src/app/devices/device-toolbar/device-toolbar.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,23 @@
</button>
<mat-divider style="height: 40px" vertical="true"></mat-divider>
@if (!isLoading()()) {
<mat-icon
(click)="getPowerState()"
style="padding: 0 18px 0 12px; cursor: pointer"
<button
mat-icon-button
type="button"
[matTooltip]="powerState() | translate"
[attr.aria-label]="'deviceToolbar.power.refreshAriaLabel.value' | translate"
[style.color]="
powerState() === 'deviceToolbar.power.on.value'
? 'green'
: powerState() === 'deviceToolbar.power.sleep.value'
? 'yellow'
: 'red'
: powerState() === 'deviceToolbar.power.off.value'
? 'red'
: 'gray'
"
[matTooltip]="powerState() | translate"
>mode_standby</mat-icon
>
(click)="refreshPowerState()">
<mat-icon>mode_standby</mat-icon>
</button>
}
<mat-divider style="height: 40px" vertical="true"></mat-divider>
<button mat-icon-button [matTooltip]="'devices.actions.powerUp.value' | translate" (click)="sendPowerAction(2)">
Expand Down
24 changes: 24 additions & 0 deletions src/app/devices/device-toolbar/device-toolbar.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,30 @@ describe('DeviceToolbarComponent', () => {
expect(component.isLoading()()).toBeFalse()
})

it('should fetch power state using cached API on initial load', () => {
expect(devicesService.getPowerStateCached).toHaveBeenCalledWith('guid')
expect(devicesService.getPowerState).not.toHaveBeenCalled()
})

it('should fetch power state using non-cached API on refresh', () => {
devicesService.getPowerState.calls.reset()
devicesService.getPowerStateCached.calls.reset()

component.refreshPowerState()

expect(devicesService.getPowerState).toHaveBeenCalledWith('guid')
expect(devicesService.getPowerStateCached).not.toHaveBeenCalled()
})
Comment thread
Copilot marked this conversation as resolved.

it('should show snackbar and reset loading state when refresh power state fails', () => {
devicesService.getPowerState.and.returnValue(throwError(() => new Error('Network error')))

component.refreshPowerState()

expect(snackBar.open).toHaveBeenCalled()
expect(component.isLoading()()).toBeFalse()
})

it('should navigate to device', async () => {
fixture.componentRef.setInput('deviceId', '12345-pokli-456772')
const routerSpy = spyOn(component.router, 'navigate')
Expand Down
98 changes: 81 additions & 17 deletions src/app/devices/device-toolbar/device-toolbar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Component, OnInit, inject, signal, input, DestroyRef } from '@angular/c
import { catchError, finalize, switchMap } from 'rxjs/operators'
import { MatSnackBar } from '@angular/material/snack-bar'
import { Router } from '@angular/router'
import { Observable, of, forkJoin } from 'rxjs'
import { Observable, of, forkJoin, EMPTY, timer } from 'rxjs'
import { DevicesService } from '../devices.service'
import SnackbarDefaults from '../../shared/config/snackBarDefault'
import { AMTFeaturesResponse, BootDetails, Device, UserConsentResponse } from '../../../models/models'
Expand Down Expand Up @@ -164,7 +164,7 @@ export class DeviceToolbarComponent implements OnInit {
this.device = data
this.devicesService.device.next(this.device)
this.isPinned.set(this.device?.certHash != null && this.device?.certHash !== '')
this.getPowerState()
this.loadPowerState()
this.loadAMTFeatures()
// react to AMT feature updates emitted by service
this.devicesService
Expand Down Expand Up @@ -217,22 +217,78 @@ export class DeviceToolbarComponent implements OnInit {
this.powerOptions.set(options)
}

getPowerState(): void {
this.isLoading().set(true)
// Goes through the cached variant so a simultaneous KVM deep-link fetch and
// this toolbar fetch are deduped into one /power/state round-trip.
this.devicesService
.getPowerStateCached(this.deviceId())
.pipe(takeUntilDestroyed(this.destroyRef))
private setOptimisticPowerState(action: number): void {
// Immediately reflect the expected outcome so the UI doesn't show the
// old state while waiting for the device to transition.
switch (action) {
case 2: // power on
this.powerState.set('deviceToolbar.power.on.value')
break
case 8: // power off
case 12: // soft off
this.powerState.set('deviceToolbar.power.off.value')
break
default: // reset, cycle, etc. — state is transitioning
this.powerState.set('Unknown')
}
}

private setPowerStateLabel(powerstate: number): void {
switch (powerstate) {
case 2:
this.powerState.set('deviceToolbar.power.on.value')
break
case 3:
case 4:
this.powerState.set('deviceToolbar.power.sleep.value')
break
default:
this.powerState.set('deviceToolbar.power.off.value')
}
}
Comment thread
Copilot marked this conversation as resolved.

private loadPowerState(): void {
Comment thread
DevipriyaS17 marked this conversation as resolved.
// Use cached to dedupe simultaneous requests (KVM deep-link + toolbar both loading).
this.fetchPowerState(this.devicesService.getPowerStateCached(this.deviceId()))
}

refreshPowerState(): void {
// Bypass the cache so manual refresh always fetches the latest state from the device.
this.fetchPowerState(this.devicesService.getPowerState(this.deviceId()), false, () => {
const msg: string = this.translate.instant('kvm.errorRetrievePower.value')
this.snackBar.open(msg, undefined, SnackbarDefaults.defaultError)
})
}

private silentRefreshPowerState(): void {
// Refresh without showing the loading spinner — used after a power action
// where the optimistic state is already shown to the user.
this.fetchPowerState(this.devicesService.getPowerState(this.deviceId()), true)
}

private fetchPowerState(source: Observable<any>, silent = false, onError?: () => void): void {
if (!silent) {
this.isLoading().set(true)
}

source
.pipe(
catchError((err) => {
if (onError) {
console.error(err)
onError()
}
return EMPTY
}),
Comment thread
Copilot marked this conversation as resolved.
finalize(() => {
if (!silent) {
this.isLoading().set(false)
}
}),
takeUntilDestroyed(this.destroyRef)
Comment thread
DevipriyaS17 marked this conversation as resolved.
)
.subscribe((powerState) => {
this.powerState.set(
powerState.powerstate.toString() === '2'
? 'deviceToolbar.power.on.value'
: powerState.powerstate.toString() === '3' || powerState.powerstate.toString() === '4'
? 'deviceToolbar.power.sleep.value'
: 'deviceToolbar.power.off.value'
)
this.isLoading().set(false)
this.setPowerStateLabel(powerState.powerstate)
})
}

Expand Down Expand Up @@ -437,6 +493,14 @@ export class DeviceToolbarComponent implements OnInit {
const msg: string = this.translate.instant('devices.powerActionSent.value')
console.log('Power action sent successfully:', data)
this.snackBar.open(msg, undefined, SnackbarDefaults.defaultSuccess)
// Set expected state immediately for instant feedback.
this.setOptimisticPowerState(action)
// Then silently confirm actual state after device transitions — no loading spinner.
timer(5000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.silentRefreshPowerState()
})
} else {
console.log('Power action failed:', data)
const msg: string = this.translate.instant('devices.failPowerAction.value')
Expand Down
4 changes: 4 additions & 0 deletions src/assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,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."
Expand Down
Loading