Skip to content

Commit

Permalink
NAS-133572: Allow users to log in to docker registries in the UI
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexKarpov98 committed Jan 21, 2025
1 parent fdda7b3 commit e2308cf
Show file tree
Hide file tree
Showing 104 changed files with 2,253 additions and 1 deletion.
4 changes: 4 additions & 0 deletions src/app/helptext/apps/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export const helptextApps = {
no_installed_message: T('Applications you install will automatically appear here. Click below and browse the TrueNAS catalog to get started.'),
},

dockerRegistries: {
tooltip: T('Signing in to Docker Hub is not required for Apps to function, but may help if you experience rate limiting issues.'),
},

catalogMessage: {
loading: T('Loading...'),
no_search_result: T('No Search Results.'),
Expand Down
8 changes: 8 additions & 0 deletions src/app/interfaces/api/api-call-directory.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import {
CreateDnsAuthenticator,
DnsAuthenticator, UpdateDnsAuthenticator,
} from 'app/interfaces/dns-authenticator.interface';
import { DockerRegistry, DockerRegistryPayload } from 'app/interfaces/docker-registry.interface';
import { DockerHubRateLimit } from 'app/interfaces/dockerhub-rate-limit.interface';
import {
DsUncachedGroup, DsUncachedUser, LoggedInUser,
Expand Down Expand Up @@ -326,6 +327,13 @@ export interface ApiCallDirectory {
'app.rollback_versions': { params: [app_name: string]; response: string[] };
'app.ix_volume.exists': { params: [string]; response: boolean };

// App/Docker Registry
'app.registry.create': { params: [DockerRegistryPayload]; response: DockerRegistry };
'app.registry.delete': { params: [number]; response: null };
'app.registry.update': { params: [number, DockerRegistryPayload]; response: DockerRegistry };
'app.registry.get_instance': { params: [number]; response: DockerRegistry };
'app.registry.query': { params: QueryParams<DockerRegistryPayload>; response: DockerRegistry[] };

// App Image
'app.image.delete': { params: DeleteContainerImageParams; response: boolean };
'app.image.dockerhub_rate_limit': { params: void; response: DockerHubRateLimit };
Expand Down
12 changes: 12 additions & 0 deletions src/app/interfaces/docker-registry.interface.ts
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/';
11 changes: 11 additions & 0 deletions src/app/pages/apps/apps.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AppsScopeWrapperComponent } from 'app/pages/apps/components/apps-scope-
import { AvailableAppsComponent } from 'app/pages/apps/components/available-apps/available-apps.component';
import { CategoryViewComponent } from 'app/pages/apps/components/available-apps/category-view/category-view.component';
import { DockerImagesListComponent } from 'app/pages/apps/components/docker-images/docker-images-list/docker-images-list.component';
import { DockerRegistriesListComponent } from 'app/pages/apps/components/docker-registries/docker-registries-list/docker-registries-list.component';
import { ContainerLogsComponent } from 'app/pages/apps/components/installed-apps/container-logs/container-logs.component';
import { ContainerShellComponent } from 'app/pages/apps/components/installed-apps/container-shell/container-shell.component';
import { InstalledAppsComponent } from 'app/pages/apps/components/installed-apps/installed-apps.component';
Expand All @@ -28,6 +29,11 @@ export const appsRoutes: Routes = [
redirectTo: 'manage-container-images',
pathMatch: 'full',
},
{
path: 'installed/docker-registries',
redirectTo: 'docker-registries',
pathMatch: 'full',
},
{
path: 'installed',
component: AppRouterOutletComponent,
Expand Down Expand Up @@ -72,6 +78,11 @@ export const appsRoutes: Routes = [
component: DockerImagesListComponent,
data: { title: T('Manage Container Images') },
},
{
path: 'docker-registries',
component: DockerRegistriesListComponent,
data: { title: T('Docker Registries') },
},
{
path: 'available',
component: AppRouterOutletComponent,
Expand Down
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>
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,
},
});
});
});
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());
}
}
Loading

0 comments on commit e2308cf

Please sign in to comment.