-
Notifications
You must be signed in to change notification settings - Fork 318
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
NAS-133572: Allow users to log in to docker registries in the UI
- Loading branch information
1 parent
fdda7b3
commit e2308cf
Showing
104 changed files
with
2,253 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
export interface DockerRegistry { | ||
id: number; | ||
name: string; | ||
username: string; | ||
password: string; | ||
uri: string; | ||
description: string | null; | ||
} | ||
|
||
export type DockerRegistryPayload = Omit<DockerRegistry, 'id'>; | ||
|
||
export const dockerHubRegistry = 'https://registry-1.docker.io/'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
37 changes: 37 additions & 0 deletions
37
...components/docker-registries/docker-registries-list/docker-registries-list.component.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
<ix-page-header> | ||
<ix-search-input1 [value]="filterString" (search)="onListFiltered($event)"></ix-search-input1> | ||
|
||
<ix-table-columns-selector [columns]="columns" (columnsChange)="columnsChange($event)"></ix-table-columns-selector> | ||
|
||
<button | ||
*ixRequiresRoles="requiredRoles" | ||
mat-button | ||
color="primary" | ||
ixTest="add-docker-registry" | ||
[ixUiSearch]="searchableElements.elements.addRegistry" | ||
(click)="onAdd()" | ||
> | ||
{{ 'Add Registry' | translate }} | ||
</button> | ||
</ix-page-header> | ||
|
||
<ix-table | ||
class="table" | ||
[ixUiSearch]="searchableElements.elements.dockerRegistriesList" | ||
[ix-table-empty]="!(dataProvider.currentPageCount$ | async)" | ||
[emptyConfig]="emptyService.defaultEmptyConfig(dataProvider.emptyType$ | async)" | ||
> | ||
<thead | ||
ix-table-head | ||
[columns]="columns" | ||
[dataProvider]="dataProvider" | ||
></thead> | ||
<tbody | ||
ix-table-body | ||
[columns]="columns" | ||
[dataProvider]="dataProvider" | ||
[isLoading]="!!(dataProvider.isLoading$ | async)" | ||
> | ||
</tbody> | ||
</ix-table> | ||
<ix-table-pager [dataProvider]="dataProvider"></ix-table-pager> |
99 changes: 99 additions & 0 deletions
99
...ponents/docker-registries/docker-registries-list/docker-registries-list.component.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import { HarnessLoader } from '@angular/cdk/testing'; | ||
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; | ||
import { MatButtonHarness } from '@angular/material/button/testing'; | ||
import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; | ||
import { MockComponent } from 'ng-mocks'; | ||
import { of } from 'rxjs'; | ||
import { mockCall, mockApi } from 'app/core/testing/utils/mock-api.utils'; | ||
import { mockAuth } from 'app/core/testing/utils/mock-auth.utils'; | ||
import { DockerRegistry } from 'app/interfaces/docker-registry.interface'; | ||
import { DialogService } from 'app/modules/dialog/dialog.service'; | ||
import { SearchInput1Component } from 'app/modules/forms/search-input1/search-input1.component'; | ||
import { IxIconHarness } from 'app/modules/ix-icon/ix-icon.harness'; | ||
import { IxTableHarness } from 'app/modules/ix-table/components/ix-table/ix-table.harness'; | ||
import { PageHeaderComponent } from 'app/modules/page-header/page-title-header/page-header.component'; | ||
import { SlideIn } from 'app/modules/slide-ins/slide-in'; | ||
import { ApiService } from 'app/modules/websocket/api.service'; | ||
import { DockerRegistriesListComponent } from 'app/pages/apps/components/docker-registries/docker-registries-list/docker-registries-list.component'; | ||
import { DockerRegistryFormComponent } from 'app/pages/apps/components/docker-registries/docker-registry-form/docker-registry-form.component'; | ||
|
||
describe('DockerRegistriesListComponent', () => { | ||
let spectator: Spectator<DockerRegistriesListComponent>; | ||
let loader: HarnessLoader; | ||
let table: IxTableHarness; | ||
|
||
const dockerRegistries: DockerRegistry[] = [ | ||
{ | ||
id: 1, | ||
name: 'Docker Hub', | ||
description: 'Docker Hub', | ||
uri: 'https://index.docker.io/v1/', | ||
username: 'docker', | ||
password: 'password', | ||
}, | ||
]; | ||
|
||
const createComponent = createComponentFactory({ | ||
component: DockerRegistriesListComponent, | ||
imports: [ | ||
MockComponent(PageHeaderComponent), | ||
SearchInput1Component, | ||
], | ||
declarations: [ | ||
], | ||
providers: [ | ||
mockAuth(), | ||
mockApi([ | ||
mockCall('app.registry.query', dockerRegistries), | ||
mockCall('app.registry.delete'), | ||
]), | ||
mockProvider(DialogService, { | ||
confirm: jest.fn(() => of(true)), | ||
}), | ||
mockProvider(SlideIn, { | ||
open: jest.fn(() => of()), | ||
}), | ||
], | ||
}); | ||
|
||
beforeEach(async () => { | ||
spectator = createComponent(); | ||
loader = TestbedHarnessEnvironment.loader(spectator.fixture); | ||
table = await loader.getHarness(IxTableHarness); | ||
}); | ||
|
||
it('should show table rows', async () => { | ||
const expectedRows = [ | ||
['Name', 'Description', 'Username', 'Uri', ''], | ||
['Docker Hub', 'Docker Hub', 'docker', 'https://index.docker.io/v1/', ''], | ||
]; | ||
|
||
expect(spectator.inject(ApiService).call).toHaveBeenCalledWith('app.registry.query'); | ||
expect(await table.getCellTexts()).toEqual(expectedRows); | ||
}); | ||
|
||
it('opens delete dialog when "Delete" button is pressed', async () => { | ||
const deleteButton = await table.getHarnessInRow(IxIconHarness.with({ name: 'mdi-delete' }), 'Docker Hub'); | ||
await deleteButton.click(); | ||
|
||
expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith({ | ||
message: 'Are you sure you want to delete the <b>Docker Hub (https://index.docker.io/v1/)</b> registry?', | ||
title: 'Delete Docker Registry', | ||
buttonColor: 'warn', | ||
buttonText: 'Delete', | ||
}); | ||
|
||
expect(spectator.inject(ApiService).call).toHaveBeenCalledWith('app.registry.delete', [1]); | ||
}); | ||
|
||
it('opens form when "Add Registry" button is pressed', async () => { | ||
const pullImageButton = await loader.getHarness(MatButtonHarness.with({ text: 'Add Registry' })); | ||
await pullImageButton.click(); | ||
|
||
expect(spectator.inject(SlideIn).open).toHaveBeenCalledWith(DockerRegistryFormComponent, { | ||
data: { | ||
isLoggedInToDockerHub: false, | ||
}, | ||
}); | ||
}); | ||
}); |
182 changes: 182 additions & 0 deletions
182
...s/components/docker-registries/docker-registries-list/docker-registries-list.component.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,182 @@ | ||
import { AsyncPipe } from '@angular/common'; | ||
import { | ||
ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, | ||
signal, | ||
} from '@angular/core'; | ||
import { MatButton } from '@angular/material/button'; | ||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; | ||
import { TranslateService, TranslateModule } from '@ngx-translate/core'; | ||
import { filter, switchMap, tap } from 'rxjs/operators'; | ||
import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; | ||
import { UiSearchDirective } from 'app/directives/ui-search.directive'; | ||
import { Role } from 'app/enums/role.enum'; | ||
import { dockerHubRegistry, DockerRegistry } from 'app/interfaces/docker-registry.interface'; | ||
import { DialogService } from 'app/modules/dialog/dialog.service'; | ||
import { EmptyService } from 'app/modules/empty/empty.service'; | ||
import { SearchInput1Component } from 'app/modules/forms/search-input1/search-input1.component'; | ||
import { iconMarker } from 'app/modules/ix-icon/icon-marker.util'; | ||
import { AsyncDataProvider } from 'app/modules/ix-table/classes/async-data-provider/async-data-provider'; | ||
import { IxTableComponent } from 'app/modules/ix-table/components/ix-table/ix-table.component'; | ||
import { | ||
actionsColumn, | ||
} from 'app/modules/ix-table/components/ix-table-body/cells/ix-cell-actions/ix-cell-actions.component'; | ||
import { textColumn } from 'app/modules/ix-table/components/ix-table-body/cells/ix-cell-text/ix-cell-text.component'; | ||
import { IxTableBodyComponent } from 'app/modules/ix-table/components/ix-table-body/ix-table-body.component'; | ||
import { IxTableColumnsSelectorComponent } from 'app/modules/ix-table/components/ix-table-columns-selector/ix-table-columns-selector.component'; | ||
import { IxTableHeadComponent } from 'app/modules/ix-table/components/ix-table-head/ix-table-head.component'; | ||
import { IxTablePagerComponent } from 'app/modules/ix-table/components/ix-table-pager/ix-table-pager.component'; | ||
import { IxTableEmptyDirective } from 'app/modules/ix-table/directives/ix-table-empty.directive'; | ||
import { createTable } from 'app/modules/ix-table/utils'; | ||
import { AppLoaderService } from 'app/modules/loader/app-loader.service'; | ||
import { PageHeaderComponent } from 'app/modules/page-header/page-title-header/page-header.component'; | ||
import { SlideIn } from 'app/modules/slide-ins/slide-in'; | ||
import { TestDirective } from 'app/modules/test-id/test.directive'; | ||
import { ApiService } from 'app/modules/websocket/api.service'; | ||
import { dockerRegistriesListElements } from 'app/pages/apps/components/docker-registries/docker-registries-list/docker-registries-list.elements'; | ||
import { DockerRegistryFormComponent } from 'app/pages/apps/components/docker-registries/docker-registry-form/docker-registry-form.component'; | ||
import { ErrorHandlerService } from 'app/services/error-handler.service'; | ||
|
||
@UntilDestroy() | ||
@Component({ | ||
selector: 'ix-docker-registries-list', | ||
templateUrl: './docker-registries-list.component.html', | ||
changeDetection: ChangeDetectionStrategy.OnPush, | ||
standalone: true, | ||
imports: [ | ||
PageHeaderComponent, | ||
IxTableColumnsSelectorComponent, | ||
RequiresRolesDirective, | ||
MatButton, | ||
TestDirective, | ||
UiSearchDirective, | ||
IxTableComponent, | ||
IxTableEmptyDirective, | ||
IxTableHeadComponent, | ||
IxTableBodyComponent, | ||
IxTablePagerComponent, | ||
SearchInput1Component, | ||
TranslateModule, | ||
AsyncPipe, | ||
], | ||
}) | ||
export class DockerRegistriesListComponent implements OnInit { | ||
readonly requiredRoles = [Role.FullAdmin]; | ||
protected readonly searchableElements = dockerRegistriesListElements; | ||
|
||
dataProvider: AsyncDataProvider<DockerRegistry>; | ||
filterString = ''; | ||
protected isLoggedIntoDockerHub = signal(false); | ||
|
||
columns = createTable<DockerRegistry>([ | ||
textColumn({ | ||
title: this.translate.instant('Name'), | ||
propertyName: 'name', | ||
}), | ||
textColumn({ | ||
title: this.translate.instant('Description'), | ||
propertyName: 'description', | ||
}), | ||
textColumn({ | ||
title: this.translate.instant('Username'), | ||
propertyName: 'username', | ||
}), | ||
textColumn({ | ||
title: this.translate.instant('Uri'), | ||
propertyName: 'uri', | ||
}), | ||
actionsColumn({ | ||
actions: [ | ||
{ | ||
iconName: iconMarker('edit'), | ||
tooltip: this.translate.instant('Edit'), | ||
onClick: (row) => this.onEdit(row), | ||
}, | ||
{ | ||
iconName: iconMarker('mdi-delete'), | ||
tooltip: this.translate.instant('Delete'), | ||
requiredRoles: this.requiredRoles, | ||
onClick: (row) => this.onDelete(row), | ||
}, | ||
], | ||
}), | ||
], { | ||
uniqueRowTag: (row) => `docker-registry-${row.uri}-${row.name}`, | ||
ariaLabels: (row) => [row.name, row.description, this.translate.instant('Docker Registry')], | ||
}); | ||
|
||
constructor( | ||
protected emptyService: EmptyService, | ||
private translate: TranslateService, | ||
private api: ApiService, | ||
private slideIn: SlideIn, | ||
private dialogService: DialogService, | ||
private loader: AppLoaderService, | ||
private errorHandler: ErrorHandlerService, | ||
private cdr: ChangeDetectorRef, | ||
) {} | ||
|
||
ngOnInit(): void { | ||
this.dataProvider = new AsyncDataProvider( | ||
this.api.call('app.registry.query').pipe( | ||
tap((registries) => { | ||
this.isLoggedIntoDockerHub.set( | ||
registries.some((registry) => registry.uri.includes(dockerHubRegistry)), | ||
); | ||
}), | ||
), | ||
); | ||
this.dataProvider.load(); | ||
} | ||
|
||
protected columnsChange(columns: typeof this.columns): void { | ||
this.columns = [...columns]; | ||
this.cdr.detectChanges(); | ||
this.cdr.markForCheck(); | ||
} | ||
|
||
protected onListFiltered(query: string): void { | ||
this.filterString = query; | ||
this.dataProvider.setFilter({ | ||
query, | ||
columnKeys: ['name', 'description', 'username', 'uri'], | ||
}); | ||
} | ||
|
||
protected onAdd(): void { | ||
this.slideIn.open(DockerRegistryFormComponent, { | ||
data: { isLoggedInToDockerHub: this.isLoggedIntoDockerHub() }, | ||
}) | ||
.pipe(filter((response) => !!response.response), untilDestroyed(this)) | ||
.subscribe(() => this.dataProvider.load()); | ||
} | ||
|
||
private onEdit(row: DockerRegistry): void { | ||
this.slideIn.open(DockerRegistryFormComponent, { | ||
data: { registry: row, isLoggedInToDockerHub: this.isLoggedIntoDockerHub() }, | ||
}) | ||
.pipe(filter((response) => !!response.response), untilDestroyed(this)) | ||
.subscribe(() => this.dataProvider.load()); | ||
} | ||
|
||
private onDelete(row: DockerRegistry): void { | ||
this.dialogService.confirm({ | ||
title: this.translate.instant('Delete Docker Registry'), | ||
message: this.translate.instant('Are you sure you want to delete the <b>{name}</b> registry?', { | ||
name: `${row.name} (${row.uri})`, | ||
}), | ||
buttonText: this.translate.instant('Delete'), | ||
buttonColor: 'warn', | ||
}) | ||
.pipe( | ||
filter(Boolean), | ||
switchMap(() => { | ||
return this.api.call('app.registry.delete', [row.id]).pipe( | ||
this.loader.withLoader(), | ||
this.errorHandler.catchError(), | ||
); | ||
}), | ||
untilDestroyed(this), | ||
) | ||
.subscribe(() => this.dataProvider.load()); | ||
} | ||
} |
Oops, something went wrong.