From 0271e926eba51563e52ceceff393c382b37c4582 Mon Sep 17 00:00:00 2001 From: Scott Aslan Date: Tue, 10 Dec 2024 16:54:30 -0500 Subject: [PATCH] [NIFI-13318] processor stop and configure UX (#9548) * [NIFI-13318] processor stop and configure UX * handle invalid run status * display validation errors * invalid icon and tooltip alternative placement * remove validation errors tooltip, move invalid icon into status button, update edit processor entity and readonly updates * restore MAT_DIALOG_DATA and only enable/disable form controls via api * clean up * only allow updates by current client and poll until stopped and no active threads * update menu options to display when available * display processor bulletins * align dialog header text and run status button * update filter for incoming updated entities and submit appropriate revision on run status changes * disable button when stopping * code clean up * update method name * update error message * update types * add types and cleanup * move run status action button * review feedback * update run status action button to consider when user cannot operate processor * update to also handle disabled run status when user does not have operate * clean up pollingProcessor * disable button when stopping * prettier * prettier * readd thread count * poll when necessary This closes #9548 --- .../nifi/web/StandardNiFiServiceFacade.java | 2 +- nifi-frontend/src/main/frontend/.gitignore | 1 + .../flow-designer/state/flow/flow.actions.ts | 15 ++ .../flow-designer/state/flow/flow.effects.ts | 174 ++++++++++++++ .../flow-designer/state/flow/flow.reducer.ts | 14 +- .../state/flow/flow.selectors.ts | 2 + .../pages/flow-designer/state/flow/index.ts | 5 + .../_flow-status.component-theme.scss | 5 - .../_edit-processor.component-theme.scss | 81 +++++++ .../edit-processor.component.html | 149 ++++++++++-- .../edit-processor.component.ts | 215 ++++++++++++++++-- .../nifi/src/app/service/client.service.ts | 14 +- .../main/frontend/apps/nifi/src/styles.scss | 3 + .../libs/shared/src/assets/styles/_app.scss | 1 + .../libs/shared/src/services/index.ts | 1 + .../services/session-storage.service.spec.ts | 33 +++ .../src/services/session-storage.service.ts | 110 +++++++++ 17 files changed, 780 insertions(+), 45 deletions(-) create mode 100644 nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/processor/edit-processor/_edit-processor.component-theme.scss create mode 100644 nifi-frontend/src/main/frontend/libs/shared/src/services/session-storage.service.spec.ts create mode 100644 nifi-frontend/src/main/frontend/libs/shared/src/services/session-storage.service.ts diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java index 824c28d2e3b5..ad5f200f5d9e 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java @@ -524,7 +524,7 @@ public void verifyRevision(final Revision revision, final NiFiUser user) { return; } - throw new InvalidRevisionException(revision + " is not the most up-to-date revision. This component appears to have been modified"); + throw new InvalidRevisionException(revision + " is not the most up-to-date revision. This component appears to have been modified. Retrieve the most up-to-date revision and try again."); } @Override diff --git a/nifi-frontend/src/main/frontend/.gitignore b/nifi-frontend/src/main/frontend/.gitignore index 8224631ef27c..581343e40384 100644 --- a/nifi-frontend/src/main/frontend/.gitignore +++ b/nifi-frontend/src/main/frontend/.gitignore @@ -36,6 +36,7 @@ yarn-error.log /libpeerconnection.log testem.log /typings +/.tool-versions # System files .DS_Store diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts index a5882f53964d..4850df9ab567 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts @@ -87,6 +87,7 @@ import { StartComponentRequest, StartComponentResponse, StartComponentsRequest, + StartPollingProcessorUntilStoppedRequest, StartProcessGroupRequest, StartProcessGroupResponse, StopComponentRequest, @@ -776,6 +777,20 @@ export const pollChangeVersionSuccess = createAction( export const stopPollingChangeVersion = createAction(`${CANVAS_PREFIX} Stop Polling Change Version`); +export const startPollingProcessorUntilStopped = createAction( + `${CANVAS_PREFIX} Start Polling Processor Until Stopped`, + props<{ request: StartPollingProcessorUntilStoppedRequest }>() +); + +export const pollProcessorUntilStopped = createAction(`${CANVAS_PREFIX} Poll Processor Until Stopped`); + +export const pollProcessorUntilStoppedSuccess = createAction( + `${CANVAS_PREFIX} Poll Processor Until Stopped Success`, + props<{ response: LoadProcessorSuccess }>() +); + +export const stopPollingProcessor = createAction(`${CANVAS_PREFIX} Stop Polling Processor`); + export const openSaveVersionDialog = createAction( `${CANVAS_PREFIX} Open Save Flow Version Dialog`, props<{ request: SaveVersionDialogRequest }>() diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts index 576f57114b8c..7819a895be41 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts @@ -45,6 +45,8 @@ import { CreateConnectionDialogRequest, CreateProcessGroupDialogRequest, DeleteComponentResponse, + DisableComponentRequest, + EnableComponentRequest, GroupComponentsDialogRequest, ImportFromRegistryDialogRequest, LoadProcessGroupResponse, @@ -56,6 +58,8 @@ import { SaveVersionRequest, SelectedComponent, Snippet, + StartComponentRequest, + StopComponentRequest, StopVersionControlRequest, StopVersionControlResponse, UpdateComponentFailure, @@ -80,6 +84,7 @@ import { selectParentProcessGroupId, selectProcessGroup, selectProcessor, + selectPollingProcessor, selectRefreshRpgDetails, selectRemoteProcessGroup, selectSaving, @@ -160,6 +165,13 @@ import { selectDocumentVisibilityState } from '../../../../state/document-visibi import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DocumentVisibility } from '../../../../state/document-visibility'; import { ErrorContextKey } from '../../../../state/error'; +import { + disableComponent, + enableComponent, + startComponent, + startPollingProcessorUntilStopped, + stopComponent +} from './flow.actions'; import { CopyPasteService } from '../../service/copy-paste.service'; import { selectCopiedContent } from '../../../../state/copy/copy.selectors'; import { CopyRequestContext, CopyResponseContext } from '../../../../state/copy'; @@ -1428,6 +1440,7 @@ export class FlowEffects { }), tap(([request, parameterContext, processGroupId]) => { const processorId: string = request.entity.id; + let runStatusChanged: boolean = false; const editDialogReference = this.dialog.open(EditProcessor, { ...XL_DIALOG, @@ -1555,6 +1568,116 @@ export class FlowEffects { }) ); }); + const startPollingIfNecessary = (processorEntity: any): boolean => { + if ( + (processorEntity.status.aggregateSnapshot.runStatus === 'Stopped' && + processorEntity.status.aggregateSnapshot.activeThreadCount > 0) || + processorEntity.status.aggregateSnapshot.runStatus === 'Validating' + ) { + this.store.dispatch( + startPollingProcessorUntilStopped({ + request: { + id: processorEntity.id + } + }) + ); + return true; + } + + return false; + }; + + const pollingStarted = startPollingIfNecessary(request.entity); + + this.store + .select(selectProcessor(processorId)) + .pipe( + takeUntil(editDialogReference.afterClosed()), + isDefinedAndNotNull(), + filter((processorEntity) => { + return ( + (runStatusChanged || pollingStarted) && + processorEntity.revision.clientId === this.client.getClientId() + ); + }), + concatLatestFrom(() => this.store.select(selectPollingProcessor)) + ) + .subscribe(([processorEntity, pollingProcessor]) => { + editDialogReference.componentInstance.processorUpdates = processorEntity; + + // if we're already polling we do not want to start polling again + if (!pollingProcessor) { + startPollingIfNecessary(processorEntity); + } + }); + + editDialogReference.componentInstance.stopComponentRequest + .pipe(takeUntil(editDialogReference.afterClosed())) + .subscribe((stopComponentRequest: StopComponentRequest) => { + runStatusChanged = true; + this.store.dispatch( + stopComponent({ + request: { + id: stopComponentRequest.id, + uri: stopComponentRequest.uri, + type: ComponentType.Processor, + revision: stopComponentRequest.revision, + errorStrategy: 'snackbar' + } + }) + ); + }); + + editDialogReference.componentInstance.disableComponentRequest + .pipe(takeUntil(editDialogReference.afterClosed())) + .subscribe((disableComponentsRequest: DisableComponentRequest) => { + runStatusChanged = true; + this.store.dispatch( + disableComponent({ + request: { + id: disableComponentsRequest.id, + uri: disableComponentsRequest.uri, + type: ComponentType.Processor, + revision: disableComponentsRequest.revision, + errorStrategy: 'snackbar' + } + }) + ); + }); + + editDialogReference.componentInstance.enableComponentRequest + .pipe(takeUntil(editDialogReference.afterClosed())) + .subscribe((enableComponentsRequest: EnableComponentRequest) => { + runStatusChanged = true; + this.store.dispatch( + enableComponent({ + request: { + id: enableComponentsRequest.id, + uri: enableComponentsRequest.uri, + type: ComponentType.Processor, + revision: enableComponentsRequest.revision, + errorStrategy: 'snackbar' + } + }) + ); + }); + + editDialogReference.componentInstance.startComponentRequest + .pipe(takeUntil(editDialogReference.afterClosed())) + .subscribe((startComponentRequest: StartComponentRequest) => { + runStatusChanged = true; + this.store.dispatch( + startComponent({ + request: { + id: startComponentRequest.id, + uri: startComponentRequest.uri, + type: ComponentType.Processor, + revision: startComponentRequest.revision, + errorStrategy: 'snackbar' + } + }) + ); + }); editDialogReference.afterClosed().subscribe((response) => { this.store.dispatch(resetPropertyVerificationState()); @@ -1578,6 +1701,57 @@ export class FlowEffects { { dispatch: false } ); + startPollingProcessorUntilStopped = createEffect(() => + this.actions$.pipe( + ofType(FlowActions.startPollingProcessorUntilStopped), + switchMap(() => + interval(2000, asyncScheduler).pipe( + takeUntil(this.actions$.pipe(ofType(FlowActions.stopPollingProcessor))) + ) + ), + switchMap(() => of(FlowActions.pollProcessorUntilStopped())) + ) + ); + + pollProcessorUntilStopped$ = createEffect(() => + this.actions$.pipe( + ofType(FlowActions.pollProcessorUntilStopped), + concatLatestFrom(() => [this.store.select(selectPollingProcessor).pipe(isDefinedAndNotNull())]), + switchMap(([, pollingProcessor]) => { + return from( + this.flowService.getProcessor(pollingProcessor.id).pipe( + map((response) => + FlowActions.pollProcessorUntilStoppedSuccess({ + response: { + id: pollingProcessor.id, + processor: response + } + }) + ), + catchError((errorResponse: HttpErrorResponse) => { + this.store.dispatch(FlowActions.stopPollingProcessor()); + return of(this.snackBarOrFullScreenError(errorResponse)); + }) + ) + ); + }) + ) + ); + + pollProcessorUntilStoppedSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(FlowActions.pollProcessorUntilStoppedSuccess), + map((action) => action.response), + filter((response) => { + return ( + response.processor.status.runStatus === 'Stopped' && + response.processor.status.aggregateSnapshot.activeThreadCount === 0 + ); + }), + switchMap(() => of(FlowActions.stopPollingProcessor())) + ) + ); + openEditConnectionDialog$ = createEffect( () => this.actions$.pipe( diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts index cd3ec9f31bbf..4cff6a7b2d79 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts @@ -50,6 +50,7 @@ import { navigateWithoutTransform, pasteSuccess, pollChangeVersionSuccess, + pollProcessorUntilStoppedSuccess, pollRevertChangesSuccess, requestRefreshRemoteProcessGroup, resetFlowState, @@ -68,10 +69,12 @@ import { setTransitionRequired, startComponent, startComponentSuccess, + startPollingProcessorUntilStopped, startProcessGroupSuccess, startRemoteProcessGroupPolling, stopComponent, stopComponentSuccess, + stopPollingProcessor, stopProcessGroupSuccess, stopRemoteProcessGroupPolling, stopVersionControl, @@ -92,6 +95,7 @@ import { produce } from 'immer'; export const initialState: FlowState = { id: 'root', changeVersionRequest: null, + pollingProcessor: null, flow: { revision: { version: 0 @@ -297,7 +301,7 @@ export const flowReducer = createReducer( } }); }), - on(loadProcessorSuccess, (state, { response }) => { + on(loadProcessorSuccess, pollProcessorUntilStoppedSuccess, (state, { response }) => { return produce(state, (draftState) => { const proposedProcessor = response.processor; const componentIndex: number = draftState.flow.processGroupFlow.flow.processors.findIndex( @@ -373,6 +377,14 @@ export const flowReducer = createReducer( saving: false, versionSaving: false })), + on(startPollingProcessorUntilStopped, (state, { request }) => ({ + ...state, + pollingProcessor: request + })), + on(stopPollingProcessor, (state) => ({ + ...state, + pollingProcessor: null + })), on( createProcessor, createProcessGroup, diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts index 101dcb8c771a..31e04c68e745 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts @@ -30,6 +30,8 @@ export const selectChangeVersionRequest = createSelector( (state: FlowState) => state.changeVersionRequest ); +export const selectPollingProcessor = createSelector(selectFlowState, (state: FlowState) => state.pollingProcessor); + export const selectSaving = createSelector(selectFlowState, (state: FlowState) => state.saving); export const selectVersionSaving = createSelector(selectFlowState, (state: FlowState) => state.versionSaving); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts index eea1eea2332a..5160c70c8f54 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts @@ -659,6 +659,7 @@ export interface FlowState { flowAnalysisOpen: boolean; versionSaving: boolean; changeVersionRequest: FlowUpdateRequestEntity | null; + pollingProcessor: StartPollingProcessorUntilStoppedRequest | null; status: 'pending' | 'loading' | 'success' | 'complete'; } @@ -792,6 +793,10 @@ export interface StopComponentRequest { errorStrategy: 'snackbar' | 'banner'; } +export interface StartPollingProcessorUntilStoppedRequest { + id: string; +} + export interface StopProcessGroupRequest { id: string; type: ComponentType; diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/header/flow-status/_flow-status.component-theme.scss b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/header/flow-status/_flow-status.component-theme.scss index d753fa35ad49..08e581d217e8 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/header/flow-status/_flow-status.component-theme.scss +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/header/flow-status/_flow-status.component-theme.scss @@ -40,11 +40,6 @@ neutral, map.get(map.get($config, neutral), lighter) ); - $material-theme-neutral-palette-default: mat.get-theme-color( - $material-theme, - neutral, - map.get(map.get($config, neutral), default) - ); $material-theme-primary-palette-default: mat.get-theme-color( $material-theme, diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/processor/edit-processor/_edit-processor.component-theme.scss b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/processor/edit-processor/_edit-processor.component-theme.scss new file mode 100644 index 000000000000..f7cfc4563f2b --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/processor/edit-processor/_edit-processor.component-theme.scss @@ -0,0 +1,81 @@ +/* + * 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 'sass:map'; +@use '@angular/material' as mat; + +@mixin generate-theme($material-theme, $config) { + $is-material-dark: if(mat.get-theme-type($material-theme) == dark, true, false); + $material-theme-secondary-palette-default: mat.get-theme-color( + $material-theme, + secondary, + map.get(map.get($config, secondary), default) + ); + $material-theme-error-palette-default: mat.get-theme-color( + $material-theme, + error, + map.get(map.get($config, error), default) + ); + + $material-theme-primary-palette-default: mat.get-theme-color( + $material-theme, + primary, + map.get(map.get($config, primary), default) + ); + + $primary-contrast: map.get(map.get($config, primary), contrast); + $caution-contrast: map.get(map.get($config, caution), contrast); + $error-contrast: map.get(map.get($config, error), contrast); + $success: map.get(map.get($config, success), default); + $caution: map.get(map.get($config, caution), default); + + #edit-processor-header { + .bulletins { + background-color: unset; + + .fa { + color: $material-theme-primary-palette-default; + } + } + + .bulletins.has-bulletins { + .fa { + color: $primary-contrast; + } + + &.error { + .fa { + color: $error-contrast; + } + + background-color: $material-theme-error-palette-default; + } + &.warning { + .fa { + color: $caution-contrast; + } + + background-color: $caution; + } + &.info, + &.debug, + &.trace { + background-color: $success; + } + } + } +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/processor/edit-processor/edit-processor.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/processor/edit-processor/edit-processor.component.html index 93a9b035fe30..c9ccb9e62254 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/processor/edit-processor/edit-processor.component.html +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/processor/edit-processor/edit-processor.component.html @@ -15,19 +15,37 @@ ~ limitations under the License. --> -

-
-
- {{ readonly ? 'Processor Details' : 'Edit Processor' }} +

+
+
+
+ {{ readonly ? 'Processor Details' : 'Edit Processor' }} +
+ | +
+ {{ formatType() }} +
-
- {{ formatType(request.entity) }} +
+ @if (hasBulletins()) { +
+
+ +
+
+ }

- @@ -48,13 +66,13 @@

Type
- {{ formatType(request.entity) }} + {{ formatType() }}
Bundle
- {{ formatBundle(request.entity) }} + {{ formatBundle() }}
@@ -290,19 +308,106 @@

@if ({ value: (saving$ | async)! }; as saving) { - - @if (readonly) { - - } @else { - - - } + +
+
+ @if (isStoppable()) { + + } @else if (isRunnable()) { + + } @else if (isDisabled()) { + + } @else if (isStopping()) { + + } @else if (isValidating()) { + + } @else if (isInvalid()) { + + } + + @if (isStoppable() && canOperate()) { + + } + @if (isRunnable() && canOperate()) { + + } + @if (isDisableable() && canOperate()) { + + } + @if (isEnableable() && canOperate()) { + + } + +
+
+ @if (readonly) { + + } @else { + + + } +
+
} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/processor/edit-processor/edit-processor.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/processor/edit-processor/edit-processor.component.ts index b238e9ecc8fe..b1264b036d29 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/processor/edit-processor/edit-processor.component.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/processor/edit-processor/edit-processor.component.ts @@ -17,6 +17,7 @@ import { Component, EventEmitter, Inject, Input, Output } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatMenuModule } from '@angular/material/menu'; import { AbstractControl, FormBuilder, @@ -30,19 +31,29 @@ import { import { MatInputModule } from '@angular/material/input'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatButtonModule } from '@angular/material/button'; -import { AsyncPipe } from '@angular/common'; +import { AsyncPipe, NgClass } from '@angular/common'; import { MatTabsModule } from '@angular/material/tabs'; import { MatOptionModule } from '@angular/material/core'; import { MatSelectModule } from '@angular/material/select'; import { Observable, of } from 'rxjs'; import { + BulletinEntity, + BulletinsTipInput, InlineServiceCreationRequest, InlineServiceCreationResponse, ParameterContextEntity, - Property + Property, + Revision } from '../../../../../../../state/shared'; import { Client } from '../../../../../../../service/client.service'; -import { EditComponentDialogRequest, UpdateProcessorRequest } from '../../../../../state/flow'; +import { + DisableComponentRequest, + EditComponentDialogRequest, + EnableComponentRequest, + StartComponentRequest, + StopComponentRequest, + UpdateProcessorRequest +} from '../../../../../state/flow'; import { PropertyTable } from '../../../../../../../ui/common/property-table/property-table.component'; import { NifiSpinnerDirective } from '../../../../../../../ui/common/spinner/nifi-spinner.directive'; import { NifiTooltipDirective, NiFiCommon, TextTip, CopyDirective } from '@nifi/shared'; @@ -51,7 +62,6 @@ import { RelationshipConfiguration, RelationshipSettings } from './relationship-settings/relationship-settings.component'; -import { ErrorBanner } from '../../../../../../../ui/common/error-banner/error-banner.component'; import { ClusterConnectionService } from '../../../../../../../service/cluster-connection.service'; import { CanvasUtils } from '../../../../../service/canvas-utils.service'; import { ConvertToParameterResponse } from '../../../../../service/parameter-helper.service'; @@ -65,6 +75,8 @@ import { TabbedDialog } from '../../../../../../../ui/common/tabbed-dialog/tabbe import { ComponentType, SelectOption } from 'libs/shared/src'; import { ErrorContextKey } from '../../../../../../../state/error'; import { ContextErrorBanner } from '../../../../../../../ui/common/context-error-banner/context-error-banner.component'; +import { BulletinsTip } from '../../../../../../../ui/common/tooltips/bulletins-tip/bulletins-tip.component'; +import { ConnectedPosition } from '@angular/cdk/overlay'; @Component({ selector: 'edit-processor', @@ -79,20 +91,24 @@ import { ContextErrorBanner } from '../../../../../../../ui/common/context-error MatTabsModule, MatOptionModule, MatSelectModule, + MatMenuModule, AsyncPipe, PropertyTable, NifiSpinnerDirective, NifiTooltipDirective, RunDurationSlider, RelationshipSettings, - ErrorBanner, PropertyVerification, ContextErrorBanner, - CopyDirective + CopyDirective, + NgClass ], styleUrls: ['./edit-processor.component.scss'] }) export class EditProcessor extends TabbedDialog { + @Input() set processorUpdates(processorUpdates: any | undefined) { + this.processRunStateUpdates(processorUpdates); + } @Input() createNewProperty!: (existingProperties: string[], allowsSensitive: boolean) => Observable; @Input() createNewService!: (request: InlineServiceCreationRequest) => Observable; @Input() parameterContext: ParameterContextEntity | undefined; @@ -110,11 +126,20 @@ export class EditProcessor extends TabbedDialog { @Output() verify: EventEmitter = new EventEmitter(); @Output() editProcessor: EventEmitter = new EventEmitter(); + @Output() stopComponentRequest: EventEmitter = new EventEmitter(); + @Output() startComponentRequest: EventEmitter = new EventEmitter(); + @Output() disableComponentRequest: EventEmitter = + new EventEmitter(); + @Output() enableComponentRequest: EventEmitter = new EventEmitter(); protected readonly TextTip = TextTip; + protected readonly BulletinsTip = BulletinsTip; editProcessorForm: FormGroup; - readonly: boolean; + readonly: boolean = true; + status: any; + revision!: Revision; + bulletins!: BulletinEntity[]; bulletinLevels = [ { @@ -182,9 +207,6 @@ export class EditProcessor extends TabbedDialog { ) { super('edit-processor-selected-index'); - this.readonly = - !request.entity.permissions.canWrite || !this.canvasUtils.runnableSupportsModification(request.entity); - const processorProperties: any = request.entity.component.config.properties; const properties: Property[] = Object.entries(processorProperties).map((entry: any) => { const [property, value] = entry; @@ -253,6 +275,32 @@ export class EditProcessor extends TabbedDialog { new FormControl({ value: this.runDurationMillis, disabled: this.readonly }, Validators.required) ); } + + this.processRunStateUpdates(request.entity); + } + + private processRunStateUpdates(entity: any) { + this.status = entity.status; + this.revision = entity.revision; + this.bulletins = entity.bulletins; + + this.readonly = !entity.permissions.canWrite || !this.canvasUtils.runnableSupportsModification(entity); + + if (this.readonly) { + this.editProcessorForm.get('properties')?.disable(); + this.editProcessorForm.get('relationshipConfiguration')?.disable(); + + if (this.supportsBatching()) { + this.editProcessorForm.get('runDuration')?.disable(); + } + } else { + this.editProcessorForm.get('properties')?.enable(); + this.editProcessorForm.get('relationshipConfiguration')?.enable(); + + if (this.supportsBatching()) { + this.editProcessorForm.get('runDuration')?.enable(); + } + } } private relationshipConfigurationValidator(): ValidatorFn { @@ -288,12 +336,12 @@ export class EditProcessor extends TabbedDialog { return this.request.entity.component.supportsBatching == true; } - formatType(entity: any): string { - return this.nifiCommon.formatType(entity.component); + formatType(): string { + return this.nifiCommon.formatType(this.request.entity.component); } - formatBundle(entity: any): string { - return this.nifiCommon.formatBundle(entity.component.bundle); + formatBundle(): string { + return this.nifiCommon.formatBundle(this.request.entity.component.bundle); } concurrentTasksChanged(): void { @@ -351,7 +399,10 @@ export class EditProcessor extends TabbedDialog { .map((relationship) => relationship.name); const payload: any = { - revision: this.client.getRevision(this.request.entity), + revision: this.client.getRevision({ + ...this.request.entity, + revision: this.revision + }), disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged(), component: { id: this.request.entity.id, @@ -401,6 +452,140 @@ export class EditProcessor extends TabbedDialog { }); } + hasBulletins(): boolean { + return this.request.entity.permissions.canRead && !this.nifiCommon.isEmpty(this.bulletins); + } + + getBulletinsTipData(): BulletinsTipInput { + return { + bulletins: this.bulletins + }; + } + + getBulletinTooltipPosition(): ConnectedPosition { + return { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + offsetX: -8, + offsetY: 8 + }; + } + + getMostSevereBulletinLevel(): string | null { + // determine the most severe of the bulletins + const mostSevere = this.canvasUtils.getMostSevereBulletin(this.bulletins); + return mostSevere ? mostSevere.bulletin.level.toLowerCase() : null; + } + + isStoppable(): boolean { + return this.status.aggregateSnapshot.runStatus === 'Running'; + } + + isStopping(): boolean { + return ( + this.status.aggregateSnapshot.runStatus === 'Stopped' && this.status.aggregateSnapshot.activeThreadCount > 0 + ); + } + + isValidating(): boolean { + return this.status.aggregateSnapshot.runStatus === 'Validating'; + } + + isInvalid(): boolean { + return this.status.aggregateSnapshot.runStatus === 'Invalid'; + } + + isDisabled(): boolean { + return this.status.aggregateSnapshot.runStatus === 'Disabled'; + } + + isRunnable(): boolean { + return ( + !( + this.status.aggregateSnapshot.runStatus === 'Running' || + this.status.aggregateSnapshot.activeThreadCount > 0 + ) && this.status.aggregateSnapshot.runStatus === 'Stopped' + ); + } + + isDisableable(): boolean { + return ( + !( + this.status.aggregateSnapshot.runStatus === 'Running' || + this.status.aggregateSnapshot.activeThreadCount > 0 + ) && + (this.status.aggregateSnapshot.runStatus === 'Stopped' || + this.status.aggregateSnapshot.runStatus === 'Invalid') + ); + } + + isEnableable(): boolean { + return ( + !( + this.status.aggregateSnapshot.runStatus === 'Running' || + this.status.aggregateSnapshot.activeThreadCount > 0 + ) && this.status.aggregateSnapshot.runStatus === 'Disabled' + ); + } + + canOperate(): boolean { + return this.request.entity.permissions.canWrite || this.request.entity.operatePermissions?.canWrite; + } + + stop() { + this.stopComponentRequest.next({ + id: this.request.entity.id, + uri: this.request.entity.uri, + type: ComponentType.Processor, + revision: this.client.getRevision({ + ...this.request.entity, + revision: this.revision + }), + errorStrategy: 'snackbar' + }); + } + + start() { + this.startComponentRequest.next({ + id: this.request.entity.id, + uri: this.request.entity.uri, + type: ComponentType.Processor, + revision: this.client.getRevision({ + ...this.request.entity, + revision: this.revision + }), + errorStrategy: 'snackbar' + }); + } + + disable() { + this.disableComponentRequest.next({ + id: this.request.entity.id, + uri: this.request.entity.uri, + type: ComponentType.Processor, + revision: this.client.getRevision({ + ...this.request.entity, + revision: this.revision + }), + errorStrategy: 'snackbar' + }); + } + + enable() { + this.enableComponentRequest.next({ + id: this.request.entity.id, + uri: this.request.entity.uri, + type: ComponentType.Processor, + revision: this.client.getRevision({ + ...this.request.entity, + revision: this.revision + }), + errorStrategy: 'snackbar' + }); + } + private getModifiedProperties(): ModifiedProperties { const propertyControl: AbstractControl | null = this.editProcessorForm.get('properties'); if (propertyControl && propertyControl.dirty) { diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/client.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/client.service.ts index 99704c4736f6..fe2d7167d532 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/client.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/client.service.ts @@ -17,12 +17,24 @@ import { Injectable } from '@angular/core'; import { v4 as uuidv4 } from 'uuid'; +import { SessionStorageService } from '@nifi/shared'; @Injectable({ providedIn: 'root' }) export class Client { - private clientId: string = uuidv4(); + private clientId: string; + + constructor(private sessionStorage: SessionStorageService) { + let clientId = this.sessionStorage.getItem('clientId'); + + if (clientId === null) { + clientId = uuidv4(); + this.sessionStorage.setItem('clientId', clientId); + } + + this.clientId = clientId; + } public getClientId(): string { return this.clientId; diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/styles.scss b/nifi-frontend/src/main/frontend/apps/nifi/src/styles.scss index 8ce41675cf8f..3ae320876ca0 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/styles.scss +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/styles.scss @@ -33,6 +33,7 @@ birdseye-control; @use 'app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component-theme' as operation-control; +@use 'app/pages/flow-designer/ui/canvas/items/processor/edit-processor/edit-processor.component-theme' as edit-processor; @use 'app/pages/flow-designer/ui/canvas/header/flow-status/flow-status.component-theme' as flow-status; @use 'app/pages/flow-designer/ui/canvas/header/new-canvas-item/new-canvas-item.component-theme' as new-canvas-item; @use 'app/pages/flow-designer/ui/canvas/header/search/search.component-theme' as search; @@ -94,6 +95,7 @@ html { @include navigation-control.generate-theme($m3-light-theme, $m3-light-theme-config); @include operation-control.generate-theme($m3-light-theme, $m3-light-theme-config); @include flow-status.generate-theme($m3-light-theme, $m3-light-theme-config); + @include edit-processor.generate-theme($m3-light-theme, $m3-light-theme-config); @include violation-details-dialog.generate-theme($m3-light-theme, $m3-light-theme-config); @include new-canvas-item.generate-theme($m3-light-theme, $m3-light-theme-config); @include search.generate-theme($m3-light-theme, $m3-light-theme-config); @@ -127,6 +129,7 @@ html { @include navigation-control.generate-theme($m3-dark-theme, $m3-dark-theme-config); @include operation-control.generate-theme($m3-dark-theme, $m3-dark-theme-config); @include flow-status.generate-theme($m3-dark-theme, $m3-dark-theme-config); + @include edit-processor.generate-theme($m3-dark-theme, $m3-dark-theme-config); @include violation-details-dialog.generate-theme($m3-dark-theme, $m3-dark-theme-config); @include new-canvas-item.generate-theme($m3-dark-theme, $m3-dark-theme-config); @include search.generate-theme($m3-dark-theme, $m3-dark-theme-config); diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss b/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss index 58709319c30d..89beaac405f4 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss +++ b/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss @@ -161,6 +161,7 @@ --mdc-outlined-button-label-text-tracking: normal; --mdc-outlined-button-label-text-weight: 400; --mat-outlined-button-horizontal-padding: 15px; + --mdc-outlined-button-container-height: 32px; } .mat-mdc-tab-header { diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/services/index.ts b/nifi-frontend/src/main/frontend/libs/shared/src/services/index.ts index 5e5054972e6c..8572877ade9e 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/services/index.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/services/index.ts @@ -17,5 +17,6 @@ export * from './nifi-common.service'; export * from './storage.service'; +export * from './session-storage.service'; export * from './theming.service'; export * from './map-table-helper.service'; diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/services/session-storage.service.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/services/session-storage.service.spec.ts new file mode 100644 index 000000000000..231824f15daa --- /dev/null +++ b/nifi-frontend/src/main/frontend/libs/shared/src/services/session-storage.service.spec.ts @@ -0,0 +1,33 @@ +/* + * 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 { TestBed } from '@angular/core/testing'; + +import { SessionStorageService } from './session-storage.service'; + +describe('SessionStorageService', () => { + let service: SessionStorageService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SessionStorageService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/services/session-storage.service.ts b/nifi-frontend/src/main/frontend/libs/shared/src/services/session-storage.service.ts new file mode 100644 index 000000000000..31d36ac4438e --- /dev/null +++ b/nifi-frontend/src/main/frontend/libs/shared/src/services/session-storage.service.ts @@ -0,0 +1,110 @@ +/* + * 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'; + +interface SessionStorageEntry { + item: T; +} + +@Injectable({ + providedIn: 'root' +}) +export class SessionStorageService { + /** + * Gets an entry for the key. The entry expiration is not checked. + * + * @param {string} key + */ + private getEntry(key: string): null | SessionStorageEntry { + try { + // parse the entry + const item = sessionStorage.getItem(key); + if (!item) { + return null; + } + + const entry = JSON.parse(item); + + // ensure the entry is present + if (entry) { + return entry; + } else { + return null; + } + } catch (e) { + return null; + } + } + + /** + * Stores the specified item. + * + * @param {string} key + * @param {object} item + */ + public setItem(key: string, item: T): void { + // create the entry + const entry: SessionStorageEntry = { + item + }; + + // store the item + sessionStorage.setItem(key, JSON.stringify(entry)); + } + + /** + * Returns whether there is an entry for this key. This will not check the expiration. If + * the entry is expired, it will return null on a subsequent getItem invocation. + * + * @param {string} key + * @returns {boolean} + */ + public hasItem(key: string): boolean { + return this.getEntry(key) !== null; + } + + /** + * Gets the item with the specified key. If an item with this key does + * not exist, null is returned. If an item exists but cannot be parsed + * or is malformed/unrecognized, null is returned. + * + * @param {type} key + */ + public getItem(key: string): null | T { + const entry: SessionStorageEntry | null = this.getEntry(key); + if (entry === null) { + return null; + } + + // if the entry has the specified field return its value + if (entry['item']) { + return entry['item']; + } else { + return null; + } + } + + /** + * Removes the item with the specified key. + * + * @param {string} key + */ + public removeItem(key: string): void { + sessionStorage.removeItem(key); + } +}