NIFI-12611: (#8296)

- Component State.

This closes #8296
This commit is contained in:
Matt Gilman 2024-01-25 14:53:17 -05:00 committed by GitHub
parent 7fc27651a4
commit ecb87149fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 873 additions and 12 deletions

View File

@ -41,6 +41,7 @@ import { MatDialogModule } from '@angular/material/dialog';
import { ControllerServiceStateEffects } from './state/contoller-service-state/controller-service-state.effects';
import { SystemDiagnosticsEffects } from './state/system-diagnostics/system-diagnostics.effects';
import { FlowConfigurationEffects } from './state/flow-configuration/flow-configuration.effects';
import { ComponentStateEffects } from './state/component-state/component-state.effects';
@NgModule({
declarations: [AppComponent],
@ -65,7 +66,8 @@ import { FlowConfigurationEffects } from './state/flow-configuration/flow-config
FlowConfigurationEffects,
StatusHistoryEffects,
ControllerServiceStateEffects,
SystemDiagnosticsEffects
SystemDiagnosticsEffects,
ComponentStateEffects
),
StoreDevtoolsModule.instrument({
maxAge: 25,

View File

@ -55,6 +55,7 @@ import {
ContextMenuItemDefinition
} from '../../../ui/common/context-menu/context-menu.component';
import { promptEmptyQueueRequest, promptEmptyQueuesRequest } from '../state/queue/queue.actions';
import { getComponentStateAndOpenDialog } from '../../../state/component-state/component-state.actions';
@Injectable({ providedIn: 'root' })
export class CanvasContextMenu implements ContextMenuDefinitionProvider {
@ -679,13 +680,21 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
},
{
condition: (selection: any) => {
// TODO - isStatefulProcessor
return false;
return this.canvasUtils.isStatefulProcessor(selection);
},
clazz: 'fa fa-tasks',
text: 'View state',
action: () => {
// TODO - viewState
action: (selection: any) => {
const selectionData = selection.datum();
this.store.dispatch(
getComponentStateAndOpenDialog({
request: {
componentName: selectionData.component.name,
componentUri: selectionData.uri,
canClear: this.canvasUtils.isConfigurable(selection)
}
})
);
}
},
{

View File

@ -464,6 +464,28 @@ export class CanvasUtils {
return selection.size() === 1 && selection.classed('funnel');
}
/**
* Determines whether the current selection is a stateful processor.
*
* @param {selection} selection
*/
public isStatefulProcessor(selection: any): boolean {
// ensure the correct number of components are selected
if (selection.size() !== 1) {
return false;
}
if (this.canRead(selection) === false || this.canModify(selection) === false) {
return false;
}
if (this.isProcessor(selection)) {
const processorData: any = selection.datum();
return processorData.component.persistsState === true;
} else {
return false;
}
}
/**
* Determines whether the user can configure or open the policy management page.
*/

View File

@ -48,6 +48,7 @@
(configureControllerService)="configureControllerService($event)"
(enableControllerService)="enableControllerService($event)"
(disableControllerService)="disableControllerService($event)"
(viewStateControllerService)="viewStateControllerService($event)"
(deleteControllerService)="deleteControllerService($event)"></controller-service-table>
</div>
<div class="flex justify-between">

View File

@ -21,15 +21,23 @@ import { ControllerServices } from './controller-services.component';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../state/controller-services/controller-services.reducer';
import { RouterTestingModule } from '@angular/router/testing';
import { Component } from '@angular/core';
describe('ControllerServices', () => {
let component: ControllerServices;
let fixture: ComponentFixture<ControllerServices>;
@Component({
selector: 'navigation',
standalone: true,
template: ''
})
class MockNavigation {}
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ControllerServices],
imports: [RouterTestingModule],
imports: [RouterTestingModule, MockNavigation],
providers: [
provideMockStore({
initialState

View File

@ -45,6 +45,7 @@ import { selectCurrentUser } from '../../../../state/current-user/current-user.s
import { selectFlowConfiguration } from '../../../../state/flow-configuration/flow-configuration.selectors';
import { NiFiState } from '../../../../state';
import { loadFlowConfiguration } from '../../../../state/flow-configuration/flow-configuration.actions';
import { getComponentStateAndOpenDialog } from '../../../../state/component-state/component-state.actions';
@Component({
selector: 'controller-services',
@ -189,6 +190,18 @@ export class ControllerServices implements OnInit, OnDestroy {
);
}
viewStateControllerService(entity: ControllerServiceEntity): void {
this.store.dispatch(
getComponentStateAndOpenDialog({
request: {
componentUri: entity.uri,
componentName: entity.component.name,
canClear: entity.component.state === 'DISABLED'
}
})
);
}
deleteControllerService(entity: ControllerServiceEntity): void {
this.store.dispatch(
promptControllerServiceDeletion({

View File

@ -134,7 +134,11 @@
*ngIf="canDelete(item)"
(click)="deleteClicked(item)"
title="Delete"></div>
<div class="pointer fa fa-tasks" *ngIf="canViewState(item)" title="View State"></div>
<div
class="pointer fa fa-tasks"
*ngIf="canViewState(item)"
(click)="viewStateClicked(item)"
title="View State"></div>
</div>
</td>
</ng-container>

View File

@ -60,6 +60,8 @@ export class FlowAnalysisRuleTable {
@Output() configureFlowAnalysisRule: EventEmitter<FlowAnalysisRuleEntity> =
new EventEmitter<FlowAnalysisRuleEntity>();
@Output() enableFlowAnalysisRule: EventEmitter<FlowAnalysisRuleEntity> = new EventEmitter<FlowAnalysisRuleEntity>();
@Output() viewStateFlowAnalysisRule: EventEmitter<FlowAnalysisRuleEntity> =
new EventEmitter<FlowAnalysisRuleEntity>();
@Output() disableFlowAnalysisRule: EventEmitter<FlowAnalysisRuleEntity> =
new EventEmitter<FlowAnalysisRuleEntity>();
@ -255,6 +257,10 @@ export class FlowAnalysisRuleTable {
return this.canRead(entity) && this.canWrite(entity) && entity.component.persistsState === true;
}
viewStateClicked(entity: FlowAnalysisRuleEntity): void {
this.viewStateFlowAnalysisRule.next(entity);
}
select(entity: FlowAnalysisRuleEntity): void {
this.selectFlowAnalysisRule.next(entity);
}

View File

@ -35,6 +35,7 @@
(selectFlowAnalysisRule)="selectFlowAnalysisRule($event)"
(enableFlowAnalysisRule)="enableFlowAnalysisRule($event)"
(disableFlowAnalysisRule)="disableFlowAnalysisRule($event)"
(viewStateFlowAnalysisRule)="viewStateFlowAnalysisRule($event)"
(deleteFlowAnalysisRule)="deleteFlowAnalysisRule($event)"></flow-analysis-rule-table>
</div>
<div class="flex justify-between">

View File

@ -41,6 +41,7 @@ import { selectCurrentUser } from '../../../../state/current-user/current-user.s
import { NiFiState } from '../../../../state';
import { FlowAnalysisRuleEntity, FlowAnalysisRulesState } from '../../state/flow-analysis-rules';
import { CurrentUser } from '../../../../state/current-user';
import { getComponentStateAndOpenDialog } from '../../../../state/component-state/component-state.actions';
@Component({
selector: 'flow-analysis-rules',
@ -128,6 +129,19 @@ export class FlowAnalysisRules implements OnInit, OnDestroy {
);
}
viewStateFlowAnalysisRule(entity: FlowAnalysisRuleEntity): void {
const canClear: boolean = entity.status.runStatus === 'DISABLED';
this.store.dispatch(
getComponentStateAndOpenDialog({
request: {
componentUri: entity.uri,
componentName: entity.component.name,
canClear
}
})
);
}
deleteFlowAnalysisRule(entity: FlowAnalysisRuleEntity): void {
this.store.dispatch(
promptFlowAnalysisRuleDeletion({

View File

@ -39,6 +39,7 @@
(configureControllerService)="configureControllerService($event)"
(enableControllerService)="enableControllerService($event)"
(disableControllerService)="disableControllerService($event)"
(viewStateControllerService)="viewStateControllerService($event)"
(deleteControllerService)="deleteControllerService($event)"></controller-service-table>
</div>
<div class="flex justify-between">

View File

@ -44,6 +44,7 @@ import { NiFiState } from '../../../../state';
import { selectFlowConfiguration } from '../../../../state/flow-configuration/flow-configuration.selectors';
import { loadFlowConfiguration } from '../../../../state/flow-configuration/flow-configuration.actions';
import { CurrentUser } from '../../../../state/current-user';
import { getComponentStateAndOpenDialog } from '../../../../state/component-state/component-state.actions';
@Component({
selector: 'management-controller-services',
@ -139,6 +140,18 @@ export class ManagementControllerServices implements OnInit, OnDestroy {
);
}
viewStateControllerService(entity: ControllerServiceEntity): void {
this.store.dispatch(
getComponentStateAndOpenDialog({
request: {
componentUri: entity.uri,
componentName: entity.component.name,
canClear: entity.component.state === 'DISABLED'
}
})
);
}
deleteControllerService(entity: ControllerServiceEntity): void {
this.store.dispatch(
promptControllerServiceDeletion({

View File

@ -124,7 +124,11 @@
*ngIf="canDelete(item)"
(click)="deleteClicked(item)"
title="Delete"></div>
<div class="pointer fa fa-tasks" *ngIf="canViewState(item)" title="View State"></div>
<div
class="pointer fa fa-tasks"
*ngIf="canViewState(item)"
(click)="viewStateClicked(item)"
title="View State"></div>
<div
class="pointer fa fa-key"
*ngIf="canManageAccessPolicies()"

View File

@ -51,6 +51,7 @@ export class ReportingTaskTable {
@Output() deleteReportingTask: EventEmitter<ReportingTaskEntity> = new EventEmitter<ReportingTaskEntity>();
@Output() startReportingTask: EventEmitter<ReportingTaskEntity> = new EventEmitter<ReportingTaskEntity>();
@Output() configureReportingTask: EventEmitter<ReportingTaskEntity> = new EventEmitter<ReportingTaskEntity>();
@Output() viewStateReportingTask: EventEmitter<ReportingTaskEntity> = new EventEmitter<ReportingTaskEntity>();
@Output() stopReportingTask: EventEmitter<ReportingTaskEntity> = new EventEmitter<ReportingTaskEntity>();
protected readonly TextTip = TextTip;
@ -233,6 +234,10 @@ export class ReportingTaskTable {
return this.canRead(entity) && this.canWrite(entity) && entity.component.persistsState === true;
}
viewStateClicked(entity: ReportingTaskEntity): void {
this.viewStateReportingTask.next(entity);
}
canManageAccessPolicies(): boolean {
return this.flowConfiguration.supportsManagedAuthorizer && this.currentUser.tenantsPermissions.canRead;
}

View File

@ -33,6 +33,7 @@
[currentUser]="currentUser"
[flowConfiguration]="(flowConfiguration$ | async)!"
(configureReportingTask)="configureReportingTask($event)"
(viewStateReportingTask)="viewStateReportingTask($event)"
(selectReportingTask)="selectReportingTask($event)"
(deleteReportingTask)="deleteReportingTask($event)"
(stopReportingTask)="stopReportingTask($event)"

View File

@ -42,6 +42,7 @@ import { selectCurrentUser } from '../../../../state/current-user/current-user.s
import { NiFiState } from '../../../../state';
import { loadFlowConfiguration } from '../../../../state/flow-configuration/flow-configuration.actions';
import { selectFlowConfiguration } from '../../../../state/flow-configuration/flow-configuration.selectors';
import { getComponentStateAndOpenDialog } from '../../../../state/component-state/component-state.actions';
@Component({
selector: 'reporting-tasks',
@ -127,6 +128,19 @@ export class ReportingTasks implements OnInit, OnDestroy {
);
}
viewStateReportingTask(entity: ReportingTaskEntity): void {
const canClear: boolean = entity.status.runStatus === 'STOPPED' && entity.status.activeThreadCount === 0;
this.store.dispatch(
getComponentStateAndOpenDialog({
request: {
componentUri: entity.uri,
componentName: entity.component.name,
canClear
}
})
);
}
startReportingTask(entity: ReportingTaskEntity): void {
this.store.dispatch(
startReportingTask({

View File

@ -0,0 +1,51 @@
/*
* 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 { HttpClient } from '@angular/common/http';
import { ClearComponentStateRequest, LoadComponentStateRequest } from '../state/component-state';
import { Observable } from 'rxjs';
import { NiFiCommon } from './nifi-common.service';
@Injectable({ providedIn: 'root' })
export class ComponentStateService {
constructor(
private httpClient: HttpClient,
private nifiCommon: NiFiCommon
) {}
/**
* 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, ':');
}
getComponentState(request: LoadComponentStateRequest): Observable<any> {
return this.httpClient.get(`${this.stripProtocol(request.componentUri)}/state`);
}
clearComponentState(request: ClearComponentStateRequest): Observable<any> {
return this.httpClient.post(`${this.stripProtocol(request.componentUri)}/state/clear-requests`, {});
}
}

View File

@ -0,0 +1,49 @@
/*
* 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 { ComponentStateRequest, ComponentStateResponse } from './index';
const COMPONENT_STATE_PREFIX = '[Component State]';
export const getComponentStateAndOpenDialog = createAction(
`${COMPONENT_STATE_PREFIX} Get Component State and Open Dialog`,
props<{ request: ComponentStateRequest }>()
);
export const loadComponentStateSuccess = createAction(
`${COMPONENT_STATE_PREFIX} Load Component State Success`,
props<{ response: ComponentStateResponse }>()
);
export const openComponentStateDialog = createAction(`${COMPONENT_STATE_PREFIX} Open Component State Dialog`);
export const componentStateApiError = createAction(
`${COMPONENT_STATE_PREFIX} Component State API error`,
props<{ error: string }>()
);
export const clearComponentState = createAction(`${COMPONENT_STATE_PREFIX} Clear Component State`);
export const reloadComponentState = createAction(`${COMPONENT_STATE_PREFIX} Reload Component State`);
export const reloadComponentStateSuccess = createAction(
`${COMPONENT_STATE_PREFIX} Reload Component State Success`,
props<{ response: ComponentStateResponse }>()
);
export const resetComponentState = createAction(`${COMPONENT_STATE_PREFIX} Reset Component State`);

View File

@ -0,0 +1,139 @@
/*
* 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, concatLatestFrom, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { NiFiState } from '../index';
import * as ComponentStateActions from './component-state.actions';
import { catchError, from, map, of, switchMap, tap } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { ComponentStateService } from '../../service/component-state.service';
import { ComponentStateDialog } from '../../ui/common/component-state/component-state.component';
import { resetComponentState } from './component-state.actions';
import { selectComponentUri } from './component-state.selectors';
import { isDefinedAndNotNull } from '../shared';
@Injectable()
export class ComponentStateEffects {
constructor(
private actions$: Actions,
private store: Store<NiFiState>,
private componentStateService: ComponentStateService,
private dialog: MatDialog
) {}
getComponentStateAndOpenDialog$ = createEffect(() =>
this.actions$.pipe(
ofType(ComponentStateActions.getComponentStateAndOpenDialog),
map((action) => action.request),
switchMap((request) =>
from(
this.componentStateService.getComponentState({ componentUri: request.componentUri }).pipe(
map((response: any) =>
ComponentStateActions.loadComponentStateSuccess({
response: {
componentState: response.componentState
}
})
),
catchError((error) =>
of(
ComponentStateActions.componentStateApiError({
error: error.error
})
)
)
)
)
)
)
);
loadComponentStateSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(ComponentStateActions.loadComponentStateSuccess),
map((action) => action.response),
switchMap((response) => of(ComponentStateActions.openComponentStateDialog()))
)
);
openComponentStateDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(ComponentStateActions.openComponentStateDialog),
tap(() => {
const dialogReference = this.dialog.open(ComponentStateDialog, {
panelClass: 'large-dialog'
});
dialogReference.afterClosed().subscribe((response) => {
this.store.dispatch(resetComponentState());
});
})
),
{ dispatch: false }
);
clearComponentState$ = createEffect(() =>
this.actions$.pipe(
ofType(ComponentStateActions.clearComponentState),
concatLatestFrom(() => this.store.select(selectComponentUri).pipe(isDefinedAndNotNull())),
switchMap(([action, componentUri]) =>
from(
this.componentStateService.clearComponentState({ componentUri }).pipe(
map((response: any) => ComponentStateActions.reloadComponentState()),
catchError((error) =>
of(
ComponentStateActions.componentStateApiError({
error: error.error
})
)
)
)
)
)
)
);
reloadComponentState$ = createEffect(() =>
this.actions$.pipe(
ofType(ComponentStateActions.reloadComponentState),
concatLatestFrom(() => this.store.select(selectComponentUri).pipe(isDefinedAndNotNull())),
switchMap(([action, componentUri]) =>
from(
this.componentStateService.getComponentState({ componentUri }).pipe(
map((response: any) =>
ComponentStateActions.reloadComponentStateSuccess({
response: {
componentState: response.componentState
}
})
),
catchError((error) =>
of(
ComponentStateActions.componentStateApiError({
error: error.error
})
)
)
)
)
)
)
);
}

View File

@ -0,0 +1,60 @@
/*
* 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 { ComponentStateState } from './index';
import { createReducer, on } from '@ngrx/store';
import {
resetComponentState,
loadComponentStateSuccess,
componentStateApiError,
getComponentStateAndOpenDialog,
reloadComponentStateSuccess
} from './component-state.actions';
export const initialState: ComponentStateState = {
componentName: null,
componentUri: null,
componentState: null,
canClear: null,
status: 'pending',
error: null
};
export const componentStateReducer = createReducer(
initialState,
on(getComponentStateAndOpenDialog, (state, { request }) => ({
...state,
componentName: request.componentName,
componentUri: request.componentUri,
canClear: request.canClear,
status: 'loading' as const
})),
on(loadComponentStateSuccess, reloadComponentStateSuccess, (state, { response }) => ({
...state,
error: null,
status: 'success' as const,
componentState: response.componentState
})),
on(componentStateApiError, (state, { error }) => ({
...state,
error,
status: 'error' as const
})),
on(resetComponentState, (state) => ({
...initialState
}))
);

View File

@ -0,0 +1,38 @@
/*
* 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 { createFeatureSelector, createSelector } from '@ngrx/store';
import { componentStateFeatureKey, ComponentStateState } from './index';
export const selectComponentStateState = createFeatureSelector<ComponentStateState>(componentStateFeatureKey);
export const selectComponentState = createSelector(
selectComponentStateState,
(state: ComponentStateState) => state.componentState
);
export const selectComponentName = createSelector(
selectComponentStateState,
(state: ComponentStateState) => state.componentName
);
export const selectComponentUri = createSelector(
selectComponentStateState,
(state: ComponentStateState) => state.componentUri
);
export const selectCanClear = createSelector(selectComponentStateState, (state: ComponentStateState) => state.canClear);

View File

@ -0,0 +1,65 @@
/*
* 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.
*/
export const componentStateFeatureKey = 'componentState';
export interface ComponentStateRequest {
componentName: string;
componentUri: string;
canClear: boolean;
}
export interface LoadComponentStateRequest {
componentUri: string;
}
export interface ClearComponentStateRequest {
componentUri: string;
}
export interface ComponentStateResponse {
componentState: ComponentState;
}
export interface StateEntry {
key: string;
value: string;
clusterNodeId?: string;
clusterNodeAddress?: string;
}
export interface StateMap {
scope: string;
state: StateEntry[];
totalEntryCount: number;
}
export interface ComponentState {
componentId: string;
localState?: StateMap;
clusterState?: StateMap;
stateDescription: string;
}
export interface ComponentStateState {
componentName: string | null;
componentUri: string | null;
componentState: ComponentState | null;
canClear: boolean | null;
error: string | null;
status: 'pending' | 'loading' | 'error' | 'success';
}

View File

@ -31,6 +31,8 @@ import { systemDiagnosticsFeatureKey, SystemDiagnosticsState } from './system-di
import { systemDiagnosticsReducer } from './system-diagnostics/system-diagnostics.reducer';
import { flowConfigurationFeatureKey, FlowConfigurationState } from './flow-configuration';
import { flowConfigurationReducer } from './flow-configuration/flow-configuration.reducer';
import { componentStateFeatureKey, ComponentStateState } from './component-state';
import { componentStateReducer } from './component-state/component-state.reducer';
export interface NiFiState {
router: RouterReducerState;
@ -41,6 +43,7 @@ export interface NiFiState {
[statusHistoryFeatureKey]: StatusHistoryState;
[controllerServiceStateFeatureKey]: ControllerServiceState;
[systemDiagnosticsFeatureKey]: SystemDiagnosticsState;
[componentStateFeatureKey]: ComponentStateState;
}
export const rootReducers: ActionReducerMap<NiFiState> = {
@ -51,5 +54,6 @@ export const rootReducers: ActionReducerMap<NiFiState> = {
[flowConfigurationFeatureKey]: flowConfigurationReducer,
[statusHistoryFeatureKey]: statusHistoryReducer,
[controllerServiceStateFeatureKey]: controllerServiceStateReducer,
[systemDiagnosticsFeatureKey]: systemDiagnosticsReducer
[systemDiagnosticsFeatureKey]: systemDiagnosticsReducer,
[componentStateFeatureKey]: componentStateReducer
};

View File

@ -26,7 +26,6 @@ import {
reloadStatusHistorySuccess,
getStatusHistoryAndOpenDialog
} from './status-history.actions';
import { produce } from 'immer';
export const initialState: StatusHistoryState = {
statusHistory: {} as StatusHistoryEntity,

View File

@ -0,0 +1,86 @@
<!--
~ 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="component-state-dialog" tabindex="0">
<h2 mat-dialog-title>Component State</h2>
<mat-dialog-content>
<div class="flex flex-col justify-between gap-y-5">
<div class="flex flex-col" *ngIf="componentName$ | async; let componentName">
<div>Name</div>
<div class="value">{{ componentName }}</div>
</div>
<div class="flex flex-col">
<div>Description</div>
<div class="value">
{{ stateDescription }}
</div>
</div>
<div class="listing-table">
<form [formGroup]="filterForm" class="flex flex-col gap-y-2">
<div class="value">Displaying {{ filteredEntries }} of {{ totalEntries }}</div>
<div class="flex justify-between items-center">
<mat-form-field>
<mat-label>Filter</mat-label>
<input matInput type="text" class="small" formControlName="filterTerm" />
</mat-form-field>
<ng-container *ngIf="{ value: (canClear$ | async)! } as canClear">
<div *ngIf="canClear.value && totalEntries > 0">
<a (click)="clearState()">Clear state</a>
</div>
</ng-container>
</div>
</form>
<div class="h-72 overflow-y-auto overflow-x-hidden border">
<table
mat-table
[dataSource]="dataSource"
matSort
matSortDisableClear
(matSortChange)="sortData($event)"
[matSortActive]="initialSortColumn"
[matSortDirection]="initialSortDirection">
<!-- Key Column -->
<ng-container matColumnDef="key">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Key</th>
<td mat-cell *matCellDef="let item">
{{ item.key }}
</td>
</ng-container>
<!-- Value Column -->
<ng-container matColumnDef="value">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Value</th>
<td mat-cell *matCellDef="let item" [title]="item.value">
{{ item.value }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr
mat-row
*matRowDef="let row; let even = even; columns: displayedColumns"
[class.even]="even"></tr>
</table>
</div>
</div>
<div *ngIf="partialResults" class="-mt-3">Showing partial results</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button color="primary" mat-raised-button mat-dialog-close>Close</button>
</mat-dialog-actions>
</div>

View File

@ -0,0 +1,32 @@
/*
* 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;
.component-state-dialog {
@include mat.button-density(-1);
width: 760px;
.listing-table {
table {
.mat-column-key {
width: 200px;
}
}
}
}

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 { ComponentStateDialog } from './component-state.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../state/component-state/component-state.reducer';
describe('ComponentStateDialog', () => {
let component: ComponentStateDialog;
let fixture: ComponentFixture<ComponentStateDialog>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ComponentStateDialog, BrowserAnimationsModule],
providers: [provideMockStore({ initialState })]
});
fixture = TestBed.createComponent(ComponentStateDialog);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,169 @@
/*
* 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 { AfterViewInit, Component, Input } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { NiFiCommon } from '../../../service/nifi-common.service';
import { MatSortModule, Sort } from '@angular/material/sort';
import { AsyncPipe, NgIf } from '@angular/common';
import { NifiTooltipDirective } from '../tooltips/nifi-tooltip.directive';
import { NifiSpinnerDirective } from '../spinner/nifi-spinner.directive';
import { ComponentStateState, StateEntry, StateMap } from '../../../state/component-state';
import { Store } from '@ngrx/store';
import { clearComponentState } from '../../../state/component-state/component-state.actions';
import {
selectCanClear,
selectComponentName,
selectComponentState
} from '../../../state/component-state/component-state.selectors';
import { isDefinedAndNotNull } from '../../../state/shared';
import { debounceTime, Observable } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
@Component({
selector: 'component-state',
standalone: true,
templateUrl: './component-state.component.html',
imports: [
MatButtonModule,
MatDialogModule,
MatTableModule,
MatSortModule,
NgIf,
NifiTooltipDirective,
NifiSpinnerDirective,
AsyncPipe,
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule
],
styleUrls: ['./component-state.component.scss', '../../../../assets/styles/listing-table.scss']
})
export class ComponentStateDialog implements AfterViewInit {
@Input() initialSortColumn: 'key' | 'value' = 'key';
@Input() initialSortDirection: 'asc' | 'desc' = 'asc';
componentName$: Observable<string> = this.store.select(selectComponentName).pipe(isDefinedAndNotNull());
canClear$: Observable<boolean> = this.store.select(selectCanClear).pipe(isDefinedAndNotNull());
// TODO - need to include scope column when clustered
displayedColumns: string[] = ['key', 'value'];
dataSource: MatTableDataSource<StateEntry> = new MatTableDataSource<StateEntry>();
filterForm: FormGroup;
stateDescription: string = '';
totalEntries: number = 0;
filteredEntries: number = 0;
partialResults: boolean = false;
constructor(
private store: Store<ComponentStateState>,
private formBuilder: FormBuilder,
private nifiCommon: NiFiCommon
) {
this.filterForm = this.formBuilder.group({ filterTerm: '' });
this.store
.select(selectComponentState)
.pipe(isDefinedAndNotNull(), takeUntilDestroyed())
.subscribe((componentState) => {
this.stateDescription = componentState.stateDescription;
const stateItems: StateEntry[] = [];
if (componentState.localState) {
const localStateItems: StateEntry[] = this.processStateMap(componentState.localState);
stateItems.push(...localStateItems);
}
if (componentState.clusterState) {
const clusterStateItems: StateEntry[] = this.processStateMap(componentState.clusterState);
stateItems.push(...clusterStateItems);
}
this.dataSource.data = this.sortStateEntries(stateItems, {
active: this.initialSortColumn,
direction: this.initialSortDirection
});
this.filteredEntries = stateItems.length;
// apply any filtering to the new data
const filterTerm = this.filterForm.get('filterTerm')?.value;
if (filterTerm?.length > 0) {
this.applyFilter(filterTerm);
}
});
}
ngAfterViewInit(): void {
this.filterForm
.get('filterTerm')
?.valueChanges.pipe(debounceTime(500))
.subscribe((filterTerm: string) => {
this.applyFilter(filterTerm);
});
}
processStateMap(stateMap: StateMap): StateEntry[] {
const stateItems: StateEntry[] = stateMap.state ? stateMap.state : [];
if (stateItems.length !== stateMap.totalEntryCount) {
this.partialResults = true;
}
this.totalEntries += stateMap.totalEntryCount;
return stateItems;
}
applyFilter(filterTerm: string) {
this.dataSource.filter = filterTerm.trim().toLowerCase();
this.filteredEntries = this.dataSource.filteredData.length;
}
sortData(sort: Sort) {
this.dataSource.data = this.sortStateEntries(this.dataSource.data, sort);
}
private sortStateEntries(data: StateEntry[], sort: Sort): StateEntry[] {
if (!data) {
return [];
}
return data.slice().sort((a, b) => {
const isAsc = sort.direction === 'asc';
let retVal = 0;
switch (sort.active) {
case 'key':
retVal = this.nifiCommon.compareString(a.key, b.key);
break;
case 'value':
retVal = this.nifiCommon.compareString(a.value, b.value);
break;
}
return retVal * (isAsc ? 1 : -1);
});
}
clearState(): void {
this.store.dispatch(clearComponentState());
}
}

View File

@ -141,7 +141,11 @@
*ngIf="canDelete(item)"
(click)="deleteClicked(item, $event)"
title="Delete"></div>
<div class="pointer fa fa-tasks" *ngIf="canViewState(item)" title="View State"></div>
<div
class="pointer fa fa-tasks"
*ngIf="canViewState(item)"
(click)="viewStateClicked(item)"
title="View State"></div>
<div
class="pointer fa fa-key"
*ngIf="canManageAccessPolicies()"

View File

@ -80,6 +80,8 @@ export class ControllerServiceTable {
new EventEmitter<ControllerServiceEntity>();
@Output() disableControllerService: EventEmitter<ControllerServiceEntity> =
new EventEmitter<ControllerServiceEntity>();
@Output() viewStateControllerService: EventEmitter<ControllerServiceEntity> =
new EventEmitter<ControllerServiceEntity>();
protected readonly TextTip = TextTip;
protected readonly BulletinsTip = BulletinsTip;
@ -251,6 +253,10 @@ export class ControllerServiceTable {
return this.canRead(entity) && this.canWrite(entity) && entity.component.persistsState === true;
}
viewStateClicked(entity: ControllerServiceEntity): void {
this.viewStateControllerService.next(entity);
}
canManageAccessPolicies(): boolean {
return this.flowConfiguration.supportsManagedAuthorizer && this.currentUser.tenantsPermissions.canRead;
}

View File

@ -100,7 +100,6 @@
<button color="accent" mat-raised-button mat-dialog-close>Cancel</button>
<button
[disabled]="selectedType == null || saving"
type="submit"
color="primary"
(click)="createExtension(selectedType)"
mat-raised-button>