This is an automated email from the ASF dual-hosted git repository.
rfellows pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 2ea99bec1a3 NIFI-15937: Adding support for polling in the connector
canvas. (#11246)
2ea99bec1a3 is described below
commit 2ea99bec1a3b753229a02959e59e5e0c0a138a18
Author: Matt Gilman <[email protected]>
AuthorDate: Wed May 13 14:19:00 2026 -0400
NIFI-15937: Adding support for polling in the connector canvas. (#11246)
---
.../connector-canvas/connector-canvas.actions.ts | 28 ++
.../connector-canvas.effects.spec.ts | 376 ++++++++++++++++++++-
.../connector-canvas/connector-canvas.effects.ts | 105 +++++-
.../connector-canvas.component.spec.ts | 199 +++++++++++
.../connector-canvas/connector-canvas.component.ts | 5 +
5 files changed, 708 insertions(+), 5 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
index 0a580dbdabb..23ba9fb5f2e 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
@@ -65,6 +65,34 @@ export const loadConnectorFlowFailure = createAction(
export const loadConnectorFlowComplete = createAction('[Connector Canvas] Load
Connector Flow Complete');
+/**
+ * Reload the currently displayed connector flow.
+ *
+ * The polling effect dispatches this action; the corresponding effect
throttles
+ * and resolves the connector and process group identifiers from state before
+ * delegating to {@link loadConnectorFlow}. Keeping the indirection makes the
+ * polling source independent of which connector and process group are
currently
+ * mounted, mirroring the flow-designer's reloadFlow pattern.
+ */
+export const reloadConnectorFlow = createAction('[Connector Canvas] Reload
Connector Flow');
+
+/**
+ * Begin periodic refresh of the connector flow on the currently mounted
canvas.
+ *
+ * The component dispatches this action in ngOnInit; the polling effect drives
a
+ * 30 second interval, gates on document visibility and on the absence of an
+ * in-flight load, and dispatches {@link reloadConnectorFlow}.
+ */
+export const startConnectorCanvasPolling = createAction('[Connector Canvas]
Start Connector Canvas Polling');
+
+/**
+ * Stop the periodic refresh started by {@link startConnectorCanvasPolling}.
+ *
+ * Dispatched by the component in ngOnDestroy (and may be dispatched explicitly
+ * by other effects that need to suppress polling around a destructive
operation).
+ */
+export const stopConnectorCanvasPolling = createAction('[Connector Canvas]
Stop Connector Canvas Polling');
+
export const enterProcessGroup = createAction(
'[Connector Canvas] Enter Process Group',
props<{ request: { id: string } }>()
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
index 04418e70862..797cccb38c4 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
@@ -41,16 +41,23 @@ import {
navigateToProvenanceForComponent,
navigateToQueueListing,
navigateWithoutTransform,
+ reloadConnectorFlow,
selectComponents,
+ startConnectorCanvasPolling,
+ stopConnectorCanvasPolling,
viewComponentConfiguration
} from './connector-canvas.actions';
import { queueEmptied } from
'../../../../state/empty-queue/empty-queue.actions';
import {
+ selectConnectorId,
selectConnectorIdFromRoute,
+ selectLoadingStatus,
selectParentProcessGroupId,
selectProcessGroupId,
selectProcessGroupIdFromRoute
} from './connector-canvas.selectors';
+import { selectDocumentVisibilityState } from
'../../../../state/document-visibility/document-visibility.selectors';
+import { DocumentVisibility } from '../../../../state/document-visibility';
import type { Mock } from 'vitest';
describe('ConnectorCanvasEffects', () => {
@@ -61,6 +68,8 @@ describe('ConnectorCanvasEffects', () => {
parentProcessGroupId?: string | null;
processGroupId?: string | null;
processGroupIdFromRoute?: string | null;
+ documentVisibility?: DocumentVisibility;
+ loadingStatus?: 'pending' | 'loading' | 'success' | 'error';
} = {}
) {
let actions$: Observable<Action>;
@@ -71,6 +80,8 @@ describe('ConnectorCanvasEffects', () => {
const processGroupId = options.processGroupId !== undefined ?
options.processGroupId : 'child-pg';
const processGroupIdFromRoute =
options.processGroupIdFromRoute !== undefined ?
options.processGroupIdFromRoute : processGroupId;
+ const documentVisibility = options.documentVisibility ??
DocumentVisibility.Visible;
+ const loadingStatus = options.loadingStatus ?? 'success';
// Mock services
const mockConnectorService = {
@@ -98,9 +109,15 @@ describe('ConnectorCanvasEffects', () => {
initialState: {},
selectors: [
{ selector: selectConnectorIdFromRoute, value:
connectorId },
+ { selector: selectConnectorId, value: connectorId },
{ selector: selectParentProcessGroupId, value:
parentProcessGroupId },
{ selector: selectProcessGroupId, value:
processGroupId },
- { selector: selectProcessGroupIdFromRoute, value:
processGroupIdFromRoute }
+ { selector: selectProcessGroupIdFromRoute, value:
processGroupIdFromRoute },
+ { selector: selectLoadingStatus, value: loadingStatus
},
+ {
+ selector: selectDocumentVisibilityState,
+ value: { documentVisibility, changedTimestamp: 0 }
+ }
]
}),
{ provide: ConnectorService, useValue: mockConnectorService },
@@ -537,6 +554,19 @@ describe('ConnectorCanvasEffects', () => {
const { mockDialog } = await dispatchView(ComponentType.Funnel);
expect(mockDialog.open).not.toHaveBeenCalled();
});
+
+ it('handles entities without operatePermissions by forcing
operatePermissions.canWrite to false', async () => {
+ const entityWithoutOperatePermissions = {
+ id: 'comp-1',
+ uri: 'https://localhost/nifi-api/processors/comp-1',
+ permissions: { canRead: true, canWrite: true },
+ component: { name: 'My Component' }
+ };
+ const { mockDialog } = await dispatchView(ComponentType.Processor,
entityWithoutOperatePermissions);
+ const [, config] = (mockDialog.open as Mock).mock.calls[0];
+ expect(config.data.entity.operatePermissions).toBeDefined();
+ expect(config.data.entity.operatePermissions.canWrite).toBe(false);
+ });
});
describe('navigateToProvenanceForComponent$', () => {
@@ -567,6 +597,34 @@ describe('ConnectorCanvasEffects', () => {
}
});
});
+
+ it('should use the supplied component type in the back navigation
route for a funnel', async () => {
+ const { effects, actions$, mockRouter } = await setup({
+ connectorId: 'conn-1',
+ processGroupIdFromRoute: 'pg-root'
+ });
+ actions$(
+ of(
+ navigateToProvenanceForComponent({
+ id: 'funnel-1',
+ componentType: ComponentType.Funnel
+ })
+ )
+ );
+
+ await firstValueFrom(effects.navigateToProvenanceForComponent$);
+
+ expect(mockRouter.navigate).toHaveBeenCalledWith(['/provenance'], {
+ queryParams: { componentId: 'funnel-1' },
+ state: {
+ backNavigation: {
+ route: ['/connectors', 'conn-1', 'canvas', 'pg-root',
ComponentType.Funnel, 'funnel-1'],
+ routeBoundary: ['/provenance'],
+ context: 'Funnel'
+ }
+ }
+ });
+ });
});
describe('navigateToQueueListing$', () => {
@@ -681,6 +739,31 @@ describe('ConnectorCanvasEffects', () => {
);
});
+ it('should dispatch loadConnectorFlow when all queues are emptied for
a process group with connector-canvas source', async () => {
+ const { effects, actions$ } = await setup({
+ connectorId: 'conn-1',
+ processGroupIdFromRoute: 'pg-root'
+ });
+ actions$(
+ of(
+ queueEmptied({
+ connectionId: null,
+ processGroupId: 'pg-root',
+ source: 'connector-canvas'
+ })
+ )
+ );
+
+ const result = await
firstValueFrom(effects.refreshAfterQueueEmptied$);
+
+ expect(result).toEqual(
+ loadConnectorFlow({
+ connectorId: 'conn-1',
+ processGroupId: 'pg-root'
+ })
+ );
+ });
+
it('should ignore queueEmptied events from the flow designer', async
() => {
const { effects, actions$ } = await setup({
connectorId: 'conn-1',
@@ -705,5 +788,296 @@ describe('ConnectorCanvasEffects', () => {
expect(emitted).toBeUndefined();
});
+
+ it('should not dispatch when the connectorId route param is missing',
async () => {
+ const { effects, actions$ } = await setup({
+ connectorId: null,
+ processGroupIdFromRoute: 'pg-root'
+ });
+ actions$(
+ of(
+ queueEmptied({
+ connectionId: 'conn-listing-1',
+ processGroupId: null,
+ source: 'connector-canvas'
+ })
+ )
+ );
+
+ let emitted: Action | undefined;
+ effects.refreshAfterQueueEmptied$.subscribe((action) => {
+ emitted = action;
+ });
+
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
+
+ expect(emitted).toBeUndefined();
+ });
+ });
+
+ describe('reloadConnectorFlow$', () => {
+ it('should dispatch loadConnectorFlow with the connector and process
group ids from state', async () => {
+ const { effects, actions$ } = await setup({
+ connectorId: 'conn-state',
+ processGroupId: 'pg-state'
+ });
+ actions$(of(reloadConnectorFlow()));
+
+ const action = await firstValueFrom(effects.reloadConnectorFlow$);
+ expect(action).toEqual(
+ loadConnectorFlow({
+ connectorId: 'conn-state',
+ processGroupId: 'pg-state'
+ })
+ );
+ });
+
+ it('should not dispatch loadConnectorFlow when the connector id is
empty', async () => {
+ const { effects, actions$ } = await setup({
+ connectorId: '',
+ processGroupId: 'pg-state'
+ });
+ actions$(of(reloadConnectorFlow()));
+
+ let emitted: Action | undefined;
+ effects.reloadConnectorFlow$.subscribe((action) => {
+ emitted = action;
+ });
+
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
+
+ expect(emitted).toBeUndefined();
+ });
+
+ it('should not dispatch loadConnectorFlow when the process group id is
null', async () => {
+ const { effects, actions$ } = await setup({
+ connectorId: 'conn-state',
+ processGroupId: null
+ });
+ actions$(of(reloadConnectorFlow()));
+
+ let emitted: Action | undefined;
+ effects.reloadConnectorFlow$.subscribe((action) => {
+ emitted = action;
+ });
+
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
+
+ expect(emitted).toBeUndefined();
+ });
+
+ describe('throttleTime', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ async function flushPromises(): Promise<void> {
+ await Promise.resolve();
+ await Promise.resolve();
+ }
+
+ it('should collapse rapid back-to-back reloadConnectorFlow
dispatches into a single loadConnectorFlow within the throttle window', async
() => {
+ const { effects, actions$ } = await setup({
+ connectorId: 'conn-state',
+ processGroupId: 'pg-state'
+ });
+ const actionSubject = new Subject<Action>();
+ actions$(actionSubject.asObservable());
+
+ const emitted: Action[] = [];
+ const subscription =
effects.reloadConnectorFlow$.subscribe((action) => {
+ emitted.push(action);
+ });
+
+ // Three reloadConnectorFlow dispatches inside the 1s throttle
window:
+ // throttleTime defaults to leading-only, so only the first
should produce
+ // a loadConnectorFlow. The remaining two are dropped.
+ actionSubject.next(reloadConnectorFlow());
+ actionSubject.next(reloadConnectorFlow());
+ actionSubject.next(reloadConnectorFlow());
+
+ await flushPromises();
+
+ expect(emitted).toEqual([
+ loadConnectorFlow({
+ connectorId: 'conn-state',
+ processGroupId: 'pg-state'
+ })
+ ]);
+
+ // After the throttle window expires the next dispatch is
allowed through.
+ await vi.advanceTimersByTimeAsync(1500);
+ actionSubject.next(reloadConnectorFlow());
+ await flushPromises();
+
+ expect(emitted).toEqual([
+ loadConnectorFlow({
+ connectorId: 'conn-state',
+ processGroupId: 'pg-state'
+ }),
+ loadConnectorFlow({
+ connectorId: 'conn-state',
+ processGroupId: 'pg-state'
+ })
+ ]);
+
+ subscription.unsubscribe();
+ });
+ });
+ });
+
+ describe('document visibility wake-up', () => {
+ it('should dispatch reloadConnectorFlow when the document becomes
visible after being hidden longer than the polling interval', async () => {
+ const { store } = await setup();
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+
+ // Simulate the tab being foregrounded after the polling cadence
elapsed.
+ // The lastReload field starts at 0 in the freshly constructed
effects, so
+ // any changedTimestamp greater than the 30 second threshold
satisfies the
+ // wake-up filter.
+ store.overrideSelector(selectDocumentVisibilityState, {
+ documentVisibility: DocumentVisibility.Visible,
+ changedTimestamp: 31 * 1000
+ });
+ store.refreshState();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(reloadConnectorFlow());
+ });
+
+ it('should not dispatch reloadConnectorFlow when the document becomes
visible within the polling interval', async () => {
+ const { store } = await setup();
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+
+ store.overrideSelector(selectDocumentVisibilityState, {
+ documentVisibility: DocumentVisibility.Visible,
+ changedTimestamp: 5 * 1000
+ });
+ store.refreshState();
+
+
expect(dispatchSpy).not.toHaveBeenCalledWith(reloadConnectorFlow());
+ });
+
+ it('should not dispatch reloadConnectorFlow when the document
transitions to hidden', async () => {
+ const { store } = await setup();
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+
+ store.overrideSelector(selectDocumentVisibilityState, {
+ documentVisibility: DocumentVisibility.Hidden,
+ changedTimestamp: 60 * 1000
+ });
+ store.refreshState();
+
+
expect(dispatchSpy).not.toHaveBeenCalledWith(reloadConnectorFlow());
+ });
+ });
+
+ describe('startConnectorCanvasPolling$', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ async function flushPromises(): Promise<void> {
+ await Promise.resolve();
+ await Promise.resolve();
+ }
+
+ it('should dispatch reloadConnectorFlow on each polling tick while the
document is visible', async () => {
+ const { effects, actions$ } = await setup();
+ const actionSubject = new Subject<Action>();
+ actions$(actionSubject.asObservable());
+
+ const emitted: Action[] = [];
+ const subscription =
effects.startConnectorCanvasPolling$.subscribe((action) => {
+ emitted.push(action);
+ });
+
+ actionSubject.next(startConnectorCanvasPolling());
+
+ await vi.advanceTimersByTimeAsync(30000);
+ await flushPromises();
+ await vi.advanceTimersByTimeAsync(30000);
+ await flushPromises();
+
+ subscription.unsubscribe();
+
+ expect(emitted).toEqual([reloadConnectorFlow(),
reloadConnectorFlow()]);
+ });
+
+ it('should suppress reloadConnectorFlow while the document is hidden',
async () => {
+ const { effects, actions$ } = await setup({
+ documentVisibility: DocumentVisibility.Hidden
+ });
+ const actionSubject = new Subject<Action>();
+ actions$(actionSubject.asObservable());
+
+ const emitted: Action[] = [];
+ const subscription =
effects.startConnectorCanvasPolling$.subscribe((action) => {
+ emitted.push(action);
+ });
+
+ actionSubject.next(startConnectorCanvasPolling());
+
+ await vi.advanceTimersByTimeAsync(60000);
+ await flushPromises();
+
+ subscription.unsubscribe();
+
+ expect(emitted).toEqual([]);
+ });
+
+ it('should suppress reloadConnectorFlow while a load is already in
flight', async () => {
+ const { effects, actions$ } = await setup({ loadingStatus:
'loading' });
+ const actionSubject = new Subject<Action>();
+ actions$(actionSubject.asObservable());
+
+ const emitted: Action[] = [];
+ const subscription =
effects.startConnectorCanvasPolling$.subscribe((action) => {
+ emitted.push(action);
+ });
+
+ actionSubject.next(startConnectorCanvasPolling());
+
+ await vi.advanceTimersByTimeAsync(30000);
+ await flushPromises();
+
+ subscription.unsubscribe();
+
+ expect(emitted).toEqual([]);
+ });
+
+ it('should stop polling once stopConnectorCanvasPolling is
dispatched', async () => {
+ const { effects, actions$ } = await setup();
+ const actionSubject = new Subject<Action>();
+ actions$(actionSubject.asObservable());
+
+ const emitted: Action[] = [];
+ const subscription =
effects.startConnectorCanvasPolling$.subscribe((action) => {
+ emitted.push(action);
+ });
+
+ actionSubject.next(startConnectorCanvasPolling());
+
+ await vi.advanceTimersByTimeAsync(30000);
+ await flushPromises();
+ expect(emitted).toEqual([reloadConnectorFlow()]);
+
+ actionSubject.next(stopConnectorCanvasPolling());
+
+ await vi.advanceTimersByTimeAsync(60000);
+ await flushPromises();
+
+ subscription.unsubscribe();
+
+ // No additional emissions after stop.
+ expect(emitted).toEqual([reloadConnectorFlow()]);
+ });
});
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
index 718a40d71ad..d636a0190b4 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
@@ -15,14 +15,15 @@
* limitations under the License.
*/
-import { Injectable, inject } from '@angular/core';
+import { DestroyRef, Injectable, inject } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { concatLatestFrom } from '@ngrx/operators';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
-import { NEVER, Observable, of } from 'rxjs';
-import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators';
-import { ComponentType, ComponentTypeNamePipe, LARGE_DIALOG, MEDIUM_DIALOG,
XL_DIALOG } from '@nifi/shared';
+import { asyncScheduler, interval, NEVER, Observable, of } from 'rxjs';
+import { catchError, filter, map, switchMap, take, takeUntil, tap,
throttleTime } from 'rxjs/operators';
+import { ComponentType, ComponentTypeNamePipe, LARGE_DIALOG, MEDIUM_DIALOG,
NiFiCommon, XL_DIALOG } from '@nifi/shared';
import { selectCurrentUser } from
'../../../../state/current-user/current-user.selectors';
import { selectPrioritizerTypes } from
'../../../../state/extension-types/extension-types.selectors';
import {
@@ -46,8 +47,10 @@ import { SelectedComponent } from
'./connector-canvas.actions';
import * as EmptyQueueActions from
'../../../../state/empty-queue/empty-queue.actions';
import {
selectBreadcrumbs,
+ selectConnectorId,
selectConnectorIdFromRoute,
selectInputPort,
+ selectLoadingStatus,
selectOutputPort,
selectParentProcessGroupId,
selectProcessGroup,
@@ -56,6 +59,8 @@ import {
selectProcessor,
selectRemoteProcessGroup
} from './connector-canvas.selectors';
+import { selectDocumentVisibilityState } from
'../../../../state/document-visibility/document-visibility.selectors';
+import { DocumentVisibility } from '../../../../state/document-visibility';
@Injectable()
export class ConnectorCanvasEffects {
@@ -66,6 +71,35 @@ export class ConnectorCanvasEffects {
private errorHelper = inject(ErrorHelper);
private componentTypeNamePipe = inject(ComponentTypeNamePipe);
private dialog = inject(MatDialog);
+ private destroyRef = inject(DestroyRef);
+
+ /**
+ * Timestamp of the most recent reload (epoch millis). Used by the document
+ * visibility subscription to debounce "tab refocus" reloads so that
+ * switching tabs in quick succession does not produce a thrash of loads.
+ */
+ private lastReload = 0;
+
+ constructor() {
+ // When the document becomes visible after having been hidden long
enough
+ // for the polling interval to be skipped, trigger a reload so the
canvas
+ // does not display stale bulletins / status while the next interval
tick
+ // is pending. The 30 second floor matches the polling cadence and
keeps
+ // rapid tab switches from forcing a fresh load every time.
+ this.store
+ .select(selectDocumentVisibilityState)
+ .pipe(
+ takeUntilDestroyed(this.destroyRef),
+ filter((documentVisibility) =>
documentVisibility.documentVisibility === DocumentVisibility.Visible),
+ filter(
+ (documentVisibility) =>
+ documentVisibility.changedTimestamp - this.lastReload
> 30 * NiFiCommon.MILLIS_PER_SECOND
+ )
+ )
+ .subscribe(() => {
+
this.store.dispatch(ConnectorCanvasActions.reloadConnectorFlow());
+ });
+ }
loadConnectorFlow$ = createEffect(() =>
this.actions$.pipe(
@@ -128,6 +162,69 @@ export class ConnectorCanvasEffects {
)
);
+ /**
+ * Translate a reload request into a fresh {@link loadConnectorFlow} for
the
+ * connector and process group currently mounted on the canvas.
+ *
+ * The connector and process group ids come from the canvas state (set by
the
+ * most recent successful load) rather than the route so that a reload
fired
+ * mid-navigation does not race the URL change. A 1 second throttle
protects
+ * against rapid back-to-back reload requests, and the effect is a no-op
when
+ * the canvas has not yet completed an initial load.
+ */
+ reloadConnectorFlow$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ConnectorCanvasActions.reloadConnectorFlow),
+ throttleTime(1000),
+ concatLatestFrom(() => [this.store.select(selectConnectorId),
this.store.select(selectProcessGroupId)]),
+ filter(([, connectorId, processGroupId]) => !!connectorId &&
processGroupId != null),
+ switchMap(([, connectorId, processGroupId]) => {
+ this.lastReload = Date.now();
+
+ return of(
+ ConnectorCanvasActions.loadConnectorFlow({
+ connectorId,
+ processGroupId: processGroupId!
+ })
+ );
+ })
+ )
+ );
+
+ /**
+ * Drive periodic refresh of the connector flow.
+ *
+ * The interval ticks every 30 seconds and is torn down when
+ * {@link stopConnectorCanvasPolling} is dispatched (typically from the
+ * canvas component's ngOnDestroy). Polling is skipped when the document is
+ * hidden so background tabs do not generate load, and it is also skipped
+ * while a load is already in flight to avoid racing user-initiated process
+ * group navigations.
+ */
+ startConnectorCanvasPolling$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ConnectorCanvasActions.startConnectorCanvasPolling),
+ switchMap(() =>
+ interval(30000, asyncScheduler).pipe(
+
takeUntil(this.actions$.pipe(ofType(ConnectorCanvasActions.stopConnectorCanvasPolling)))
+ )
+ ),
+ concatLatestFrom(() => [
+ this.store.select(selectDocumentVisibilityState),
+ this.store.select(selectLoadingStatus)
+ ]),
+ // Skip polling when the document is hidden or while a flow load is
+ // already in flight. Without the loading guard the polling-driven
+ // reload would race a user-initiated process group navigation: the
+ // load effect uses switchMap, so a polling reload would cancel the
+ // pending navigation load and replace it with a load of the
+ // previously-rendered group, leaving the URL and canvas out of
sync.
+ filter(([, documentVisibility]) =>
documentVisibility.documentVisibility === DocumentVisibility.Visible),
+ filter(([, , loadingStatus]) => loadingStatus !== 'loading'),
+ switchMap(() => of(ConnectorCanvasActions.reloadConnectorFlow()))
+ )
+ );
+
/**
* Select components - updates route with selection
* Routes to /connectors/:id/canvas/:processGroupId/:type/:componentId
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
index d28f81a04e0..d1abe53782b 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
@@ -59,6 +59,8 @@ import {
resetConnectorCanvasState,
selectComponents,
setSkipTransform,
+ startConnectorCanvasPolling,
+ stopConnectorCanvasPolling,
viewComponentConfiguration
} from '../../state/connector-canvas/connector-canvas.actions';
import {
@@ -393,6 +395,14 @@ describe('ConnectorCanvasComponent', () => {
);
expect(loadFlowDispatches).toHaveLength(0);
}));
+
+ it('should dispatch startConnectorCanvasPolling on init', fakeAsync(()
=> {
+ const { fixture, dispatchSpy } = setup();
+ fixture.detectChanges();
+ tick();
+
+
expect(dispatchSpy).toHaveBeenCalledWith(startConnectorCanvasPolling());
+ }));
});
describe('Component destruction', () => {
@@ -415,6 +425,16 @@ describe('ConnectorCanvasComponent', () => {
expect(dispatchSpy).toHaveBeenCalledWith(resetConnectorCanvasEntityState());
});
+
+ it('should dispatch stopConnectorCanvasPolling on destroy', () => {
+ const { fixture, dispatchSpy } = setup();
+ fixture.detectChanges();
+ dispatchSpy.mockClear();
+
+ fixture.destroy();
+
+
expect(dispatchSpy).toHaveBeenCalledWith(stopConnectorCanvasPolling());
+ });
});
describe('Data ready state', () => {
@@ -1793,6 +1813,88 @@ describe('ConnectorCanvasComponent', () => {
})
);
}));
+
+ it('should set canClear to true when status data is missing',
fakeAsync(() => {
+ const { fixture, component, dispatchSpy } = setup();
+ fixture.detectChanges();
+ tick();
+ dispatchSpy.mockClear();
+
+ // No status property: runStatus is undefined (not 'Running')
and activeThreadCount
+ // defaults to 0 via the || 0 fallback, so canClear resolves
to true.
+ const processorEntity = {
+ id: 'proc-1',
+ component: { name: 'Stateless Processor', persistsState:
true }
+ };
+
+ component.viewProcessorStateAction(processorEntity);
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ getComponentStateAndOpenDialog({
+ request: {
+ componentName: 'Stateless Processor',
+ componentId: 'proc-1',
+ componentType: ComponentType.Processor,
+ canClear: true,
+ connectorId: DEFAULT_CONNECTOR_ID
+ }
+ })
+ );
+ }));
+
+ it('should set canClear to true when processor is disabled',
fakeAsync(() => {
+ const { fixture, component, dispatchSpy } = setup();
+ fixture.detectChanges();
+ tick();
+ dispatchSpy.mockClear();
+
+ const processorEntity = {
+ id: 'proc-1',
+ component: { name: 'Disabled Processor', persistsState:
true },
+ status: { aggregateSnapshot: { runStatus: 'Disabled',
activeThreadCount: 0 } }
+ };
+
+ component.viewProcessorStateAction(processorEntity);
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ getComponentStateAndOpenDialog({
+ request: {
+ componentName: 'Disabled Processor',
+ componentId: 'proc-1',
+ componentType: ComponentType.Processor,
+ canClear: true,
+ connectorId: DEFAULT_CONNECTOR_ID
+ }
+ })
+ );
+ }));
+
+ it('should set canClear to true when processor is invalid',
fakeAsync(() => {
+ const { fixture, component, dispatchSpy } = setup();
+ fixture.detectChanges();
+ tick();
+ dispatchSpy.mockClear();
+
+ const processorEntity = {
+ id: 'proc-1',
+ component: { name: 'Invalid Processor', persistsState:
true },
+ status: { aggregateSnapshot: { runStatus: 'Invalid',
activeThreadCount: 0 } }
+ };
+
+ component.viewProcessorStateAction(processorEntity);
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ getComponentStateAndOpenDialog({
+ request: {
+ componentName: 'Invalid Processor',
+ componentId: 'proc-1',
+ componentType: ComponentType.Processor,
+ canClear: true,
+ connectorId: DEFAULT_CONNECTOR_ID
+ }
+ })
+ );
+ }));
});
describe('queue and controller-services actions', () => {
@@ -1910,6 +2012,91 @@ describe('ConnectorCanvasComponent', () => {
cancelConnectorDrain({ connector: operableConnectorEntity
as any })
);
});
+
+ it('canDrain should be true when the entity is operable and
DRAIN_FLOWFILES is allowed', () => {
+ const { component, fixture } = setup();
+ const store = TestBed.inject(MockStore);
+ store.overrideSelector(selectConnectorCanvasEntity,
operableConnectorEntity);
+ store.refreshState();
+ fixture.detectChanges();
+
+ expect(component.canDrain()).toBe(true);
+ });
+
+ it('canDrain should be false when the user cannot operate the
connector', () => {
+ const { component, fixture } = setup();
+ const entityWithoutOperatePermissions = {
+ ...operableConnectorEntity,
+ permissions: { canRead: true, canWrite: false },
+ operatePermissions: { canRead: true, canWrite: false }
+ };
+ const store = TestBed.inject(MockStore);
+ store.overrideSelector(selectConnectorCanvasEntity,
entityWithoutOperatePermissions);
+ store.refreshState();
+ fixture.detectChanges();
+
+ expect(component.canDrain()).toBe(false);
+ });
+
+ it('canDrain should be false when the DRAIN_FLOWFILES action is
not allowed', () => {
+ const { component, fixture } = setup();
+ const entityWithDrainDisallowed = {
+ ...operableConnectorEntity,
+ component: {
+ availableActions: [
+ { name: 'DRAIN_FLOWFILES', allowed: false },
+ { name: 'CANCEL_DRAIN_FLOWFILES', allowed: true }
+ ]
+ }
+ };
+ const store = TestBed.inject(MockStore);
+ store.overrideSelector(selectConnectorCanvasEntity,
entityWithDrainDisallowed);
+ store.refreshState();
+ fixture.detectChanges();
+
+ expect(component.canDrain()).toBe(false);
+ });
+
+ it('canDrain should be true when only operatePermissions.canWrite
is true (permissions.canWrite is false)', () => {
+ const { component, fixture } = setup();
+ const operateOnlyEntity = {
+ ...operableConnectorEntity,
+ permissions: { canRead: true, canWrite: false },
+ operatePermissions: { canRead: true, canWrite: true }
+ };
+ const store = TestBed.inject(MockStore);
+ store.overrideSelector(selectConnectorCanvasEntity,
operateOnlyEntity);
+ store.refreshState();
+ fixture.detectChanges();
+
+ expect(component.canDrain()).toBe(true);
+ });
+
+ it('canCancelDrain should be false when no entity is loaded', ()
=> {
+ const { component } = setup();
+ expect(component.canCancelDrain()).toBe(false);
+ });
+
+ it('canCancelDrain should be true when the entity is operable and
CANCEL_DRAIN_FLOWFILES is allowed', () => {
+ const { component, fixture } = setup();
+ const store = TestBed.inject(MockStore);
+ store.overrideSelector(selectConnectorCanvasEntity,
operableConnectorEntity);
+ store.refreshState();
+ fixture.detectChanges();
+
+ expect(component.canCancelDrain()).toBe(true);
+ });
+
+ it('canCancelDrain should be false while the entity is saving', ()
=> {
+ const { component, fixture } = setup();
+ const store = TestBed.inject(MockStore);
+ store.overrideSelector(selectConnectorCanvasEntity,
operableConnectorEntity);
+ store.overrideSelector(selectConnectorCanvasEntitySaving,
true);
+ store.refreshState();
+ fixture.detectChanges();
+
+ expect(component.canCancelDrain()).toBe(false);
+ });
});
});
@@ -2124,4 +2311,16 @@ describe('ConnectorCanvasComponent', () => {
expect(graphControls.canNavigateToParent()).toBe(true);
}));
});
+
+ describe('returnToConnectorListing', () => {
+ it('should navigate to the connectors listing route', () => {
+ const { component } = setup();
+ const router = TestBed.inject(Router);
+ const navigateSpy = vi.spyOn(router, 'navigate');
+
+ component.returnToConnectorListing();
+
+ expect(navigateSpy).toHaveBeenCalledWith(['/connectors']);
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
index 84e762ea1ee..1f901190aa6 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
@@ -329,6 +329,10 @@ export class ConnectorCanvasComponent implements OnInit,
OnDestroy {
};
this.store.dispatch(setConfiguration({ configuration: config }));
+ // Begin periodic refresh so bulletins, queue counts, and run status
reflect
+ // server-side changes without requiring the user to manually navigate.
+
this.store.dispatch(ConnectorCanvasActions.startConnectorCanvasPolling());
+
// Subscribe to connector ID and process group ID from route and load
flow data
combineLatest([
this.store.select(ConnectorCanvasSelectors.selectConnectorIdFromRoute),
@@ -394,6 +398,7 @@ export class ConnectorCanvasComponent implements OnInit,
OnDestroy {
}
ngOnDestroy(): void {
+
this.store.dispatch(ConnectorCanvasActions.stopConnectorCanvasPolling());
this.store.dispatch(ConnectorCanvasActions.resetConnectorCanvasState());
this.store.dispatch(ConnectorCanvasEntityActions.resetConnectorCanvasEntityState());
}