Skip to content

Commit 308f0ef

Browse files
committed
feat: add more to IDER tab
1 parent fc5f750 commit 308f0ef

16 files changed

Lines changed: 575 additions & 37 deletions

File tree

src/app/devices/ider/ider.component.html

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,66 @@
1-
<mat-toolbar style="height: 100px" class="flex flex-row">
2-
<div class="flex flex-50 justify-end">
3-
<button mat-flat-button color="primary" (click)="fileInput.click()">
4-
<mat-icon>upload_file</mat-icon>{{ 'ider.attachDisk.label.value' | translate }}
1+
<mat-toolbar class="flex flex-row ider-toolbar">
2+
<div class="ider-status" [class.active]="isIDERActive()" [class.loading]="isLoading()">
3+
<mat-icon>
4+
@if (isLoading()) {
5+
sync
6+
} @else if (isIDERActive()) {
7+
check_circle
8+
} @else {
9+
radio_button_unchecked
10+
}
11+
</mat-icon>
12+
<div class="ider-status-copy">
13+
<div class="ider-status-title">
14+
@if (isIDERActive()) {
15+
{{ 'warning.iderActive.value' | translate }}
16+
} @else if (isLoading() && loadingStatus()) {
17+
{{ loadingStatus() | translate }}
18+
} @else {
19+
{{ 'ider.status.notConnected.value' | translate }}
20+
}
21+
</div>
22+
@if (!isIDERActive() && !isLoading()) {
23+
<div class="ider-status-subtitle">{{ 'ider.status.attachHelp.value' | translate }}</div>
24+
}
25+
</div>
26+
</div>
27+
28+
<div class="ider-actions">
29+
<button
30+
mat-flat-button
31+
color="primary"
32+
[disabled]="isLoading() || isIDERActive()"
33+
(click)="onAttachDiskImage(fileInput)">
34+
<mat-icon [class.loading-icon]="isLoading()">
35+
@if (isLoading()) {
36+
sync
37+
} @else {
38+
upload_file
39+
}</mat-icon
40+
>{{ 'ider.attachDisk.label.value' | translate }}
541
</button>
6-
<input hidden (change)="onFileSelected($event)" #fileInput type="file" id="file" accept=".iso, .img" />
42+
<input hidden (change)="onFileSelected($event)" #fileInput type="file" id="file" accept=".iso" />
743
@if (isIDERActive()) {
844
<button mat-flat-button color="warn" (click)="onCancelIDER()" class="stop-ider-button">
945
<mat-icon>stop</mat-icon>{{ 'ider.stopIder.label.value' | translate }}
1046
</button>
1147
}
1248
</div>
1349
</mat-toolbar>
50+
51+
@if (isLoading() && deviceState() !== 2) {
52+
<div class="loading-container">
53+
<div class="loading-status">{{ loadingStatus() | translate }}</div>
54+
</div>
55+
}
56+
57+
@if (!diskImage && !isLoading()) {
58+
<div class="ider-empty-state">
59+
<mat-icon>dns</mat-icon>
60+
<p>{{ 'ider.emptyState.selectIso.value' | translate }}</p>
61+
</div>
62+
}
63+
1464
@if (diskImage && deviceIDERConnection()) {
1565
<amt-ider
1666
class="ider"
Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,94 @@
11
.canvas {
22
background-color: black;
33
}
4+
.ider-toolbar {
5+
min-height: 96px;
6+
height: auto;
7+
gap: 16px;
8+
justify-content: space-between;
9+
align-items: center;
10+
flex-wrap: wrap;
11+
}
12+
.ider-status {
13+
display: flex;
14+
align-items: center;
15+
gap: 12px;
16+
padding: 10px 12px;
17+
border-radius: 12px;
18+
border: 1px solid rgba(0, 0, 0, 0.12);
19+
background: #f5f7fa;
20+
}
21+
.ider-status mat-icon {
22+
color: #616161;
23+
}
24+
.ider-status.loading mat-icon {
25+
color: #1976d2;
26+
animation: spin 1.5s linear infinite;
27+
}
28+
.ider-status.active mat-icon {
29+
color: #2e7d32;
30+
}
31+
.ider-status-copy {
32+
display: flex;
33+
flex-direction: column;
34+
gap: 2px;
35+
}
36+
.ider-status-title {
37+
font-size: 14px;
38+
font-weight: 600;
39+
color: rgba(0, 0, 0, 0.87);
40+
}
41+
.ider-status-subtitle {
42+
font-size: 12px;
43+
color: rgba(0, 0, 0, 0.6);
44+
}
45+
.ider-actions {
46+
display: flex;
47+
align-items: center;
48+
justify-content: flex-end;
49+
margin-inline-start: auto;
50+
}
51+
.ider-actions .loading-icon {
52+
animation: spin 1.5s linear infinite;
53+
}
454
.loading-container {
5-
position: absolute;
6-
top: 50%;
7-
left: 50%;
8-
transform: translate(-50%, -50%);
55+
margin: 12px 0;
956
display: flex;
1057
flex-direction: column;
1158
align-items: center;
12-
gap: 16px;
59+
gap: 8px;
1360
}
1461
.loading-status {
1562
color: rgba(0, 0, 0, 0.87);
1663
font-size: 14px;
1764
font-weight: 500;
1865
text-align: center;
19-
margin-top: 8px;
2066
}
2167
.stop-ider-button {
2268
margin-inline-start: 8px;
2369
}
70+
.ider-empty-state {
71+
margin-top: 16px;
72+
border: 1px dashed rgba(0, 0, 0, 0.2);
73+
border-radius: 12px;
74+
padding: 24px 16px;
75+
display: flex;
76+
flex-direction: column;
77+
align-items: center;
78+
justify-content: center;
79+
gap: 8px;
80+
color: rgba(0, 0, 0, 0.65);
81+
text-align: center;
82+
}
83+
.ider-empty-state mat-icon {
84+
color: rgba(0, 0, 0, 0.45);
85+
}
86+
87+
@keyframes spin {
88+
from {
89+
transform: rotate(0deg);
90+
}
91+
to {
92+
transform: rotate(360deg);
93+
}
94+
}

src/app/devices/ider/ider.component.spec.ts

Lines changed: 146 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,27 @@ describe('IderComponent', () => {
148148
it('should create', () => {
149149
expect(component).toBeTruthy()
150150
fixture.detectChanges()
151-
expect(tokenSpy).toHaveBeenCalled()
152-
expect(getPowerStateCachedSpy).toHaveBeenCalled()
151+
expect(tokenSpy).not.toHaveBeenCalled()
152+
expect(getPowerStateCachedSpy).not.toHaveBeenCalled()
153153
expect(getAMTFeaturesCachedSpy).toHaveBeenCalled()
154154
expect(getPowerStateSpy).not.toHaveBeenCalled()
155155
expect(getAMTFeaturesSpy).not.toHaveBeenCalled()
156-
expect(getRedirectionStatusSpy).toHaveBeenCalled()
156+
expect(getRedirectionStatusSpy).not.toHaveBeenCalled()
157+
})
158+
159+
it('prompts to enable IDER on tab load when IDER is disabled', () => {
160+
getAMTFeaturesCachedSpy.and.returnValue(
161+
of({
162+
...amtFeaturesResponse,
163+
IDER: false,
164+
redirection: true
165+
})
166+
)
167+
168+
fixture.detectChanges()
169+
170+
expect(dialogSpy.open).toHaveBeenCalled()
171+
expect(setAmtFeaturesSpy).toHaveBeenCalled()
157172
})
158173

159174
it('should set isDisconnecting to true on NavigationStart event', () => {
@@ -223,9 +238,25 @@ describe('IderComponent', () => {
223238
})
224239
})
225240

241+
it('postUserConsentDecision short-circuits when file selection was canceled', (done) => {
242+
tokenSpy.calls.reset()
243+
;(component as any).diskSelectionCanceled = true
244+
component.isLoading.set(true)
245+
component.loadingStatus.set('ider.status.connectingIder.value')
246+
247+
component.postUserConsentDecision(true).subscribe((result) => {
248+
expect(result).toBeNull()
249+
expect(component.isLoading()).toBeFalse()
250+
expect(component.loadingStatus()).toBe('')
251+
expect(tokenSpy).not.toHaveBeenCalled()
252+
expect((component as any).diskSelectionCanceled).toBeFalse()
253+
done()
254+
})
255+
})
256+
226257
it('init runs consent handlers and refreshes token after consent success', () => {
227258
tokenSpy.calls.reset()
228-
fixture.detectChanges()
259+
component.connect()
229260
expect(component.loadingStatus()).toBe('ider.status.connectingIder.value')
230261
expect(userConsentService.handleUserConsentDecision).toHaveBeenCalledWith(
231262
true,
@@ -236,6 +267,21 @@ describe('IderComponent', () => {
236267
expect(tokenSpy.calls.count()).toBe(1)
237268
})
238269

270+
it('init stops before consent handlers when enabling IDER is declined', () => {
271+
tokenSpy.calls.reset()
272+
userConsentService.handleUserConsentDecision.calls.reset()
273+
userConsentService.handleUserConsentResponse.calls.reset()
274+
spyOn(component, 'handleAMTFeaturesResponse').and.returnValue(of(false))
275+
276+
component.connect()
277+
278+
expect(userConsentService.handleUserConsentDecision).not.toHaveBeenCalled()
279+
expect(userConsentService.handleUserConsentResponse).not.toHaveBeenCalled()
280+
expect(component.isLoading()).toBeFalse()
281+
expect(component.loadingStatus()).toBe('')
282+
expect(tokenSpy.calls.count()).toBe(1)
283+
})
284+
239285
it('postUserConsentDecision does not issue a duplicate AMT features fetch', (done) => {
240286
getAMTFeaturesSpy.calls.reset()
241287
getAMTFeaturesCachedSpy.calls.reset()
@@ -477,13 +523,109 @@ describe('IderComponent', () => {
477523
expect(component.diskImage).toBeNull()
478524
})
479525

526+
it('onAttachDiskImage starts connection and opens file picker', () => {
527+
const connectSpy = spyOn(component, 'connect')
528+
const mockFileInput = jasmine.createSpyObj('HTMLInputElement', ['click']) as HTMLInputElement
529+
mockFileInput.value = 'existing.iso'
530+
531+
component.onAttachDiskImage(mockFileInput)
532+
533+
expect(mockFileInput.value).toBe('')
534+
expect(connectSpy).toHaveBeenCalled()
535+
expect(mockFileInput.click).toHaveBeenCalled()
536+
})
537+
538+
it('onFileSelected does not start connection when a file is selected during active attach flow', () => {
539+
const connectSpy = spyOn(component, 'connect')
540+
const mockFile = new File([''], 'test-file.iso', { type: 'application/octet-stream' })
541+
const mockEvt = { target: { files: [mockFile] } } as unknown as Event
542+
component.isLoading.set(true)
543+
544+
component.onFileSelected(mockEvt)
545+
546+
expect(connectSpy).not.toHaveBeenCalled()
547+
})
548+
549+
it('onFileSelected does not start connection when file selection is canceled', () => {
550+
const connectSpy = spyOn(component, 'connect')
551+
component.isLoading.set(true)
552+
component.loadingStatus.set('ider.status.connectingIder.value')
553+
const mockEvt = { target: { files: [] } } as unknown as Event
554+
555+
component.onFileSelected(mockEvt)
556+
557+
expect(component.diskImage).toBeNull()
558+
expect(component.deviceIDERConnection()).toBeFalse()
559+
expect(component.isLoading()).toBeFalse()
560+
expect(component.loadingStatus()).toBe('')
561+
expect(connectSpy).not.toHaveBeenCalled()
562+
})
563+
564+
it('onAttachDiskImage ignores clicks while loading', () => {
565+
const connectSpy = spyOn(component, 'connect')
566+
const mockFileInput = jasmine.createSpyObj('HTMLInputElement', ['click']) as HTMLInputElement
567+
component.isLoading.set(true)
568+
569+
component.onAttachDiskImage(mockFileInput)
570+
571+
expect(connectSpy).not.toHaveBeenCalled()
572+
expect(mockFileInput.click).not.toHaveBeenCalled()
573+
})
574+
575+
it('onAttachDiskImage warns and does not reconnect when IDER is already active', () => {
576+
const connectSpy = spyOn(component, 'connect')
577+
const mockFileInput = jasmine.createSpyObj('HTMLInputElement', ['click']) as HTMLInputElement
578+
component.isIDERActive.set(true)
579+
580+
component.onAttachDiskImage(mockFileInput)
581+
582+
expect(displayWarningSpy).toHaveBeenCalledWith('ider.alreadyActiveWarning.value')
583+
expect(connectSpy).not.toHaveBeenCalled()
584+
expect(mockFileInput.click).not.toHaveBeenCalled()
585+
})
586+
587+
it('onAttachDiskImage clears loading when picker closes without selecting a file', () => {
588+
const connectSpy = spyOn(component, 'connect')
589+
const mockFileInput = jasmine.createSpyObj('HTMLInputElement', ['click']) as HTMLInputElement
590+
591+
component.onAttachDiskImage(mockFileInput)
592+
component.isLoading.set(true)
593+
component.loadingStatus.set('ider.status.connectingIder.value')
594+
595+
window.dispatchEvent(new Event('focus'))
596+
597+
expect(connectSpy).toHaveBeenCalled()
598+
expect(component.diskImage).toBeNull()
599+
expect(component.isLoading()).toBeFalse()
600+
expect(component.loadingStatus()).toBe('')
601+
})
602+
603+
it('disables the Attach button when IDER is already active', () => {
604+
component.isIDERActive.set(true)
605+
fixture.detectChanges()
606+
607+
const attachButton = fixture.nativeElement.querySelector('button[color="primary"]') as HTMLButtonElement
608+
609+
expect(attachButton.disabled).toBeTrue()
610+
})
611+
480612
// onCancelIDER()
481613
it('should set deviceIDERConnection to false on cancel IDER', () => {
482614
const deviceIDERConnectionSpy = spyOn(component.deviceIDERConnection, 'set')
483615
component.onCancelIDER()
484616
expect(deviceIDERConnectionSpy).toHaveBeenCalledWith(false)
485617
})
486618

619+
it('should clear loading state on cancel IDER', () => {
620+
component.isLoading.set(true)
621+
component.loadingStatus.set('ider.status.connectingIder.value')
622+
623+
component.onCancelIDER()
624+
625+
expect(component.isLoading()).toBeFalse()
626+
expect(component.loadingStatus()).toBe('')
627+
})
628+
487629
it('should clear file input value on cancel IDER', () => {
488630
const mockFileInput = document.createElement('input')
489631
mockFileInput.id = 'file'

0 commit comments

Comments
 (0)