import { FeatureTogglesGuardService } from '~/app/shared/services/feature-toggles-guard.service';
import { ModuleStatusGuardService } from '~/app/shared/services/module-status-guard.service';
import { SharedModule } from '~/app/shared/shared.module';
+import { TextLabelListComponent } from '~/app/shared/components/text-label-list/text-label-list.component';
import { IscsiSettingComponent } from './iscsi-setting/iscsi-setting.component';
import { IscsiTabsComponent } from './iscsi-tabs/iscsi-tabs.component';
import { IscsiTargetDetailsComponent } from './iscsi-target-details/iscsi-target-details.component';
LayoutModule,
ThemeModule,
NvmeofSetupCardsComponent,
- NvmeofGatewayGroupFilterComponent
+ NvmeofGatewayGroupFilterComponent,
+ TextLabelListComponent
],
declarations: [
RbdListComponent,
}
}
},
+ {
+ path: `${URLVerbs.EDIT}/:name`,
+ component: NvmeofGroupFormComponent,
+ data: {
+ breadcrumbs: ActionLabels.EDIT,
+ pageHeader: {
+ title: $localize`Edit Gateway Group`,
+ description: $localize`Modify gateway group configuration.`
+ }
+ }
+ },
{
path: `${URLVerbs.VIEW}/:group`,
component: NvmeGatewayViewComponent,
-<div class="nvmeof-content-layout">
- <div class="nvmeof-content-main">
- <ng-container *ngIf="gatewayGroup$ | async as gateways">
- <!-- Details Card Section -->
- <ng-container *ngIf="selection.hasSelection">
- <cd-details-card
- cardTitle="Details"
- i18n-cardTitle
- [details]="selectedGatewayDetails"
- [showEditButton]="true"
- [editButtonLabel]="actionLabels.EDIT"
- [columns]="4"
- (editClicked)="editSelectedGatewayGroup()">
- </cd-details-card>
- </ng-container>
+<ng-container *ngIf="gatewayGroup$ | async as gateways">
+ <cd-table
+ #table
+ [data]="gateways"
+ [columns]="columns"
+ columnMode="flex"
+ selectionType="single"
+ identifier="name"
+ (updateSelection)="updateSelection($event)"
+ (fetchData)="fetchData()"
+ emptyStateTitle="No gateway group created"
+ i18n-emptyStateTitle
+ emptyStateMessage="Set up your first gateway group to start using NVMe over Fabrics. This will allow you to create high-performance block storage with NVMe/TCP protocol."
+ i18n-emptyStateMessage>
- <cd-table
- #table
- [data]="gateways"
- [columns]="columns"
- columnMode="flex"
- selectionType="single"
- identifier="name"
- (updateSelection)="updateSelection($event)"
- (fetchData)="fetchData()"
- emptyStateTitle="No gateway group created"
- i18n-emptyStateTitle
- emptyStateMessage="Set up your first gateway group to start using NVMe over Fabrics. This will allow you to create high-performance block storage with NVMe/TCP protocol."
- i18n-emptyStateMessage>
- <div class="table-actions">
- <cd-table-actions
- [permission]="permission"
- [selection]="selection"
- [tableActions]="tableActions">
- </cd-table-actions>
- </div>
- </cd-table>
- </ng-container>
- </div>
-</div>
+ <div class="table-actions">
+ <cd-table-actions [permission]="permission"
+ [selection]="selection"
+ class="btn-group"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
+ </cd-table>
+</ng-container>
<ng-template #dateTpl
let-created="data.value">
</ng-template>
<ng-template #customTableItemTemplate
- let-value="data.value"
- let-row="data.row">
+ let-value="data.value">
<a cdsLink
- [routerLink]="[viewUrl, value]"
+ [routerLink]="[viewUrl, value | encodeUri]"
(click)="$event.stopPropagation()">
{{ value }}
</a>
display: block;
margin-bottom: layout.$spacing-05;
}
-
-.table-actions {
- margin-top: layout.$spacing-03;
-}
import { CephServiceService } from '~/app/shared/api/ceph-service.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
-import { DetailItem } from '~/app/shared/components/details-card/details-card.component';
import { FinishedTask } from '~/app/shared/models/finished-task';
import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
import { NotificationService } from '~/app/shared/services/notification.service';
gatewayGroupName = '';
subsystemCount = 0;
gatewayCount = 0;
- selectedGatewayDetails: DetailItem[] = [];
private lastGroupCount = 0;
viewUrl = `/${BASE_URL}/view`;
icons = Icons;
+
iconSize = IconSize;
constructor(
}),
catchError(() => {
return of([]);
- }),
- finalize(() => this.setTableLoading(false))
+ })
)
),
shareReplay({ bufferSize: 1, refCount: true }),
.subscribe(() => this.fetchData());
}
fetchData(): void {
- this.setTableLoading(true);
this.subject.next([]);
this.checkNodesAvailability();
}
- private setTableLoading(loading: boolean): void {
- if (this.table) {
- this.table.loadingIndicator = loading;
- }
- }
-
updateSelection(selection: CdTableSelection): void {
this.selection = selection;
- this.selectedGatewayDetails = this.buildGatewayDetails(selection.first());
}
deleteGatewayGroupModal() {
this.router.navigate([this.urlBuilder.getEdit(selectedGroup.name)]);
}
- private buildGatewayDetails(selectedGroup: any): DetailItem[] {
- if (!selectedGroup) {
- return [];
- }
-
- const runningGateways = selectedGroup.statusCount?.running ?? 0;
- const errorGateways = selectedGroup.statusCount?.error ?? 0;
- const totalGateways = runningGateways + errorGateways;
-
- return [
- {
- label: $localize`Gateway name`,
- value: selectedGroup.name
- },
- {
- label: $localize`Gateway nodes`,
- value: totalGateways
- },
- {
- label: $localize`Encryption`,
- value: selectedGroup.spec?.enable_auth ? $localize`Enabled` : $localize`Disabled`,
- type: 'status'
- },
- {
- label: $localize`mTLS`,
- value: selectedGroup.spec?.enable_mtls ? $localize`Enabled` : $localize`Disabled`,
- type: 'status'
- }
- ];
- }
-
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
-
- editSelectedGatewayGroup(): void {
- const selectedGroup = this.selection.first();
- if (!selectedGroup) {
- return;
- }
- this.router.navigate([this.urlBuilder.getEdit(selectedGroup.name)]);
- }
-
- private buildGatewayDetails(selectedGroup: any): DetailItem[] {
- if (!selectedGroup) {
- return [];
- }
-
- const runningGateways = selectedGroup.statusCount?.running ?? 0;
- const errorGateways = selectedGroup.statusCount?.error ?? 0;
- const totalGateways = runningGateways + errorGateways;
-
- return [
- {
- label: $localize`Gateway name`,
- value: selectedGroup.name
- },
- {
- label: $localize`Gateway nodes`,
- value: totalGateways
- },
- {
- label: $localize`Encryption`,
- value: selectedGroup.spec?.enable_auth ? $localize`Enabled` : $localize`Disabled`,
- type: 'status'
- },
- {
- label: $localize`mTLS`,
- value: selectedGroup.spec?.enable_mtls ? $localize`Enabled` : $localize`Disabled`,
- type: 'status'
- }
- ];
- }
}
<div class="content-theme">
@if (mode === 'details' && groupName) {
- <cd-details-card cardTitle="Details" i18n-cardTitle [details]="gatewayDetails" [showEditButton]="true"
- editButtonLabel="Edit" i18n-editButtonLabel (editClicked)="onEditGateway()" class="cds-mb-5">
+ <cd-details-card cardTitle="Details" i18n-cardTitle [details]="gatewayDetails" [showEditButton]="false"
+ class="cds-mb-5">
</cd-details-card>
}
this.hostsLoaded.emit(this.count);
}
- onEditGateway(): void {}
-
private buildGatewayDetails(
serviceSpec: CephServiceSpec,
gatewayNodeCount: number
i18n>Special characters are not allowed.</span>
</div>
</div>
- <div cdsRow
- class="form-item">
- <div cdsCol>
- <cds-checkbox id="enableCds"
- formControlName="enable_auth"
- i18n>Enable encryption
- </cds-checkbox>
- </div>
- </div>
- @if (groupForm.controls.enableEncryption.value) {
- <div cdsRow
- class="form-item">
- <div cdsCol>
- <cds-textarea-label
- labelInputID="encryptionConfig"
- helperText="Provide the encryption configuration details."
- cdRequiredField="Encryption configuration"
- i18n
- i18n-helperText
- [invalid]="encryptionConfigRef.isInvalid">Encryption configuration
- <textarea cdsTextArea
- id="encryptionConfig"
- cdValidate
- #encryptionConfigRef="cdValidate"
- [invalid]="encryptionConfigRef.isInvalid"
- formControlName="encryptionKey"
- cols="100"
- rows="4"></textarea>
- </cds-textarea-label>
- <span
- class="invalid-feedback"
- *ngIf="groupForm.showError('encryptionKey', formDir, 'required')"
- i18n>This field is required.</span>
- </div>
+ <!-- Target Nodes Selection -->
+ <div
+ cdsRow
+ class="form-item"
+ >
+ <div cdsCol>
+ <h1 class="cds--type-heading-02">Select target nodes</h1>
+ <cd-help-text>
+ <span i18n>
+ Gateway nodes to run NVMe-oF target pods/services
+ </span>
+ </cd-help-text>
+ </div>
+ <div
+ cdsCol
+ class="cds-pt-3 cds-pb-3"
+ >
+ <cd-nvmeof-gateway-node
+ (hostsLoaded)="onHostsLoaded($event)"
+ ></cd-nvmeof-gateway-node>
</div>
- }
-
- <div cdsRow
- class="form-item">
- <div cdsCol>
- <h1 class="cds--type-heading-02">Select target nodes</h1>
- <cd-help-text>
- <span i18n>
- Gateway nodes to run NVMe-oF target pods/services
- </span>
- </cd-help-text>
- </div>
- <div cdsCol
- class="cds-pt-3 cds-pb-3">
- <cd-nvmeof-gateway-node
- (hostsLoaded)="onHostsLoaded($event)"></cd-nvmeof-gateway-node>
- </div>
</div>
<div cdsRow class="form-item cds-mb-0">
<div cdsCol>
id="encryptionKey"
formControlName="encryptionKey"
rows="4"
- cols="100">
+ cols="100"
+ cdsTheme="g10">
</textarea>
</cds-textarea-label>
<span
import { CheckboxModule, GridModule, InputModule, SelectModule } from 'carbon-components-angular';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { FormHelper } from '~/testing/unit-test-helper';
describe('NvmeofGroupFormComponent', () => {
let formHelper: FormHelper;
let taskWrapperService: TaskWrapperService;
let cephServiceService: CephServiceService;
+ let nvmeofService: NvmeofService;
let router: Router;
beforeEach(async () => {
component = fixture.componentInstance;
taskWrapperService = TestBed.inject(TaskWrapperService);
cephServiceService = TestBed.inject(CephServiceService);
+ nvmeofService = TestBed.inject(NvmeofService);
router = TestBed.inject(Router);
+ // Mock NvmeofService.exists so the async unique validator resolves immediately
+ spyOn(nvmeofService, 'exists').and.returnValue(of(false));
+
component.ngOnInit();
form = component.groupForm;
formHelper = new FormHelper(form);
expect(cephServiceService.create).toHaveBeenCalledWith({
service_type: 'nvmeof',
- service_id: 'default',
+ service_id: 'nvmeof.default',
group: 'default',
placement: {
hosts: ['host1', 'host2']
);
});
- it('should create service with encryption when enabled', () => {
+ it('should create service with encryption key when enabled', () => {
component.gatewayNodeComponent = {
getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
getSelectedHostnames: (): string[] => ['host1']
} as any;
component.groupForm.get('groupName').setValue('mtls-internal');
- component.groupForm.get('pool').setValue('rbd');
component.groupForm.get('enableEncryption').setValue(true);
component.groupForm.get('encryptionKey').setValue('test-encryption-key');
component.groupForm.get('enableMtls').setValue(true);
expect(cephServiceService.create).toHaveBeenCalledWith(
jasmine.objectContaining({
service_type: 'nvmeof',
- service_id: 'rbd.mtls-internal',
+ service_id: 'nvmeof.mtls-internal',
ssl: true,
enable_auth: true,
certificate_source: 'cephadm-signed',
} as any;
component.groupForm.get('groupName').setValue('mtls-external');
- component.groupForm.get('pool').setValue('rbd');
component.groupForm.get('enableEncryption').setValue(true);
component.groupForm.get('encryptionKey').setValue('test-encryption-key');
component.groupForm.get('enableMtls').setValue(true);
expect(cephServiceService.create).toHaveBeenCalledWith(
jasmine.objectContaining({
- service_id: 'rbd.mtls-external',
+ service_id: 'nvmeof.mtls-external',
ssl: true,
enable_auth: true,
certificate_source: 'inline',
import { Component, OnInit, ViewChild } from '@angular/core';
import { UntypedFormControl, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import { CdForm } from '~/app/shared/forms/cd-form';
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { CephServiceService } from '~/app/shared/api/ceph-service.service';
import { FinishedTask } from '~/app/shared/models/finished-task';
-import { Router } from '@angular/router';
import { CdValidators } from '~/app/shared/forms/cd-validators';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { Pool } from '../../pool/pool';
import { CertificateType } from '~/app/shared/models/service.interface';
@Component({
resource: string;
group = '';
pageURL = '';
- pools: Pool[] = [];
- poolsLoading = false;
hasAvailableNodes = true;
+ editing = false;
+ gatewayGroupName = '';
+ existingServiceData: any = null;
constructor(
private authStorageService: AuthStorageService,
private taskWrapperService: TaskWrapperService,
private cephServiceService: CephServiceService,
private nvmeofService: NvmeofService,
- private router: Router
+ private router: Router,
+ private route: ActivatedRoute
) {
super();
this.permission = this.authStorageService.getPermissions().nvmeof;
}
ngOnInit() {
- this.action = this.actionLabels.CREATE;
this.createForm();
+
+ this.route.params.subscribe((params) => {
+ if (params['name']) {
+ this.editing = true;
+ this.gatewayGroupName = params['name'];
+ this.action = this.actionLabels.EDIT;
+ this.loadGatewayGroupData(params['name']);
+ } else {
+ this.editing = false;
+ this.action = this.actionLabels.CREATE;
+ }
+ });
}
createForm() {
+ const groupNameValidators = [
+ Validators.required,
+ (control: any) => {
+ const value = control.value;
+ return value && /[^a-zA-Z0-9_-]/.test(value) ? { invalidChars: true } : null;
+ }
+ ];
+
+ const groupNameAsyncValidators = this.editing
+ ? []
+ : [CdValidators.unique(this.nvmeofService.exists, this.nvmeofService)];
+
this.groupForm = new CdFormGroup({
- groupName: new UntypedFormControl(
- null,
- [
- Validators.required,
- (control) => {
- const value = control.value;
- return value && /[^a-zA-Z0-9_-]/.test(value) ? { invalidChars: true } : null;
- }
- ],
- [CdValidators.unique(this.nvmeofService.exists, this.nvmeofService)]
- ),
+ groupName: new UntypedFormControl(null, groupNameValidators, groupNameAsyncValidators),
unmanaged: new UntypedFormControl(false),
enableEncryption: new UntypedFormControl(false),
encryptionConfig: new UntypedFormControl(null),
encryptionKey: new UntypedFormControl(null),
enableMtls: new UntypedFormControl(false),
certificateType: new UntypedFormControl(CertificateType.internal),
- pool: new UntypedFormControl('rbd'),
custom_sans: new UntypedFormControl([]),
rootCACert: new UntypedFormControl(null),
clientCert: new UntypedFormControl(null),
});
this.groupForm.get('enableEncryption')?.valueChanges.subscribe((enabled) => {
- // Keep both legacy and new encryption fields in sync.
if (!enabled) {
return;
}
encryptionConfigControl.setValue(encryptionKeyControl.value, { emitEvent: false });
}
});
+
+ if (this.editing) {
+ this.groupForm.get('groupName')?.disable();
+ }
+ }
+
+ loadGatewayGroupData(groupName: string) {
+ this.nvmeofService.listGatewayGroups().subscribe(
+ (gatewayGroups: any) => {
+ const groups = gatewayGroups?.[0] ?? [];
+ const group = groups.find((g: any) => g.spec?.group === groupName);
+
+ if (group) {
+ this.existingServiceData = group;
+ const spec = group.spec || {};
+
+ this.groupForm.patchValue({
+ groupName: groupName,
+ unmanaged: spec.unmanaged || false,
+ enableEncryption: spec.ssl || false,
+ enableMtls: spec.ssl || false,
+ certificateType:
+ spec.certificate_source === 'inline'
+ ? CertificateType.external
+ : CertificateType.internal,
+ encryptionKey: spec.encryption_key || '',
+ custom_sans: spec.custom_sans || []
+ });
+
+ if (spec.certificate_source === 'inline') {
+ this.groupForm.patchValue({
+ rootCACert: spec.root_ca_cert || null,
+ clientCert: spec.client_cert || null,
+ clientKey: spec.client_key || null,
+ serverCert: spec.server_cert || null,
+ serverKey: spec.server_key || null
+ });
+ }
+ }
+ },
+ (_error) => {
+ // Error loading gateway group data
+ }
+ );
}
onHostsLoaded(count: number): void {
return;
}
- const formValues = this.groupForm.value;
+ const formValues = this.groupForm.getRawValue();
const selectedHostnames = this.gatewayNodeComponent?.getSelectedHostnames() || [];
if (selectedHostnames.length === 0) {
this.groupForm.setErrors({ cdSubmitButton: true });
return;
}
- const taskUrl = `service/${URLVerbs.CREATE}`;
- const serviceId = `${formValues.groupName}`;
+
+ const groupName = this.editing ? this.gatewayGroupName : formValues.groupName;
+ const serviceName = `nvmeof.${groupName}`;
+ const taskUrl = this.editing ? `service/${URLVerbs.EDIT}` : `service/${URLVerbs.CREATE}`;
const serviceSpec: Record<string, any> = {
service_type: 'nvmeof',
- service_id: serviceId,
- group: formValues.groupName,
+ service_id: serviceName,
+ group: groupName,
placement: {
hosts: selectedHostnames
},
serviceSpec['certificate_source'] =
formValues.certificateType === CertificateType.internal ? 'cephadm-signed' : 'inline';
- if (formValues.pool) {
- serviceSpec['pool'] = formValues.pool;
- serviceSpec['service_id'] = `${formValues.pool}.${formValues.groupName}`;
- }
-
if (
formValues.certificateType === CertificateType.internal &&
formValues.custom_sans?.length > 0
}
}
+ const apiCall = this.editing
+ ? this.cephServiceService.update(serviceSpec)
+ : this.cephServiceService.create(serviceSpec);
+
this.taskWrapperService
.wrapTaskAroundCall({
task: new FinishedTask(taskUrl, {
- service_name: `nvmeof.${serviceId}`
+ service_name: serviceName
}),
- call: this.cephServiceService.create(serviceSpec)
+ call: apiCall
})
.subscribe({
complete: () => {
this.router.navigateByUrl('/block/nvmeof/gateways');
}
- onCertificateTypeChange(type: CertificateType): void {
- this.groupForm.get('certificateType')?.setValue(type);
- }
-
onFileUpload(event: Event, controlName: string): void {
const target = event.target as HTMLInputElement;
const file = target?.files?.[0];
reader.onload = () => control.setValue(reader.result);
reader.readAsText(file, 'utf8');
}
+
+ onCertificateTypeChange(type: CertificateType): void {
+ this.groupForm.get('certificateType')?.setValue(type);
+ if (type === CertificateType.internal) {
+ this.groupForm.patchValue({
+ rootCACert: null,
+ clientCert: null,
+ clientKey: null,
+ serverCert: null,
+ serverKey: null
+ });
+ } else {
+ this.groupForm.patchValue({
+ custom_sans: []
+ });
+ }
+ }
}
component.fetchData();
});
+
+ it('should update group and trigger namespace fetch on group change', () => {
+ const fetchDataSpy = jest.spyOn(component, 'fetchData');
+
+ component.onGroupChange('g1');
+
+ expect(component.group).toBe('g1');
+ expect(fetchDataSpy).not.toHaveBeenCalled(); // onGroupChange calls namespaceSubject.next() directly
+ });
+
+ it('should clear group on onGroupChange with null', () => {
+ component.group = 'g1';
+ component.onGroupChange(null);
+ expect(component.group).toBeNull();
+ });
});
});
}
+ get group(): string | null {
+ return this.groupHandler.group;
+ }
+
+ set group(value: string | null) {
+ this.groupHandler.group = value;
+ }
+
+ onGroupChange(group: string | null): void {
+ this.groupHandler.onGroupChange(group);
+ }
+
updateSelection(selection: CdTableSelection) {
this.selection = selection;
}
} else {
this.showRealmCreationForm = false;
}
- this.updateRgwControlStates();
+ this.updateRgwControlStates();
},
(_error) => {
const defaultZone = new RgwZone();
defaultZonegroup.name = 'default';
this.zoneList.push(defaultZone);
this.zonegroupList.push(defaultZonegroup);
- this.updateRgwControlStates();
+ this.updateRgwControlStates();
}
);
}
this.toggleFormControlState('zone_name', this.editing || this.zoneList.length === 0);
}
- private updateGrafanaPasswordControlState(serviceType = this.serviceForm?.get('service_type')?.value): void {
+ private updateGrafanaPasswordControlState(
+ serviceType = this.serviceForm?.get('service_type')?.value
+ ): void {
this.toggleFormControlState(
'grafana_admin_password',
this.editing && serviceType === 'grafana'
<ng-template #header>
@if (cardTitle) {
<h3 class="cds--type-heading-03" i18n>{{ cardTitle }}</h3>
- @if (showEditButton) {
- <button
- cdsButton="ghost"
- size="sm"
- [disabled]="editButtonDisabled"
- (click)="onEditClick()"
- [attr.aria-label]="editButtonLabel"
- >
- {{ editButtonLabel }}
- <cd-icon class="cds-ml-5" type="edit"></cd-icon>
- </button>
- }
}
</ng-template>
- <div>
+ <div [cdsStack]="'vertical'"
+ [gap]="0">
@if (details && details.length > 0) {
- <div class="cds--grid cds--no-gutter">
- <div class="cds--row">
- @for (detail of getVisibleDetails(); track detail.label) {
- <div class="cds--col">
- <div [cdsStack]="'vertical'" [gap]="2">
- <label class="cds--type-label-01">{{ detail.label }}</label>
- <div class="cds--type-body-01">
- @switch (detail.type) {
- @case ('status') {
- <div [cdsStack]="'horizontal'" [gap]="3">
- <cd-icon [type]="getStatusIcon(detail)"></cd-icon>
- <span>{{ getDisplayValue(detail.value) }}</span>
- </div>
- }
-
- @default {
- <span>{{ getDisplayValue(detail.value) }}</span>
- }
- }
- </div>
- </div>
+ <div [cdsStack]="'horizontal'"
+ [gap]="5">
+ @for (detail of getVisibleDetails(); track detail.label) {
+ <div [cdsStack]="'vertical'"
+ [gap]="2">
+ <label class="cds--type-label-01">{{ detail.label }}</label>
+ <div class="cds--type-body-01">
+ @switch (detail.type) {
+ @case ('status') {
+ <div [cdsStack]="'horizontal'" [gap]="3">
+ <cd-icon [type]="getStatusIcon(detail)"></cd-icon>
+ <span>{{ getDisplayValue(detail.value) }}</span>
+ </div>
+ }
+
+ @default {
+ <span>{{ getDisplayValue(detail.value) }}</span>
+ }
+ }
</div>
- }
</div>
+ }
</div>
}
<ng-content></ng-content>