[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
This commit is contained in:
Scott Aslan 2024-12-10 16:54:30 -05:00 committed by GitHub
parent a5086a9eb2
commit 0271e926eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 780 additions and 45 deletions

View File

@ -524,7 +524,7 @@ public class StandardNiFiServiceFacade implements NiFiServiceFacade {
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

View File

@ -36,6 +36,7 @@ yarn-error.log
/libpeerconnection.log
testem.log
/typings
/.tool-versions
# System files
.DS_Store

View File

@ -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 }>()

View File

@ -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(

View File

@ -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,

View File

@ -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);

View File

@ -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;

View File

@ -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,

View File

@ -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;
}
}
}
}

View File

@ -15,19 +15,37 @@
~ limitations under the License.
-->
<h2 mat-dialog-title>
<div class="flex justify-between items-baseline">
<div>
{{ readonly ? 'Processor Details' : 'Edit Processor' }}
<h2 id="edit-processor-header" mat-dialog-title>
<div class="flex justify-between items-center">
<div class="flex items-baseline">
<div class="mr-2">
{{ readonly ? 'Processor Details' : 'Edit Processor' }}
</div>
|
<div class="ml-2 text-base">
{{ formatType() }}
</div>
</div>
<div class="text-base">
{{ formatType(request.entity) }}
<div class="flex">
@if (hasBulletins()) {
<div class="w-[48px]">
<div
nifiTooltip
[delayClose]="true"
[tooltipComponentType]="BulletinsTip"
[tooltipInputData]="getBulletinsTipData()"
[position]="getBulletinTooltipPosition()"
[ngClass]="getMostSevereBulletinLevel()"
class="absolute top-0 right-0 text-3xl h-14 w-14 bulletins has-bulletins flex justify-center items-center">
<i class="fa fa-sticky-note-o"></i>
</div>
</div>
}
</div>
</div>
</h2>
<form class="processor-edit-form" [formGroup]="editProcessorForm">
<context-error-banner [context]="ErrorContextKey.PROCESSOR"></context-error-banner>
<!-- TODO - Stop & Configure -->
<mat-tab-group [(selectedIndex)]="selectedIndex" (selectedIndexChange)="tabChanged($event)">
<mat-tab label="Settings">
<mat-dialog-content>
@ -48,13 +66,13 @@
<div class="flex flex-col mb-5">
<div>Type</div>
<div class="tertiary-color font-medium">
{{ formatType(request.entity) }}
{{ formatType() }}
</div>
</div>
<div class="flex flex-col mb-6">
<div>Bundle</div>
<div class="tertiary-color font-medium">
{{ formatBundle(request.entity) }}
{{ formatBundle() }}
</div>
</div>
<div class="flex gap-x-4">
@ -290,19 +308,106 @@
</mat-tab>
</mat-tab-group>
@if ({ value: (saving$ | async)! }; as saving) {
<mat-dialog-actions align="end">
@if (readonly) {
<button mat-flat-button mat-dialog-close>Close</button>
} @else {
<button mat-button mat-dialog-close>Cancel</button>
<button
[disabled]="!editProcessorForm.dirty || editProcessorForm.invalid || saving.value"
type="button"
(click)="submitForm()"
mat-flat-button>
<span *nifiSpinner="saving.value">Apply</span>
</button>
}
<mat-dialog-actions align="start">
<div class="flex w-full justify-between items-center">
<div>
@if (isStoppable()) {
<button
type="button"
[disabled]="!canOperate()"
mat-stroked-button
[matMenuTriggerFor]="operateMenu">
<div class="flex items-center">
<i class="mr-2 success-color-default fa fa-play"></i>Running<i
class="ml-2 -mt-1 fa fa-sort-desc"></i>
</div>
</button>
} @else if (isRunnable()) {
<button
type="button"
[disabled]="!canOperate()"
mat-stroked-button
[matMenuTriggerFor]="operateMenu">
<div class="flex items-center">
<i class="mr-2 error-color-variant fa fa-stop"></i>Stopped<i
class="ml-2 -mt-1 fa fa-sort-desc"></i>
</div>
</button>
} @else if (isDisabled()) {
<button
type="button"
[disabled]="!canOperate()"
mat-stroked-button
[matMenuTriggerFor]="operateMenu">
<div class="flex items-center">
<i class="mr-2 icon icon-enable-false primary-color"></i>Disable<i
class="ml-2 -mt-1 fa fa-sort-desc"></i>
</div>
</button>
} @else if (isStopping()) {
<button type="button" [disabled]="true" mat-stroked-button [matMenuTriggerFor]="operateMenu">
<div class="flex items-center">
<i class="mr-2 fa fa-circle-o-notch fa-spin primary-color"></i>Stopping ({{
status.aggregateSnapshot.activeThreadCount
}})
</div>
</button>
} @else if (isValidating()) {
<button type="button" [disabled]="true" mat-stroked-button [matMenuTriggerFor]="operateMenu">
<div class="flex items-center">
<i class="mr-2 fa fa-circle-o-notch fa-spin primary-color"></i>Validating
</div>
</button>
} @else if (isInvalid()) {
<button
type="button"
mat-stroked-button
[disabled]="!canOperate()"
[matMenuTriggerFor]="operateMenu">
<div class="flex items-center">
<i class="mr-2 fa fa-warning caution-color"></i>Invalid<i
class="ml-2 -mt-1 fa fa-sort-desc"></i>
</div>
</button>
}
<mat-menu #operateMenu="matMenu" xPosition="before">
@if (isStoppable() && canOperate()) {
<button mat-menu-item (click)="stop()">
<i class="mr-2 fa fa-stop primary-color"></i>Stop
</button>
}
@if (isRunnable() && canOperate()) {
<button mat-menu-item [disabled]="editProcessorForm.dirty" (click)="start()">
<i class="mr-2 fa fa-play primary-color"></i>Start
</button>
}
@if (isDisableable() && canOperate()) {
<button mat-menu-item [disabled]="editProcessorForm.dirty" (click)="disable()">
<i class="mr-2 icon icon-enable-false primary-color"></i>Disable
</button>
}
@if (isEnableable() && canOperate()) {
<button mat-menu-item [disabled]="editProcessorForm.dirty" (click)="enable()">
<i class="mr-2 fa fa-flash primary-color"></i>Enable
</button>
}
</mat-menu>
</div>
<div>
@if (readonly) {
<button mat-flat-button mat-dialog-close>Close</button>
} @else {
<button mat-button mat-dialog-close>Cancel</button>
<button
[disabled]="!editProcessorForm.dirty || editProcessorForm.invalid || saving.value"
type="button"
(click)="submitForm()"
mat-flat-button>
<span *nifiSpinner="saving.value">Apply</span>
</button>
}
</div>
</div>
</mat-dialog-actions>
}
</form>

View File

@ -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<Property>;
@Input() createNewService!: (request: InlineServiceCreationRequest) => Observable<InlineServiceCreationResponse>;
@Input() parameterContext: ParameterContextEntity | undefined;
@ -110,11 +126,20 @@ export class EditProcessor extends TabbedDialog {
@Output() verify: EventEmitter<VerifyPropertiesRequestContext> = new EventEmitter<VerifyPropertiesRequestContext>();
@Output() editProcessor: EventEmitter<UpdateProcessorRequest> = new EventEmitter<UpdateProcessorRequest>();
@Output() stopComponentRequest: EventEmitter<StopComponentRequest> = new EventEmitter<StopComponentRequest>();
@Output() startComponentRequest: EventEmitter<StartComponentRequest> = new EventEmitter<StartComponentRequest>();
@Output() disableComponentRequest: EventEmitter<DisableComponentRequest> =
new EventEmitter<DisableComponentRequest>();
@Output() enableComponentRequest: EventEmitter<EnableComponentRequest> = new EventEmitter<EnableComponentRequest>();
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) {

View File

@ -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<string>('clientId');
if (clientId === null) {
clientId = uuidv4();
this.sessionStorage.setItem('clientId', clientId);
}
this.clientId = clientId;
}
public getClientId(): string {
return this.clientId;

View File

@ -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);

View File

@ -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 {

View File

@ -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';

View File

@ -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();
});
});

View File

@ -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<T> {
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<T>(key: string): null | SessionStorageEntry<T> {
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<T>(key: string, item: T): void {
// create the entry
const entry: SessionStorageEntry<T> = {
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<T>(key: string): null | T {
const entry: SessionStorageEntry<T> | 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);
}
}