NIFI-12486: Registry Clients (#8142)

* NIFI-12486:
- Registry Clients.
- General authorization guard.
- Additional authorization checks in the existing Settings tabs.

* NIFI-12486:
- Adding authorization guard to /counters.

* NIFI-12486:
- Enabling some debug build out to attempt to track down a sporadic build failure.

* NIFI-12486:
- Addressing review feedback.

* NIFI-12486:
- Fixing unit test and running prettier.

This closes #8142
This commit is contained in:
Matt Gilman 2023-12-15 10:43:50 -05:00 committed by GitHub
parent 76613a0ed4
commit b0f30d6860
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 2204 additions and 95 deletions

View File

@ -16,7 +16,12 @@
*/
package org.apache.nifi.web.api.entity;
import io.swagger.annotations.ApiModelProperty;
import org.apache.nifi.web.api.dto.util.TimeAdapter;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import java.util.Date;
import java.util.Set;
/**
@ -25,6 +30,7 @@ import java.util.Set;
@XmlRootElement(name = "registryClientsEntity")
public class FlowRegistryClientsEntity extends Entity {
private Date currentTime;
private Set<FlowRegistryClientEntity> registries;
/**
@ -38,4 +44,19 @@ public class FlowRegistryClientsEntity extends Entity {
this.registries = registries;
}
/**
* @return current time on the server
*/
@XmlJavaTypeAdapter(TimeAdapter.class)
@ApiModelProperty(
value = "The current time on the system.",
dataType = "string"
)
public Date getCurrentTime() {
return currentTime;
}
public void setCurrentTime(Date currentTime) {
this.currentTime = currentTime;
}
}

View File

@ -1483,6 +1483,7 @@ public class ControllerResource extends ApplicationResource {
final Set<FlowRegistryClientEntity> flowRegistryClients = serviceFacade.getRegistryClients();
final FlowRegistryClientsEntity flowRegistryClientEntities = new FlowRegistryClientsEntity();
flowRegistryClientEntities.setCurrentTime(new Date());
flowRegistryClientEntities.setRegistries(flowRegistryClients);
return generateOkResponse(populateRemainingRegistryClientEntityContent(flowRegistryClientEntities)).build();

View File

@ -1838,6 +1838,7 @@ public class FlowResource extends ApplicationResource {
final Set<FlowRegistryClientEntity> registryClients = serviceFacade.getRegistryClientsForUser();
final FlowRegistryClientsEntity registryClientEntities = new FlowRegistryClientsEntity();
registryClientEntities.setCurrentTime(new Date());
registryClientEntities.setRegistries(registryClients);
return generateOkResponse(populateRemainingRegistryClientEntityContent(registryClientEntities)).build();

View File

@ -37,7 +37,9 @@
"maximumError": "3mb"
}
],
"outputHashing": "all"
"outputHashing": "all",
"buildOptimizer": false,
"optimization": true
},
"development": {
"buildOptimizer": false,

View File

@ -4,7 +4,7 @@
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build": "ng build --verbose",
"watch": "ng build --watch --configuration development",
"test": "ng test --karma-config=karma.conf.js --watch=false",
"prettier": "prettier --config .prettierrc . --check",

View File

@ -17,7 +17,7 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { authGuard } from './service/guard/auth.guard';
import { authenticationGuard } from './service/guard/authentication.guard';
const routes: Routes = [
{
@ -26,17 +26,17 @@ const routes: Routes = [
},
{
path: 'settings',
canMatch: [authGuard],
canMatch: [authenticationGuard],
loadChildren: () => import('./pages/settings/feature/settings.module').then((m) => m.SettingsModule)
},
{
path: 'provenance',
canMatch: [authGuard],
canMatch: [authenticationGuard],
loadChildren: () => import('./pages/provenance/feature/provenance.module').then((m) => m.ProvenanceModule)
},
{
path: 'parameter-contexts',
canMatch: [authGuard],
canMatch: [authenticationGuard],
loadChildren: () =>
import('./pages/parameter-contexts/feature/parameter-contexts.module').then(
(m) => m.ParameterContextsModule
@ -44,12 +44,12 @@ const routes: Routes = [
},
{
path: 'counters',
canMatch: [authGuard],
canMatch: [authenticationGuard],
loadChildren: () => import('./pages/counters/feature/counters.module').then((m) => m.CountersModule)
},
{
path: '',
canMatch: [authGuard],
canMatch: [authenticationGuard],
loadChildren: () =>
import('./pages/flow-designer/feature/flow-designer.module').then((m) => m.FlowDesignerModule)
}

View File

@ -18,11 +18,14 @@
import { RouterModule, Routes } from '@angular/router';
import { NgModule } from '@angular/core';
import { Counters } from './counters.component';
import { authorizationGuard } from '../../../service/guard/authorization.guard';
import { User } from '../../../state/user';
const routes: Routes = [
{
path: '',
component: Counters
component: Counters,
canMatch: [authorizationGuard((user: User) => user.countersPermissions.canRead)]
}
];

View File

@ -22,6 +22,7 @@ import {
controllerServicesApiError,
createControllerService,
createControllerServiceSuccess,
deleteControllerService,
deleteControllerServiceSuccess,
inlineCreateControllerServiceSuccess,
loadControllerServices,
@ -72,7 +73,7 @@ export const controllerServicesReducer = createReducer(
error,
status: 'error' as const
})),
on(createControllerService, (state, { request }) => ({
on(createControllerService, configureControllerService, deleteControllerService, (state, { request }) => ({
...state,
saving: true
})),
@ -87,10 +88,6 @@ export const controllerServicesReducer = createReducer(
draftState.controllerServices.push(response.controllerService);
});
}),
on(configureControllerService, (state, { request }) => ({
...state,
saving: true
})),
on(configureControllerServiceSuccess, (state, { response }) => {
return produce(state, (draftState) => {
const componentIndex: number = draftState.controllerServices.findIndex((f: any) => response.id === f.id);
@ -108,6 +105,7 @@ export const controllerServicesReducer = createReducer(
if (componentIndex > -1) {
draftState.controllerServices.splice(componentIndex, 1);
}
draftState.saving = false;
});
})
);

View File

@ -90,7 +90,11 @@
Data Provenance
</button>
<mat-divider></mat-divider>
<button mat-menu-item class="global-menu-item" [routerLink]="['/settings']">
<button
mat-menu-item
class="global-menu-item"
[routerLink]="['/settings']"
[disabled]="!user.controllerPermissions.canRead">
<i class="fa fa-fw fa-wrench mr-2"></i>
Controller Settings
</button>

View File

@ -116,7 +116,10 @@ export class ProvenanceEventTable implements AfterViewInit {
totalCount: number = 0;
filteredCount: number = 0;
constructor(private formBuilder: FormBuilder) {
constructor(
private formBuilder: FormBuilder,
private nifiCommon: NiFiCommon
) {
this.filterForm = this.formBuilder.group({ filterTerm: '', filterColumn: this.filterColumnOptions[0] });
}
@ -145,35 +148,34 @@ export class ProvenanceEventTable implements AfterViewInit {
return data.sort((a, b) => {
const isAsc = sort.direction === 'asc';
let retVal: number = 0;
switch (sort.active) {
case 'eventTime':
// event ideas are increasing, so we can use this simple number for sorting purposes
// since we don't surface the timestamp as millis
return (a.eventId - b.eventId) * (isAsc ? 1 : -1);
retVal = this.nifiCommon.compareNumber(a.eventId, b.eventId);
break;
case 'eventType':
return this.compare(a.eventType, b.eventType, isAsc);
retVal = this.nifiCommon.compareString(a.eventType, b.eventType);
break;
case 'flowFileUuid':
return this.compare(a.flowFileUuid, b.flowFileUuid, isAsc);
retVal = this.nifiCommon.compareString(a.flowFileUuid, b.flowFileUuid);
break;
case 'fileSize':
return (a.fileSizeBytes - b.fileSizeBytes) * (isAsc ? 1 : -1);
retVal = this.nifiCommon.compareNumber(a.fileSizeBytes, b.fileSizeBytes);
break;
case 'componentName':
return this.compare(a.componentName, b.componentName, isAsc);
retVal = this.nifiCommon.compareString(a.componentName, b.componentName);
break;
case 'componentType':
return this.compare(a.componentType, b.componentType, isAsc);
default:
return 0;
retVal = this.nifiCommon.compareString(a.componentType, b.componentType);
break;
}
return retVal * (isAsc ? 1 : -1);
});
}
private compare(a: string, b: string, isAsc: boolean): number {
if (a === b) {
return 0;
}
return (a < b ? -1 : 1) * (isAsc ? 1 : -1);
}
applyFilter(filterTerm: string, filterColumn: string) {
this.dataSource.filter = `${filterTerm}|${filterColumn}`;
this.filteredCount = this.dataSource.filteredData.length;

View File

@ -24,11 +24,14 @@ import { ReportingTasks } from '../ui/reporting-tasks/reporting-tasks.component'
import { FlowAnalysisRules } from '../ui/flow-analysis-rules/flow-analysis-rules.component';
import { RegistryClients } from '../ui/registry-clients/registry-clients.component';
import { ParameterProviders } from '../ui/parameter-providers/parameter-providers.component';
import { authorizationGuard } from '../../../service/guard/authorization.guard';
import { User } from '../../../state/user';
const routes: Routes = [
{
path: '',
component: Settings,
canMatch: [authorizationGuard((user: User) => user.controllerPermissions.canRead)],
children: [
{ path: '', pathMatch: 'full', redirectTo: 'general' },
{ path: 'general', component: General },
@ -59,7 +62,22 @@ const routes: Routes = [
]
},
{ path: 'flow-analysis-rules', component: FlowAnalysisRules },
{ path: 'registry-clients', component: RegistryClients },
{
path: 'registry-clients',
component: RegistryClients,
children: [
{
path: ':id',
component: RegistryClients,
children: [
{
path: 'edit',
component: RegistryClients
}
]
}
]
},
{ path: 'parameter-providers', component: ParameterProviders }
]
}

View File

@ -32,6 +32,7 @@ import { RegistryClientsModule } from '../ui/registry-clients/registry-clients.m
import { ReportingTasksModule } from '../ui/reporting-tasks/reporting-tasks.module';
import { MatTabsModule } from '@angular/material/tabs';
import { ReportingTasksEffects } from '../state/reporting-tasks/reporting-tasks.effects';
import { RegistryClientsEffects } from '../state/registry-clients/registry-clients.effects';
@NgModule({
declarations: [Settings],
@ -46,7 +47,12 @@ import { ReportingTasksEffects } from '../state/reporting-tasks/reporting-tasks.
ReportingTasksModule,
SettingsRoutingModule,
StoreModule.forFeature(settingsFeatureKey, reducers),
EffectsModule.forFeature(GeneralEffects, ManagementControllerServicesEffects, ReportingTasksEffects),
EffectsModule.forFeature(
GeneralEffects,
ManagementControllerServicesEffects,
ReportingTasksEffects,
RegistryClientsEffects
),
MatTabsModule
]
})

View File

@ -0,0 +1,80 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { Client } from '../../../service/client.service';
import { NiFiCommon } from '../../../service/nifi-common.service';
import {
CreateRegistryClientRequest,
DeleteRegistryClientRequest,
EditRegistryClientRequest,
RegistryClientEntity
} from '../state/registry-clients';
@Injectable({ providedIn: 'root' })
export class RegistryClientService {
private static readonly API: string = '../nifi-api';
/**
* The NiFi model contain the url for each component. That URL is an absolute URL. Angular CSRF handling
* does not work on absolute URLs, so we need to strip off the proto for the request header to be added.
*
* https://stackoverflow.com/a/59586462
*
* @param url
* @private
*/
private stripProtocol(url: string): string {
return this.nifiCommon.substringAfterFirst(url, ':');
}
constructor(
private httpClient: HttpClient,
private client: Client,
private nifiCommon: NiFiCommon
) {}
getRegistryClients(): Observable<any> {
return this.httpClient.get(`${RegistryClientService.API}/controller/registry-clients`);
}
createRegistryClient(createReportingTask: CreateRegistryClientRequest): Observable<any> {
return this.httpClient.post(`${RegistryClientService.API}/controller/registry-clients`, createReportingTask);
}
getPropertyDescriptor(id: string, propertyName: string, sensitive: boolean): Observable<any> {
const params: any = {
propertyName,
sensitive
};
return this.httpClient.get(`${RegistryClientService.API}/controller/registry-clients/${id}/descriptors`, {
params
});
}
updateRegistryClient(request: EditRegistryClientRequest): Observable<any> {
return this.httpClient.put(this.stripProtocol(request.uri), request.payload);
}
deleteRegistryClient(deleteRegistryClient: DeleteRegistryClientRequest): Observable<any> {
const entity: RegistryClientEntity = deleteRegistryClient.registryClient;
const revision: any = this.client.getRevision(entity);
return this.httpClient.delete(this.stripProtocol(entity.uri), { params: revision });
}
}

View File

@ -15,10 +15,6 @@
* limitations under the License.
*/
/*
Canvas Positioning/Transforms
*/
import { Action, combineReducers, createFeatureSelector } from '@ngrx/store';
import { GeneralState, generalFeatureKey } from './general';
import { generalReducer } from './general/general.reducer';
@ -29,6 +25,8 @@ import {
import { managementControllerServicesReducer } from './management-controller-services/management-controller-services.reducer';
import { reportingTasksFeatureKey, ReportingTasksState } from './reporting-tasks';
import { reportingTasksReducer } from './reporting-tasks/reporting-tasks.reducer';
import { registryClientsFeatureKey, RegistryClientsState } from './registry-clients';
import { registryClientsReducer } from './registry-clients/registry-clients.reducer';
export const settingsFeatureKey = 'settings';
@ -36,13 +34,15 @@ export interface SettingsState {
[generalFeatureKey]: GeneralState;
[managementControllerServicesFeatureKey]: ManagementControllerServicesState;
[reportingTasksFeatureKey]: ReportingTasksState;
[registryClientsFeatureKey]: RegistryClientsState;
}
export function reducers(state: SettingsState | undefined, action: Action) {
return combineReducers({
[generalFeatureKey]: generalReducer,
[managementControllerServicesFeatureKey]: managementControllerServicesReducer,
[reportingTasksFeatureKey]: reportingTasksReducer
[reportingTasksFeatureKey]: reportingTasksReducer,
[registryClientsFeatureKey]: registryClientsReducer
})(state, action);
}

View File

@ -18,7 +18,7 @@
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as ManagementControllerServicesActions from './management-controller-services.actions';
import { catchError, from, map, NEVER, Observable, of, switchMap, take, tap, withLatestFrom } from 'rxjs';
import { catchError, from, map, NEVER, Observable, of, switchMap, take, takeUntil, tap, withLatestFrom } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
import { Store } from '@ngrx/store';
@ -139,15 +139,23 @@ export class ManagementControllerServicesEffects {
)
);
createControllerServiceSuccess$ = createEffect(
() =>
this.actions$.pipe(
ofType(ManagementControllerServicesActions.createControllerServiceSuccess),
tap(() => {
this.dialog.closeAll();
})
),
{ dispatch: false }
createControllerServiceSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(ManagementControllerServicesActions.createControllerServiceSuccess),
map((action) => action.response),
tap(() => {
this.dialog.closeAll();
}),
switchMap((response) =>
of(
ManagementControllerServicesActions.selectControllerService({
request: {
id: response.controllerService.id
}
})
)
)
)
);
navigateToEditService$ = createEffect(
@ -294,7 +302,7 @@ export class ManagementControllerServicesEffects {
};
editDialogReference.componentInstance.editControllerService
.pipe(take(1))
.pipe(takeUntil(editDialogReference.afterClosed()))
.subscribe((payload: any) => {
this.store.dispatch(
ManagementControllerServicesActions.configureControllerService({

View File

@ -22,6 +22,7 @@ import {
configureControllerServiceSuccess,
createControllerService,
createControllerServiceSuccess,
deleteControllerService,
deleteControllerServiceSuccess,
inlineCreateControllerServiceSuccess,
loadManagementControllerServices,
@ -57,7 +58,7 @@ export const managementControllerServicesReducer = createReducer(
error,
status: 'error' as const
})),
on(createControllerService, (state, { request }) => ({
on(createControllerService, configureControllerService, deleteControllerService, (state, { request }) => ({
...state,
saving: true
})),
@ -72,10 +73,6 @@ export const managementControllerServicesReducer = createReducer(
draftState.controllerServices.push(response.controllerService);
});
}),
on(configureControllerService, (state, { request }) => ({
...state,
saving: true
})),
on(configureControllerServiceSuccess, (state, { response }) => {
return produce(state, (draftState) => {
const componentIndex: number = draftState.controllerServices.findIndex((f: any) => response.id === f.id);
@ -93,6 +90,7 @@ export const managementControllerServicesReducer = createReducer(
if (componentIndex > -1) {
draftState.controllerServices.splice(componentIndex, 1);
}
draftState.saving = false;
});
})
);

View File

@ -0,0 +1,87 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BulletinEntity, DocumentedType, Permissions, Revision } from '../../../../state/shared';
export const registryClientsFeatureKey = 'registryClients';
export interface CreateRegistryClientDialogRequest {
registryClientTypes: DocumentedType[];
}
export interface LoadRegistryClientsResponse {
registryClients: RegistryClientEntity[];
loadedTimestamp: string;
}
export interface CreateRegistryClientRequest {
revision: Revision;
component: {
name: string;
type: string;
description?: string;
};
}
export interface CreateRegistryClientSuccess {
registryClient: RegistryClientEntity;
}
export interface EditRegistryClientDialogRequest {
registryClient: RegistryClientEntity;
}
export interface EditRegistryClientRequest {
id: string;
uri: string;
payload: any;
}
export interface EditRegistryClientRequestSuccess {
id: string;
registryClient: RegistryClientEntity;
}
export interface DeleteRegistryClientRequest {
registryClient: RegistryClientEntity;
}
export interface DeleteRegistryClientSuccess {
registryClient: RegistryClientEntity;
}
export interface SelectRegistryClientRequest {
id: string;
}
export interface RegistryClientEntity {
permissions: Permissions;
operatePermissions?: Permissions;
revision: Revision;
bulletins?: BulletinEntity[];
id: string;
uri: string;
component: any;
}
export interface RegistryClientsState {
registryClients: RegistryClientEntity[];
saving: boolean;
loadedTimestamp: string;
error: string | null;
status: 'pending' | 'loading' | 'error' | 'success';
}

View File

@ -0,0 +1,93 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createAction, props } from '@ngrx/store';
import {
CreateRegistryClientRequest,
CreateRegistryClientSuccess,
DeleteRegistryClientRequest,
DeleteRegistryClientSuccess,
EditRegistryClientDialogRequest,
EditRegistryClientRequest,
EditRegistryClientRequestSuccess,
LoadRegistryClientsResponse,
SelectRegistryClientRequest
} from './index';
export const loadRegistryClients = createAction('[Registry Clients] Load Registry Clients');
export const loadRegistryClientsSuccess = createAction(
'[Registry Clients] Load Registry Clients Success',
props<{ response: LoadRegistryClientsResponse }>()
);
export const registryClientsApiError = createAction(
'[Registry Clients] Load Registry Clients Error',
props<{ error: string }>()
);
export const openNewRegistryClientDialog = createAction('[Registry Clients] Open New Registry Client Dialog');
export const createRegistryClient = createAction(
'[Registry Clients] Create Registry Client',
props<{ request: CreateRegistryClientRequest }>()
);
export const createRegistryClientSuccess = createAction(
'[Registry Clients] Create Registry Client Success',
props<{ response: CreateRegistryClientSuccess }>()
);
export const navigateToEditRegistryClient = createAction(
'[Registry Clients] Navigate To Edit Registry Client',
props<{ id: string }>()
);
export const openConfigureRegistryClientDialog = createAction(
'[Registry Clients] Open Configure Registry Client Dialog',
props<{ request: EditRegistryClientDialogRequest }>()
);
export const configureRegistryClient = createAction(
'[Registry Clients] Configure Registry Client',
props<{ request: EditRegistryClientRequest }>()
);
export const configureRegistryClientSuccess = createAction(
'[Registry Clients] Configure Registry Client Success',
props<{ response: EditRegistryClientRequestSuccess }>()
);
export const promptRegistryClientDeletion = createAction(
'[Registry Clients] Prompt Registry Client Deletion',
props<{ request: DeleteRegistryClientRequest }>()
);
export const deleteRegistryClient = createAction(
'[Registry Clients] Delete Registry Client',
props<{ request: DeleteRegistryClientRequest }>()
);
export const deleteRegistryClientSuccess = createAction(
'[Registry Clients] Delete Registry Client Success',
props<{ response: DeleteRegistryClientSuccess }>()
);
export const selectClient = createAction(
'[Registry Clients] Select Registry Client',
props<{ request: SelectRegistryClientRequest }>()
);

View File

@ -0,0 +1,422 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as RegistryClientsActions from './registry-clients.actions';
import { catchError, from, map, NEVER, Observable, of, switchMap, take, takeUntil, tap, withLatestFrom } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../../state';
import { selectRegistryClientTypes } from '../../../../state/extension-types/extension-types.selectors';
import { YesNoDialog } from '../../../../ui/common/yes-no-dialog/yes-no-dialog.component';
import { Router } from '@angular/router';
import { RegistryClientService } from '../../service/registry-client.service';
import { CreateRegistryClient } from '../../ui/registry-clients/create-registry-client/create-registry-client.component';
import { selectSaving } from './registry-clients.selectors';
import { EditRegistryClient } from '../../ui/registry-clients/edit-registry-client/edit-registry-client.component';
import {
InlineServiceCreationRequest,
InlineServiceCreationResponse,
NewPropertyDialogRequest,
NewPropertyDialogResponse,
Property,
PropertyDescriptor
} from '../../../../state/shared';
import { NewPropertyDialog } from '../../../../ui/common/new-property-dialog/new-property-dialog.component';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
import { CreateControllerService } from '../../../../ui/common/controller-service/create-controller-service/create-controller-service.component';
import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
import { Client } from '../../../../service/client.service';
@Injectable()
export class RegistryClientsEffects {
constructor(
private actions$: Actions,
private store: Store<NiFiState>,
private client: Client,
private registryClientService: RegistryClientService,
private extensionTypesService: ExtensionTypesService,
private managementControllerServiceService: ManagementControllerServiceService,
private dialog: MatDialog,
private router: Router
) {}
loadRegistryClients$ = createEffect(() =>
this.actions$.pipe(
ofType(RegistryClientsActions.loadRegistryClients),
switchMap(() =>
from(this.registryClientService.getRegistryClients()).pipe(
map((response) =>
RegistryClientsActions.loadRegistryClientsSuccess({
response: {
registryClients: response.registries,
loadedTimestamp: response.currentTime
}
})
),
catchError((error) =>
of(
RegistryClientsActions.registryClientsApiError({
error: error.error
})
)
)
)
)
)
);
openNewRegistryClientDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(RegistryClientsActions.openNewRegistryClientDialog),
withLatestFrom(this.store.select(selectRegistryClientTypes)),
tap(([action, registryClientTypes]) => {
const dialogReference = this.dialog.open(CreateRegistryClient, {
data: {
registryClientTypes
},
panelClass: 'medium-dialog'
});
dialogReference.componentInstance.saving$ = this.store.select(selectSaving);
dialogReference.componentInstance.createRegistryClient.pipe(take(1)).subscribe((request) => {
this.store.dispatch(
RegistryClientsActions.createRegistryClient({
request
})
);
});
})
),
{ dispatch: false }
);
createRegistryClient$ = createEffect(() =>
this.actions$.pipe(
ofType(RegistryClientsActions.createRegistryClient),
map((action) => action.request),
switchMap((request) =>
from(this.registryClientService.createRegistryClient(request)).pipe(
map((response) =>
RegistryClientsActions.createRegistryClientSuccess({
response: {
registryClient: response
}
})
),
catchError((error) =>
of(
RegistryClientsActions.registryClientsApiError({
error: error.error
})
)
)
)
)
)
);
createRegistryClientSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(RegistryClientsActions.createRegistryClientSuccess),
map((action) => action.response),
tap(() => {
this.dialog.closeAll();
}),
switchMap((response) =>
of(
RegistryClientsActions.selectClient({
request: {
id: response.registryClient.id
}
})
)
)
)
);
navigateToEditRegistryClient$ = createEffect(
() =>
this.actions$.pipe(
ofType(RegistryClientsActions.navigateToEditRegistryClient),
map((action) => action.id),
tap((id) => {
this.router.navigate(['/settings', 'registry-clients', id, 'edit']);
})
),
{ dispatch: false }
);
openConfigureControllerServiceDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(RegistryClientsActions.openConfigureRegistryClientDialog),
map((action) => action.request),
tap((request) => {
const registryClientId: string = request.registryClient.id;
const editDialogReference = this.dialog.open(EditRegistryClient, {
data: request,
panelClass: 'large-dialog'
});
editDialogReference.componentInstance.saving$ = this.store.select(selectSaving);
editDialogReference.componentInstance.createNewProperty = (
existingProperties: string[],
allowsSensitive: boolean
): Observable<Property> => {
const dialogRequest: NewPropertyDialogRequest = { existingProperties, allowsSensitive };
const newPropertyDialogReference = this.dialog.open(NewPropertyDialog, {
data: dialogRequest,
panelClass: 'small-dialog'
});
return newPropertyDialogReference.componentInstance.newProperty.pipe(
take(1),
switchMap((dialogResponse: NewPropertyDialogResponse) => {
return this.registryClientService
.getPropertyDescriptor(
registryClientId,
dialogResponse.name,
dialogResponse.sensitive
)
.pipe(
take(1),
map((response) => {
newPropertyDialogReference.close();
return {
property: dialogResponse.name,
value: null,
descriptor: response.propertyDescriptor
};
})
);
})
);
};
editDialogReference.componentInstance.getServiceLink = (serviceId: string) => {
return of(['/settings', 'management-controller-services', serviceId]);
};
editDialogReference.componentInstance.createNewService = (
request: InlineServiceCreationRequest
): Observable<InlineServiceCreationResponse> => {
const descriptor: PropertyDescriptor = request.descriptor;
// fetch all services that implement the requested service api
return this.extensionTypesService
.getImplementingControllerServiceTypes(
// @ts-ignore
descriptor.identifiesControllerService,
descriptor.identifiesControllerServiceBundle
)
.pipe(
take(1),
switchMap((implementingTypesResponse) => {
// show the create controller service dialog with the types that implemented the interface
const createServiceDialogReference = this.dialog.open(CreateControllerService, {
data: {
controllerServiceTypes: implementingTypesResponse.controllerServiceTypes
},
panelClass: 'medium-dialog'
});
return createServiceDialogReference.componentInstance.createControllerService.pipe(
take(1),
switchMap((controllerServiceType) => {
// typically this sequence would be implemented with ngrx actions, however we are
// currently in an edit session and we need to return both the value (new service id)
// and updated property descriptor so the table renders correctly
return this.managementControllerServiceService
.createControllerService({
revision: {
clientId: this.client.getClientId(),
version: 0
},
controllerServiceType: controllerServiceType.type,
controllerServiceBundle: controllerServiceType.bundle
})
.pipe(
take(1),
switchMap((createReponse) => {
// fetch an updated property descriptor
return this.registryClientService
.getPropertyDescriptor(
registryClientId,
descriptor.name,
false
)
.pipe(
take(1),
map((descriptorResponse) => {
createServiceDialogReference.close();
return {
value: createReponse.id,
descriptor:
descriptorResponse.propertyDescriptor
};
})
);
}),
catchError((error) => {
// TODO - show error
return NEVER;
})
);
})
);
})
);
};
editDialogReference.componentInstance.editRegistryClient
.pipe(takeUntil(editDialogReference.afterClosed()))
.subscribe((payload: any) => {
this.store.dispatch(
RegistryClientsActions.configureRegistryClient({
request: {
id: registryClientId,
uri: request.registryClient.uri,
payload
}
})
);
});
editDialogReference.afterClosed().subscribe((response) => {
if (response != 'ROUTED') {
this.store.dispatch(
RegistryClientsActions.selectClient({
request: {
id: registryClientId
}
})
);
}
});
})
),
{ dispatch: false }
);
configureControllerService$ = createEffect(() =>
this.actions$.pipe(
ofType(RegistryClientsActions.configureRegistryClient),
map((action) => action.request),
switchMap((request) =>
from(this.registryClientService.updateRegistryClient(request)).pipe(
map((response) =>
RegistryClientsActions.configureRegistryClientSuccess({
response: {
id: request.id,
registryClient: response
}
})
),
catchError((error) =>
of(
RegistryClientsActions.registryClientsApiError({
error: error.error
})
)
)
)
)
)
);
configureRegistryClientSuccess = createEffect(
() =>
this.actions$.pipe(
ofType(RegistryClientsActions.configureRegistryClientSuccess),
tap(() => {
this.dialog.closeAll();
})
),
{ dispatch: false }
);
promptRegistryClientDeletion$ = createEffect(
() =>
this.actions$.pipe(
ofType(RegistryClientsActions.promptRegistryClientDeletion),
map((action) => action.request),
tap((request) => {
const dialogReference = this.dialog.open(YesNoDialog, {
data: {
title: 'Delete Registry Client',
message: `Delete registry client ${request.registryClient.component.name}?`
},
panelClass: 'small-dialog'
});
dialogReference.componentInstance.yes.pipe(take(1)).subscribe(() => {
this.store.dispatch(
RegistryClientsActions.deleteRegistryClient({
request
})
);
});
})
),
{ dispatch: false }
);
deleteRegistryClient$ = createEffect(() =>
this.actions$.pipe(
ofType(RegistryClientsActions.deleteRegistryClient),
map((action) => action.request),
switchMap((request) =>
from(this.registryClientService.deleteRegistryClient(request)).pipe(
map((response) =>
RegistryClientsActions.deleteRegistryClientSuccess({
response: {
registryClient: response
}
})
),
catchError((error) =>
of(
RegistryClientsActions.registryClientsApiError({
error: error.error
})
)
)
)
)
)
);
selectRegistryClient$ = createEffect(
() =>
this.actions$.pipe(
ofType(RegistryClientsActions.selectClient),
map((action) => action.request),
tap((request) => {
this.router.navigate(['/settings', 'registry-clients', request.id]);
})
),
{ dispatch: false }
);
}

View File

@ -0,0 +1,90 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createReducer, on } from '@ngrx/store';
import { produce } from 'immer';
import { RegistryClientsState } from './index';
import {
configureRegistryClient,
configureRegistryClientSuccess,
createRegistryClient,
createRegistryClientSuccess,
deleteRegistryClient,
deleteRegistryClientSuccess,
loadRegistryClients,
loadRegistryClientsSuccess,
registryClientsApiError
} from './registry-clients.actions';
export const initialState: RegistryClientsState = {
registryClients: [],
saving: false,
loadedTimestamp: '',
error: null,
status: 'pending'
};
export const registryClientsReducer = createReducer(
initialState,
on(loadRegistryClients, (state) => ({
...state,
status: 'loading' as const
})),
on(loadRegistryClientsSuccess, (state, { response }) => ({
...state,
registryClients: response.registryClients,
loadedTimestamp: response.loadedTimestamp,
error: null,
status: 'success' as const
})),
on(registryClientsApiError, (state, { error }) => ({
...state,
saving: false,
error,
status: 'error' as const
})),
on(createRegistryClient, configureRegistryClient, deleteRegistryClient, (state, { request }) => ({
...state,
saving: true
})),
on(createRegistryClientSuccess, (state, { response }) => {
return produce(state, (draftState) => {
draftState.registryClients.push(response.registryClient);
draftState.saving = false;
});
}),
on(configureRegistryClientSuccess, (state, { response }) => {
return produce(state, (draftState) => {
const componentIndex: number = draftState.registryClients.findIndex((f: any) => response.id === f.id);
if (componentIndex > -1) {
draftState.registryClients[componentIndex] = response.registryClient;
}
draftState.saving = false;
});
}),
on(deleteRegistryClientSuccess, (state, { response }) => {
return produce(state, (draftState) => {
const componentIndex: number = draftState.registryClients.findIndex(
(f: any) => response.registryClient.id === f.id
);
if (componentIndex > -1) {
draftState.registryClients.splice(componentIndex, 1);
}
draftState.saving = false;
});
})
);

View File

@ -0,0 +1,53 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createSelector } from '@ngrx/store';
import { selectSettingsState, SettingsState } from '../index';
import { selectCurrentRoute } from '../../../../state/router/router.selectors';
import { RegistryClientEntity, registryClientsFeatureKey, RegistryClientsState } from './index';
export const selectRegistryClientsState = createSelector(
selectSettingsState,
(state: SettingsState) => state[registryClientsFeatureKey]
);
export const selectSaving = createSelector(selectRegistryClientsState, (state: RegistryClientsState) => state.saving);
export const selectRegistryClientIdFromRoute = createSelector(selectCurrentRoute, (route) => {
if (route) {
// always select the registry client from the route
return route.params.id;
}
return null;
});
export const selectSingleEditedRegistryClient = createSelector(selectCurrentRoute, (route) => {
if (route?.routeConfig?.path == 'edit') {
return route.params.id;
}
return null;
});
export const selectRegistryClients = createSelector(
selectRegistryClientsState,
(state: RegistryClientsState) => state.registryClients
);
export const selectRegistryClient = (id: string) =>
createSelector(selectRegistryClients, (entities: RegistryClientEntity[]) =>
entities.find((entity) => id == entity.id)
);

View File

@ -105,15 +105,23 @@ export class ReportingTasksEffects {
)
);
createReportingTaskSuccess$ = createEffect(
() =>
this.actions$.pipe(
ofType(ReportingTaskActions.createReportingTaskSuccess),
tap(() => {
this.dialog.closeAll();
})
),
{ dispatch: false }
createReportingTaskSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(ReportingTaskActions.createReportingTaskSuccess),
map((action) => action.response),
tap(() => {
this.dialog.closeAll();
}),
switchMap((response) =>
of(
ReportingTaskActions.selectReportingTask({
request: {
reportingTask: response.reportingTask
}
})
)
)
)
);
promptReportingTaskDeletion$ = createEffect(
@ -124,8 +132,8 @@ export class ReportingTasksEffects {
tap((request) => {
const dialogReference = this.dialog.open(YesNoDialog, {
data: {
title: 'Delete Controller Service',
message: `Delete controller service ${request.reportingTask.component.name}?`
title: 'Delete Reporting Task',
message: `Delete reporting task ${request.reportingTask.component.name}?`
},
panelClass: 'small-dialog'
});

View File

@ -20,6 +20,7 @@ import { ReportingTasksState } from './index';
import {
createReportingTask,
createReportingTaskSuccess,
deleteReportingTask,
deleteReportingTaskSuccess,
loadReportingTasks,
loadReportingTasksSuccess,
@ -56,7 +57,7 @@ export const reportingTasksReducer = createReducer(
error,
status: 'error' as const
})),
on(createReportingTask, (state, { request }) => ({
on(createReportingTask, deleteReportingTask, (state, { request }) => ({
...state,
saving: true
})),
@ -94,6 +95,7 @@ export const reportingTasksReducer = createReducer(
if (componentIndex > -1) {
draftState.reportingTasks.splice(componentIndex, 1);
}
draftState.saving = false;
});
})
);

View File

@ -16,14 +16,18 @@
-->
<div class="general-form w-96">
<form [formGroup]="controllerForm">
<form [formGroup]="controllerForm" *ngIf="currentUser$ | async; let currentUser">
<div>
<mat-form-field>
<mat-label>Maximum Timer Driven Thread Count</mat-label>
<input matInput formControlName="timerDrivenThreadCount" type="text" />
<input
matInput
formControlName="timerDrivenThreadCount"
[readonly]="!currentUser.controllerPermissions.canWrite"
type="text" />
</mat-form-field>
</div>
<div>
<div *ngIf="currentUser.controllerPermissions.canWrite">
<button
[disabled]="!controllerForm.dirty || controllerForm.invalid"
type="button"

View File

@ -21,6 +21,7 @@ import { ControllerEntity, GeneralState, UpdateControllerConfigRequest } from '.
import { Store } from '@ngrx/store';
import { updateControllerConfig } from '../../../state/general/general.actions';
import { Client } from '../../../../../service/client.service';
import { selectUser } from '../../../../../state/user/user.selectors';
@Component({
selector: 'general-form',
@ -35,6 +36,7 @@ export class GeneralForm {
this.controllerForm.get('timerDrivenThreadCount')?.setValue(controller.component.maxTimerDrivenThreadCount);
}
currentUser$ = this.store.select(selectUser);
controllerForm: FormGroup;
constructor(

View File

@ -20,8 +20,8 @@
<ngx-skeleton-loader count="3"></ngx-skeleton-loader>
</div>
<ng-template #loaded>
<div class="flex flex-col h-full gap-y-2">
<div class="flex justify-end">
<div class="flex flex-col h-full gap-y-2" *ngIf="currentUser$ | async; let currentUser">
<div class="flex justify-end" *ngIf="currentUser.controllerPermissions.canWrite">
<button class="nifi-button" (click)="openNewControllerServiceDialog()">
<i class="fa fa-plus"></i>
</button>

View File

@ -36,6 +36,8 @@ import { ControllerServiceEntity } from '../../../../state/shared';
import { initialState } from '../../state/management-controller-services/management-controller-services.reducer';
import { filter, switchMap, take } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { selectUser } from '../../../../state/user/user.selectors';
import { NiFiState } from '../../../../state';
@Component({
selector: 'management-controller-services',
@ -45,8 +47,9 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class ManagementControllerServices implements OnInit {
serviceState$ = this.store.select(selectManagementControllerServicesState);
selectedServiceId$ = this.store.select(selectControllerServiceIdFromRoute);
currentUser$ = this.store.select(selectUser);
constructor(private store: Store<ManagementControllerServicesState>) {
constructor(private store: Store<NiFiState>) {
this.store
.select(selectSingleEditedService)
.pipe(

View File

@ -0,0 +1,69 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<h2 mat-dialog-title>Add Registry Client</h2>
<form class="create-registry-client-form" [formGroup]="createRegistryClientForm">
<mat-dialog-content>
<div>
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput formControlName="name" type="text" />
</mat-form-field>
</div>
<div>
<mat-form-field>
<mat-label>Type</mat-label>
<mat-select formControlName="type">
<ng-container *ngFor="let option of request.registryClientTypes">
<ng-container *ngIf="option.description; else noDescription">
<mat-option
[value]="option.type"
nifiTooltip
[tooltipComponentType]="TextTip"
[tooltipInputData]="getOptionTipData(option)"
[delayClose]="false">
{{ formatType(option) }}
</mat-option>
</ng-container>
<ng-template #noDescription>
<mat-option [value]="option.type">
{{ formatType(option) }}
</mat-option>
</ng-template>
</ng-container>
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field>
<mat-label>Description</mat-label>
<textarea matInput formControlName="description" type="text"></textarea>
</mat-form-field>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" *ngIf="{ value: (saving$ | async)! } as saving">
<button color="accent" mat-raised-button mat-dialog-close>Cancel</button>
<button
[disabled]="!createRegistryClientForm.dirty || createRegistryClientForm.invalid || saving.value"
type="button"
color="primary"
(click)="createRegistryClientClicked()"
mat-raised-button>
<span *nifiSpinner="saving.value">Apply</span>
</button>
</mat-dialog-actions>
</form>

View File

@ -0,0 +1,26 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@use '@angular/material' as mat;
.create-registry-client-form {
@include mat.button-density(-1);
.mat-mdc-form-field {
width: 100%;
}
}

View File

@ -0,0 +1,57 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { CreateRegistryClient } from './create-registry-client.component';
import { CreateRegistryClientDialogRequest } from '../../../state/registry-clients';
describe('CreateRegistryClient', () => {
let component: CreateRegistryClient;
let fixture: ComponentFixture<CreateRegistryClient>;
const data: CreateRegistryClientDialogRequest = {
registryClientTypes: [
{
type: 'org.apache.nifi.registry.flow.NifiRegistryFlowRegistryClient',
bundle: {
group: 'org.apache.nifi',
artifact: 'nifi-flow-registry-client-nar',
version: '2.0.0-SNAPSHOT'
},
restricted: false,
tags: []
}
]
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CreateRegistryClient, BrowserAnimationsModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }]
});
fixture = TestBed.createComponent(CreateRegistryClient);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,108 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Inject, Input, Output } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatButtonModule } from '@angular/material/button';
import { AsyncPipe, NgForOf, NgIf } from '@angular/common';
import { Observable } from 'rxjs';
import { DocumentedType, TextTipInput } from '../../../../../state/shared';
import { CreateRegistryClientDialogRequest, CreateRegistryClientRequest } from '../../../state/registry-clients';
import { NifiSpinnerDirective } from '../../../../../ui/common/spinner/nifi-spinner.directive';
import { Client } from '../../../../../service/client.service';
import { MatSelectModule } from '@angular/material/select';
import { NifiTooltipDirective } from '../../../../../ui/common/tooltips/nifi-tooltip.directive';
import { TextTip } from '../../../../../ui/common/tooltips/text-tip/text-tip.component';
import { NiFiCommon } from '../../../../../service/nifi-common.service';
@Component({
selector: 'create-registry-client',
standalone: true,
templateUrl: './create-registry-client.component.html',
imports: [
ReactiveFormsModule,
MatDialogModule,
MatInputModule,
MatCheckboxModule,
MatButtonModule,
NgIf,
AsyncPipe,
NifiSpinnerDirective,
MatSelectModule,
NgForOf,
NifiTooltipDirective
],
styleUrls: ['./create-registry-client.component.scss']
})
export class CreateRegistryClient {
@Input() saving$!: Observable<boolean>;
@Output() createRegistryClient: EventEmitter<CreateRegistryClientRequest> =
new EventEmitter<CreateRegistryClientRequest>();
protected readonly TextTip = TextTip;
createRegistryClientForm: FormGroup;
constructor(
@Inject(MAT_DIALOG_DATA) public request: CreateRegistryClientDialogRequest,
private formBuilder: FormBuilder,
private nifiCommon: NiFiCommon,
private client: Client
) {
let type: string | null = null;
if (request.registryClientTypes.length > 0) {
type = request.registryClientTypes[0].type;
}
// build the form
this.createRegistryClientForm = this.formBuilder.group({
name: new FormControl('', Validators.required),
type: new FormControl(type, Validators.required),
description: new FormControl('')
});
}
formatType(option: DocumentedType): string {
return this.nifiCommon.substringAfterLast(option.type, '.');
}
getOptionTipData(option: DocumentedType): TextTipInput {
return {
// @ts-ignore
text: option.description
};
}
createRegistryClientClicked() {
const request: CreateRegistryClientRequest = {
revision: {
clientId: this.client.getClientId(),
version: 0
},
component: {
name: this.createRegistryClientForm.get('name')?.value,
type: this.createRegistryClientForm.get('type')?.value,
description: this.createRegistryClientForm.get('description')?.value
}
};
this.createRegistryClient.next(request);
}
}

View File

@ -0,0 +1,72 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<h2 mat-dialog-title>Edit Registry Client</h2>
<form class="edit-registry-client-form" [formGroup]="editRegistryClientForm">
<mat-dialog-content>
<mat-tab-group>
<mat-tab label="Settings">
<div class="tab-content py-4 flex flex-col">
<div class="flex flex-col mb-5">
<div>Id</div>
<div class="value">{{ request.registryClient.id }}</div>
</div>
<div>
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput formControlName="name" type="text" />
</mat-form-field>
</div>
<div class="flex flex-col mb-5">
<div>Id</div>
<div class="value">{{ request.registryClient.component.type }}</div>
</div>
<div>
<mat-form-field>
<mat-label>Description</mat-label>
<textarea matInput formControlName="description" type="text"></textarea>
</mat-form-field>
</div>
</div>
</mat-tab>
<mat-tab label="Properties">
<div class="tab-content py-4">
<property-table
formControlName="properties"
[createNewProperty]="createNewProperty"
[createNewService]="createNewService"
[getParameters]="getParameters"
[getServiceLink]="getServiceLink"
[supportsSensitiveDynamicProperties]="
request.registryClient.component.supportsSensitiveDynamicProperties
"></property-table>
</div>
</mat-tab>
</mat-tab-group>
</mat-dialog-content>
<mat-dialog-actions align="end" *ngIf="{ value: (saving$ | async)! } as saving">
<button color="accent" mat-raised-button mat-dialog-close>Cancel</button>
<button
[disabled]="!editRegistryClientForm.dirty || editRegistryClientForm.invalid || saving.value"
type="button"
color="primary"
(click)="createRegistryClientClicked()"
mat-raised-button>
<span *nifiSpinner="saving.value">Apply</span>
</button>
</mat-dialog-actions>
</form>

View File

@ -0,0 +1,36 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@use '@angular/material' as mat;
.edit-registry-client-form {
@include mat.button-density(-1);
.mdc-dialog__content {
padding: 0 16px;
font-size: 14px;
.tab-content {
height: 475px;
overflow-y: auto;
}
}
.mat-mdc-form-field {
width: 100%;
}
}

View File

@ -0,0 +1,118 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { EditRegistryClient } from './edit-registry-client.component';
import { EditRegistryClientDialogRequest } from '../../../state/registry-clients';
describe('EditRegistryClient', () => {
let component: EditRegistryClient;
let fixture: ComponentFixture<EditRegistryClient>;
const data: EditRegistryClientDialogRequest = {
registryClient: {
revision: {
clientId: 'fdbbc975-5fd5-4dbf-9308-432d75d20c04',
version: 2
},
id: '454cab42-018c-1000-6f9f-3603643c504c',
uri: 'https://localhost:4200/nifi-api/controller/registry-clients/454cab42-018c-1000-6f9f-3603643c504c',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: '454cab42-018c-1000-6f9f-3603643c504c',
name: 'registry',
description: '',
type: 'org.apache.nifi.registry.flow.NifiRegistryFlowRegistryClient',
bundle: {
group: 'org.apache.nifi',
artifact: 'nifi-flow-registry-client-nar',
version: '2.0.0-SNAPSHOT'
},
properties: {
url: null,
'ssl-context-service': null
},
descriptors: {
url: {
name: 'url',
displayName: 'URL',
description: 'URL of the NiFi Registry',
required: true,
sensitive: false,
dynamic: false,
supportsEl: false,
expressionLanguageScope: 'Not Supported',
dependencies: []
},
'ssl-context-service': {
name: 'ssl-context-service',
displayName: 'SSL Context Service',
description: 'Specifies the SSL Context Service to use for communicating with NiFiRegistry',
allowableValues: [
{
allowableValue: {
displayName: 'StandardRestrictedSSLContextService',
value: '45b3d2bf-018c-1000-f99a-fd441220c9d7'
},
canRead: true
}
],
required: false,
sensitive: false,
dynamic: false,
supportsEl: false,
expressionLanguageScope: 'Not Supported',
identifiesControllerService: 'org.apache.nifi.ssl.SSLContextService',
identifiesControllerServiceBundle: {
group: 'org.apache.nifi',
artifact: 'nifi-standard-services-api-nar',
version: '2.0.0-SNAPSHOT'
},
dependencies: []
}
},
supportsSensitiveDynamicProperties: false,
restricted: false,
deprecated: false,
validationErrors: ["'URL' is invalid because URL is required"],
validationStatus: 'INVALID',
multipleVersionsAvailable: false,
extensionMissing: false
}
}
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [EditRegistryClient, BrowserAnimationsModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }]
});
fixture = TestBed.createComponent(EditRegistryClient);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,136 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Inject, Input, Output } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { AbstractControl, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatButtonModule } from '@angular/material/button';
import { AsyncPipe, NgForOf, NgIf } from '@angular/common';
import { Observable } from 'rxjs';
import {
DocumentedType,
InlineServiceCreationRequest,
InlineServiceCreationResponse,
Parameter,
Property,
TextTipInput
} from '../../../../../state/shared';
import { EditRegistryClientDialogRequest } from '../../../state/registry-clients';
import { NifiSpinnerDirective } from '../../../../../ui/common/spinner/nifi-spinner.directive';
import { Client } from '../../../../../service/client.service';
import { MatSelectModule } from '@angular/material/select';
import { NifiTooltipDirective } from '../../../../../ui/common/tooltips/nifi-tooltip.directive';
import { TextTip } from '../../../../../ui/common/tooltips/text-tip/text-tip.component';
import { NiFiCommon } from '../../../../../service/nifi-common.service';
import { MatTabsModule } from '@angular/material/tabs';
import { PropertyTable } from '../../../../../ui/common/property-table/property-table.component';
@Component({
selector: 'edit-registry-client',
standalone: true,
templateUrl: './edit-registry-client.component.html',
imports: [
ReactiveFormsModule,
MatDialogModule,
MatInputModule,
MatCheckboxModule,
MatButtonModule,
NgIf,
AsyncPipe,
NifiSpinnerDirective,
MatSelectModule,
NgForOf,
NifiTooltipDirective,
MatTabsModule,
PropertyTable
],
styleUrls: ['./edit-registry-client.component.scss']
})
export class EditRegistryClient {
@Input() createNewProperty!: (existingProperties: string[], allowsSensitive: boolean) => Observable<Property>;
@Input() createNewService!: (request: InlineServiceCreationRequest) => Observable<InlineServiceCreationResponse>;
@Input() getParameters!: (sensitive: boolean) => Observable<Parameter[]>;
@Input() getServiceLink!: (serviceId: string) => Observable<string[]>;
@Input() saving$!: Observable<boolean>;
@Output() editRegistryClient: EventEmitter<any> = new EventEmitter<any>();
protected readonly TextTip = TextTip;
editRegistryClientForm: FormGroup;
constructor(
@Inject(MAT_DIALOG_DATA) public request: EditRegistryClientDialogRequest,
private formBuilder: FormBuilder,
private nifiCommon: NiFiCommon,
private client: Client
) {
const serviceProperties: any = request.registryClient.component.properties;
const properties: Property[] = Object.entries(serviceProperties).map((entry: any) => {
const [property, value] = entry;
return {
property,
value,
descriptor: request.registryClient.component.descriptors[property]
};
});
// build the form
this.editRegistryClientForm = this.formBuilder.group({
name: new FormControl(request.registryClient.component.name, Validators.required),
description: new FormControl(request.registryClient.component.description),
properties: new FormControl(properties)
});
}
formatType(option: DocumentedType): string {
return this.nifiCommon.substringAfterLast(option.type, '.');
}
getOptionTipData(option: DocumentedType): TextTipInput {
return {
// @ts-ignore
text: option.description
};
}
createRegistryClientClicked() {
const payload: any = {
revision: this.client.getRevision(this.request.registryClient),
component: {
id: this.request.registryClient.id,
name: this.editRegistryClientForm.get('name')?.value,
type: this.editRegistryClientForm.get('type')?.value,
description: this.editRegistryClientForm.get('description')?.value
}
};
const propertyControl: AbstractControl | null = this.editRegistryClientForm.get('properties');
if (propertyControl && propertyControl.dirty) {
const properties: Property[] = propertyControl.value;
const values: { [key: string]: string | null } = {};
properties.forEach((property) => (values[property.property] = property.value));
payload.component.properties = values;
payload.component.sensitiveDynamicPropertyNames = properties
.filter((property) => property.descriptor.dynamic && property.descriptor.sensitive)
.map((property) => property.descriptor.name);
}
this.editRegistryClient.next(payload);
}
}

View File

@ -0,0 +1,126 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<div class="relative h-full border">
<div class="registry-client-table listing-table absolute inset-0 overflow-y-auto">
<table
mat-table
[dataSource]="dataSource"
matSort
matSortDisableClear
(matSortChange)="updateSort($event)"
[matSortActive]="sort.active"
[matSortDirection]="sort.direction">
<!-- More Details Column -->
<ng-container matColumnDef="moreDetails">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let item">
<ng-container *ngIf="canRead(item)">
<div class="flex items-center">
<!-- TODO - handle read only in configure component? -->
<div
class="mr-3 pointer fa fa-warning has-errors"
*ngIf="hasErrors(item)"
nifiTooltip
[tooltipComponentType]="ValidationErrorsTip"
[tooltipInputData]="getValidationErrorsTipData(item)"></div>
<div
class="mr-3 pointer fa fa-sticky-note-o"
*ngIf="hasBulletins(item)"
nifiTooltip
[tooltipComponentType]="BulletinsTip"
[tooltipInputData]="getBulletinsTipData(item)"></div>
</div>
</ng-container>
</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
<td mat-cell *matCellDef="let item">
<ng-container *ngIf="canRead(item); else nameNoPermissions">
{{ item.component.name }}
</ng-container>
<ng-template #nameNoPermissions>
<div class="unset">{{ item.id }}</div>
</ng-template>
</td>
</ng-container>
<!-- Description Column -->
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Description</th>
<td mat-cell *matCellDef="let item">
<ng-container *ngIf="canRead(item); else descriptionNoPermissions">
{{ item.component.description }}
</ng-container>
<ng-template #descriptionNoPermissions>
<div class="unset">{{ item.id }}</div>
</ng-template>
</td>
</ng-container>
<!-- Type Column -->
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Type</th>
<td mat-cell *matCellDef="let item">
<ng-container *ngIf="canRead(item)">
{{ formatType(item) }}
</ng-container>
</td>
</ng-container>
<!-- Bundle Column -->
<ng-container matColumnDef="bundle">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Bundle</th>
<td mat-cell *matCellDef="let item">
<ng-container *ngIf="canRead(item)">
{{ formatBundle(item) }}
</ng-container>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let item">
<div class="flex items-center gap-x-3">
<div
class="pointer fa fa-pencil"
*ngIf="canConfigure(item)"
(click)="configureClicked(item, $event)"
title="Configure"></div>
<div
class="pointer fa fa-trash"
*ngIf="canDelete(item)"
(click)="deleteClicked(item, $event)"
title="Delete"></div>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr
mat-row
*matRowDef="let row; let even = even; columns: displayedColumns"
(click)="select(row)"
[class.selected]="isSelected(row)"
[class.even]="even"></tr>
</table>
</div>
</div>

View File

@ -0,0 +1,24 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.registry-client-table.listing-table {
table {
.mat-column-moreDetails {
min-width: 100px;
}
}
}

View File

@ -0,0 +1,42 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegistryClientTable } from './registry-client-table.component';
import { MatTableModule } from '@angular/material/table';
import { MatSortModule } from '@angular/material/sort';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
describe('RegistryClientTable', () => {
let component: RegistryClientTable;
let fixture: ComponentFixture<RegistryClientTable>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [RegistryClientTable],
imports: [MatTableModule, MatSortModule, BrowserAnimationsModule]
});
fixture = TestBed.createComponent(RegistryClientTable);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,157 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { Sort } from '@angular/material/sort';
import { ReportingTaskEntity } from '../../../state/reporting-tasks';
import { TextTip } from '../../../../../ui/common/tooltips/text-tip/text-tip.component';
import { BulletinsTip } from '../../../../../ui/common/tooltips/bulletins-tip/bulletins-tip.component';
import { ValidationErrorsTip } from '../../../../../ui/common/tooltips/validation-errors-tip/validation-errors-tip.component';
import { NiFiCommon } from '../../../../../service/nifi-common.service';
import { BulletinsTipInput, ValidationErrorsTipInput } from '../../../../../state/shared';
import { RegistryClientEntity } from '../../../state/registry-clients';
@Component({
selector: 'registry-client-table',
templateUrl: './registry-client-table.component.html',
styleUrls: ['./registry-client-table.component.scss', '../../../../../../assets/styles/listing-table.scss']
})
export class RegistryClientTable {
@Input() set registryClients(registryClientEntities: RegistryClientEntity[]) {
if (registryClientEntities) {
this.dataSource.data = this.sortEvents(registryClientEntities, this.sort);
}
}
@Input() selectedRegistryClientId!: string;
@Output() selectRegistryClient: EventEmitter<RegistryClientEntity> = new EventEmitter<RegistryClientEntity>();
@Output() configureRegistryClient: EventEmitter<RegistryClientEntity> = new EventEmitter<RegistryClientEntity>();
@Output() deleteRegistryClient: EventEmitter<RegistryClientEntity> = new EventEmitter<RegistryClientEntity>();
protected readonly TextTip = TextTip;
protected readonly BulletinsTip = BulletinsTip;
protected readonly ValidationErrorsTip = ValidationErrorsTip;
displayedColumns: string[] = ['moreDetails', 'name', 'description', 'type', 'bundle', 'actions'];
dataSource: MatTableDataSource<RegistryClientEntity> = new MatTableDataSource<RegistryClientEntity>();
sort: Sort = {
active: 'name',
direction: 'asc'
};
constructor(private nifiCommon: NiFiCommon) {}
canRead(entity: RegistryClientEntity): boolean {
return entity.permissions.canRead;
}
canWrite(entity: RegistryClientEntity): boolean {
return entity.permissions.canWrite;
}
hasErrors(entity: RegistryClientEntity): boolean {
return this.canRead(entity) && !this.nifiCommon.isEmpty(entity.component.validationErrors);
}
getValidationErrorsTipData(entity: RegistryClientEntity): ValidationErrorsTipInput {
return {
isValidating: false,
validationErrors: entity.component.validationErrors
};
}
hasBulletins(entity: RegistryClientEntity): boolean {
return this.canRead(entity) && !this.nifiCommon.isEmpty(entity.bulletins);
}
getBulletinsTipData(entity: RegistryClientEntity): BulletinsTipInput {
return {
// @ts-ignore
bulletins: entity.bulletins
};
}
formatType(entity: RegistryClientEntity): string {
return this.nifiCommon.formatType(entity.component);
}
formatBundle(entity: RegistryClientEntity): string {
return this.nifiCommon.formatBundle(entity.component.bundle);
}
updateSort(sort: Sort): void {
this.sort = sort;
this.dataSource.data = this.sortEvents(this.dataSource.data, sort);
}
sortEvents(entities: RegistryClientEntity[], sort: Sort): RegistryClientEntity[] {
const data: RegistryClientEntity[] = entities.slice();
return data.sort((a, b) => {
const isAsc = sort.direction === 'asc';
let retVal: number = 0;
switch (sort.active) {
case 'name':
retVal = this.nifiCommon.compareString(a.component.name, b.component.name);
break;
case 'description':
retVal = this.nifiCommon.compareString(a.component.description, b.component.description);
break;
case 'type':
retVal = this.nifiCommon.compareString(this.formatType(a), this.formatType(b));
break;
case 'bundle':
retVal = this.nifiCommon.compareString(this.formatBundle(a), this.formatBundle(b));
break;
}
return retVal * (isAsc ? 1 : -1);
});
}
canConfigure(entity: RegistryClientEntity): boolean {
return this.canRead(entity) && this.canWrite(entity);
}
configureClicked(entity: RegistryClientEntity, event: MouseEvent): void {
event.stopPropagation();
this.configureRegistryClient.next(entity);
}
canDelete(entity: RegistryClientEntity): boolean {
return this.canRead(entity) && this.canWrite(entity);
}
deleteClicked(entity: RegistryClientEntity, event: MouseEvent): void {
event.stopPropagation();
this.deleteRegistryClient.next(entity);
}
select(entity: ReportingTaskEntity): void {
this.selectRegistryClient.next(entity);
}
isSelected(entity: ReportingTaskEntity): boolean {
if (this.selectedRegistryClientId) {
return entity.id == this.selectedRegistryClientId;
}
return false;
}
}

View File

@ -15,4 +15,34 @@
~ limitations under the License.
-->
<p>registry-clients works!</p>
<ng-container *ngIf="registryClientsState$ | async; let registryClientState">
<div *ngIf="isInitialLoading(registryClientState); else loaded">
<ngx-skeleton-loader count="3"></ngx-skeleton-loader>
</div>
<ng-template #loaded>
<div class="flex flex-col h-full gap-y-2" *ngIf="currentUser$ | async; let currentUser">
<div class="flex justify-end" *ngIf="currentUser.controllerPermissions.canWrite">
<button class="nifi-button" (click)="openNewRegistryClientDialog()">
<i class="fa fa-plus"></i>
</button>
</div>
<div class="flex-1">
<registry-client-table
[selectedRegistryClientId]="selectedRegistryClientId$ | async"
[registryClients]="registryClientState.registryClients"
(selectRegistryClient)="selectRegistryClient($event)"
(configureRegistryClient)="configureRegistryClient($event)"
(deleteRegistryClient)="deleteRegistryClient($event)"></registry-client-table>
</div>
<div class="flex justify-between">
<div class="refresh-container flex items-center gap-x-2">
<button class="nifi-button" (click)="refreshRegistryClientListing()">
<i class="fa fa-refresh" [class.fa-spin]="registryClientState.status === 'loading'"></i>
</button>
<div>Last updated:</div>
<div class="refresh-timestamp">{{ registryClientState.loadedTimestamp }}</div>
</div>
</div>
</div>
</ng-template>
</ng-container>

View File

@ -18,6 +18,8 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegistryClients } from './registry-clients.component';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../state/registry-clients/registry-clients.reducer';
describe('RegistryClients', () => {
let component: RegistryClients;
@ -25,7 +27,8 @@ describe('RegistryClients', () => {
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [RegistryClients]
declarations: [RegistryClients],
providers: [provideMockStore({ initialState })]
});
fixture = TestBed.createComponent(RegistryClients);
component = fixture.componentInstance;

View File

@ -15,11 +15,107 @@
* limitations under the License.
*/
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import {
selectRegistryClient,
selectRegistryClientIdFromRoute,
selectRegistryClientsState,
selectSingleEditedRegistryClient
} from '../../state/registry-clients/registry-clients.selectors';
import {
loadRegistryClients,
navigateToEditRegistryClient,
openConfigureRegistryClientDialog,
openNewRegistryClientDialog,
promptRegistryClientDeletion,
selectClient
} from '../../state/registry-clients/registry-clients.actions';
import { RegistryClientEntity, RegistryClientsState } from '../../state/registry-clients';
import { initialState } from '../../state/registry-clients/registry-clients.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import { NiFiState } from '../../../../state';
import { filter, switchMap, take } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'registry-clients',
templateUrl: './registry-clients.component.html',
styleUrls: ['./registry-clients.component.scss']
})
export class RegistryClients {}
export class RegistryClients implements OnInit {
registryClientsState$ = this.store.select(selectRegistryClientsState);
selectedRegistryClientId$ = this.store.select(selectRegistryClientIdFromRoute);
currentUser$ = this.store.select(selectUser);
constructor(private store: Store<NiFiState>) {
this.store
.select(selectSingleEditedRegistryClient)
.pipe(
filter((id: string) => id != null),
switchMap((id: string) =>
this.store.select(selectRegistryClient(id)).pipe(
filter((entity) => entity != null),
take(1)
)
),
takeUntilDestroyed()
)
.subscribe((entity) => {
if (entity) {
this.store.dispatch(
openConfigureRegistryClientDialog({
request: {
registryClient: entity
}
})
);
}
});
}
ngOnInit(): void {
this.store.dispatch(loadRegistryClients());
}
isInitialLoading(state: RegistryClientsState): boolean {
// using the current timestamp to detect the initial load event
return state.loadedTimestamp == initialState.loadedTimestamp;
}
openNewRegistryClientDialog(): void {
this.store.dispatch(openNewRegistryClientDialog());
}
refreshRegistryClientListing(): void {
this.store.dispatch(loadRegistryClients());
}
selectRegistryClient(entity: RegistryClientEntity): void {
this.store.dispatch(
selectClient({
request: {
id: entity.id
}
})
);
}
configureRegistryClient(entity: RegistryClientEntity): void {
this.store.dispatch(
navigateToEditRegistryClient({
id: entity.id
})
);
}
deleteRegistryClient(entity: RegistryClientEntity): void {
this.store.dispatch(
promptRegistryClientDeletion({
request: {
registryClient: entity
}
})
);
}
}

View File

@ -18,10 +18,15 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RegistryClients } from './registry-clients.component';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { RegistryClientTable } from './registry-client-table/registry-client-table.component';
import { MatTableModule } from '@angular/material/table';
import { MatSortModule } from '@angular/material/sort';
import { NifiTooltipDirective } from '../../../../ui/common/tooltips/nifi-tooltip.directive';
@NgModule({
declarations: [RegistryClients],
declarations: [RegistryClients, RegistryClientTable],
exports: [RegistryClients],
imports: [CommonModule]
imports: [CommonModule, NgxSkeletonLoaderModule, MatTableModule, MatSortModule, NifiTooltipDirective]
})
export class RegistryClientsModule {}

View File

@ -20,8 +20,8 @@
<ngx-skeleton-loader count="3"></ngx-skeleton-loader>
</div>
<ng-template #loaded>
<div class="flex flex-col h-full gap-y-2">
<div class="flex justify-end">
<div class="flex flex-col h-full gap-y-2" *ngIf="currentUser$ | async; let currentUser">
<div class="flex justify-end" *ngIf="currentUser.controllerPermissions.canWrite">
<button class="nifi-button" (click)="openNewReportingTaskDialog()">
<i class="fa fa-plus"></i>
</button>

View File

@ -31,6 +31,8 @@ import {
stopReportingTask
} from '../../state/reporting-tasks/reporting-tasks.actions';
import { initialState } from '../../state/reporting-tasks/reporting-tasks.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import { NiFiState } from '../../../../state';
@Component({
selector: 'reporting-tasks',
@ -40,8 +42,9 @@ import { initialState } from '../../state/reporting-tasks/reporting-tasks.reduce
export class ReportingTasks implements OnInit {
reportingTaskState$ = this.store.select(selectReportingTasksState);
selectedReportingTaskId$ = this.store.select(selectReportingTaskIdFromRoute);
currentUser$ = this.store.select(selectUser);
constructor(private store: Store<ReportingTasksState>) {}
constructor(private store: Store<NiFiState>) {}
ngOnInit(): void {
this.store.dispatch(loadReportingTasks());

View File

@ -48,6 +48,10 @@ export class ExtensionTypesService {
return this.httpClient.get(`${ExtensionTypesService.API}/flow/reporting-task-types`);
}
getRegistryClientTypes(): Observable<any> {
return this.httpClient.get(`${ExtensionTypesService.API}/controller/registry-types`);
}
getPrioritizers(): Observable<any> {
return this.httpClient.get(`${ExtensionTypesService.API}/flow/prioritizers`);
}

View File

@ -18,11 +18,11 @@
import { TestBed } from '@angular/core/testing';
import { CanMatchFn } from '@angular/router';
import { authGuard } from './auth.guard';
import { authenticationGuard } from './authentication.guard';
describe('authGuard', () => {
describe('authenticationGuard', () => {
const executeGuard: CanMatchFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
TestBed.runInInjectionContext(() => authenticationGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});

View File

@ -26,7 +26,7 @@ import { UserState } from '../../state/user';
import { loadUserSuccess } from '../../state/user/user.actions';
import { selectUserState } from '../../state/user/user.selectors';
export const authGuard: CanMatchFn = (route, state) => {
export const authenticationGuard: CanMatchFn = (route, state) => {
const authStorage: AuthStorage = inject(AuthStorage);
const authService: AuthService = inject(AuthService);
const userService: UserService = inject(UserService);

View File

@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CanMatchFn, Route, Router, UrlSegment } from '@angular/router';
import { inject } from '@angular/core';
import { map } from 'rxjs';
import { Store } from '@ngrx/store';
import { User, UserState } from '../../state/user';
import { selectUser } from '../../state/user/user.selectors';
export const authorizationGuard = (authorizationCheck: (user: User) => boolean): CanMatchFn => {
return (route: Route, state: UrlSegment[]) => {
const router: Router = inject(Router);
const store: Store<UserState> = inject(Store<UserState>);
return store.select(selectUser).pipe(
map((currentUser) => {
if (authorizationCheck(currentUser)) {
return true;
}
// TODO - replace with 404 error page
return router.parseUrl('/');
})
);
};
};

View File

@ -160,6 +160,30 @@ export class NiFiCommon {
return groupString + bundle.artifact;
}
/**
* Compares two strings.
*
* @param a
* @param b
*/
public compareString(a: string, b: string): number {
if (a === b) {
return 0;
}
return a < b ? -1 : 1;
}
/**
* Compares two numbers.
*
* @param a
* @param b
*/
public compareNumber(a: number, b: number): number {
return a - b;
}
/**
* Constant regex for leading and/or trailing whitespace.
*/

View File

@ -59,18 +59,27 @@ export class ExtensionTypesEffects {
combineLatest([
this.extensionTypesService.getControllerServiceTypes(),
this.extensionTypesService.getReportingTaskTypes(),
this.extensionTypesService.getRegistryClientTypes(),
this.extensionTypesService.getParameterProviderTypes(),
this.extensionTypesService.getFlowAnalysisRuleTypes()
]).pipe(
map(([controllerServiceTypes, reportingTaskTypes, parameterProviderTypes, flowAnalysisRuleTypes]) =>
ExtensionTypesActions.loadExtensionTypesForSettingsSuccess({
response: {
controllerServiceTypes: controllerServiceTypes.controllerServiceTypes,
reportingTaskTypes: reportingTaskTypes.reportingTaskTypes,
parameterProviderTypes: parameterProviderTypes.parameterProviderTypes,
flowAnalysisRuleTypes: flowAnalysisRuleTypes.flowAnalysisRuleTypes
}
})
map(
([
controllerServiceTypes,
reportingTaskTypes,
registryClientTypes,
parameterProviderTypes,
flowAnalysisRuleTypes
]) =>
ExtensionTypesActions.loadExtensionTypesForSettingsSuccess({
response: {
controllerServiceTypes: controllerServiceTypes.controllerServiceTypes,
reportingTaskTypes: reportingTaskTypes.reportingTaskTypes,
registryClientTypes: registryClientTypes.flowRegistryClientTypes,
parameterProviderTypes: parameterProviderTypes.parameterProviderTypes,
flowAnalysisRuleTypes: flowAnalysisRuleTypes.flowAnalysisRuleTypes
}
})
),
catchError((error) => of(ExtensionTypesActions.extensionTypesApiError({ error: error.error })))
)

View File

@ -30,6 +30,7 @@ export const initialState: ExtensionTypesState = {
controllerServiceTypes: [],
prioritizerTypes: [],
reportingTaskTypes: [],
registryClientTypes: [],
flowAnalysisRuleTypes: [],
parameterProviderTypes: [],
error: null,
@ -54,6 +55,7 @@ export const extensionTypesReducer = createReducer(
...state,
controllerServiceTypes: response.controllerServiceTypes,
reportingTaskTypes: response.reportingTaskTypes,
registryClientTypes: response.registryClientTypes,
parameterProviderTypes: response.parameterProviderTypes,
flowAnalysisRuleTypes: response.flowAnalysisRuleTypes,
error: null,

View File

@ -39,3 +39,8 @@ export const selectReportingTaskTypes = createSelector(
selectExtensionTypesState,
(state: ExtensionTypesState) => state.reportingTaskTypes
);
export const selectRegistryClientTypes = createSelector(
selectExtensionTypesState,
(state: ExtensionTypesState) => state.registryClientTypes
);

View File

@ -28,6 +28,7 @@ export interface LoadExtensionTypesForCanvasResponse {
export interface LoadExtensionTypesForSettingsResponse {
controllerServiceTypes: DocumentedType[];
reportingTaskTypes: DocumentedType[];
registryClientTypes: DocumentedType[];
flowAnalysisRuleTypes: DocumentedType[];
parameterProviderTypes: DocumentedType[];
}
@ -41,6 +42,7 @@ export interface ExtensionTypesState {
controllerServiceTypes: DocumentedType[];
prioritizerTypes: DocumentedType[];
reportingTaskTypes: DocumentedType[];
registryClientTypes: DocumentedType[];
flowAnalysisRuleTypes: DocumentedType[];
parameterProviderTypes: DocumentedType[];
error: string | null;

View File

@ -32,5 +32,13 @@
.mat-column-property {
min-width: 230px;
}
.mat-column-value {
min-width: 230px;
}
.mat-column-actions {
min-width: 50px;
}
}
}