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 56543a76af8 NIFI-15823: Adding the proveance preview component into
the connector canvas graph controls. (#11238)
56543a76af8 is described below
commit 56543a76af8430ea4c59ca888656dd6b0dfba4a8
Author: Matt Gilman <[email protected]>
AuthorDate: Thu May 14 13:09:44 2026 -0400
NIFI-15823: Adding the proveance preview component into the connector
canvas graph controls. (#11238)
---
.../connector-provenance-preview.actions.ts | 59 ++++
.../connector-provenance-preview.effects.spec.ts | 314 ++++++++++++++++++
.../connector-provenance-preview.effects.ts | 229 +++++++++++++
.../connector-provenance-preview.reducer.spec.ts | 68 ++++
.../connector-provenance-preview.reducer.ts | 54 +++
.../connector-provenance-preview.selectors.spec.ts | 53 +++
.../connector-provenance-preview.selectors.ts | 38 +++
.../state/connector-provenance-preview/index.ts | 40 +++
.../connector-canvas.component.html | 14 +-
.../connector-canvas.component.spec.ts | 323 +++++++++++++++++-
.../connector-canvas/connector-canvas.component.ts | 149 +++++++++
.../ui/connector-canvas/connector-canvas.module.ts | 6 +-
.../connector-graph-controls.component.html | 17 +-
.../connector-graph-controls.component.spec.ts | 148 ++++++++-
.../connector-graph-controls.component.ts | 18 +-
.../pages/provenance/service/provenance.service.ts | 6 +
.../provenance-preview.component.html | 168 ++++++++++
.../provenance-preview.component.scss | 16 +
.../provenance-preview.component.spec.ts | 368 +++++++++++++++++++++
.../provenance-preview.component.ts | 238 +++++++++++++
20 files changed, 2318 insertions(+), 8 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.actions.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.actions.ts
new file mode 100644
index 00000000000..22e5a29ca79
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.actions.ts
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createAction, props } from '@ngrx/store';
+import { ProvenanceEvent, ProvenanceEventDialogRequest } from
'../../../../state/shared';
+import { DownloadContentRequest, ReplayEventRequest, ViewContentRequest } from
'./index';
+
+export const loadLatestEventsForComponent = createAction(
+ '[Connector Provenance] Load Latest Events For Component',
+ props<{ componentId: string }>()
+);
+
+export const loadLatestEventsForComponentSuccess = createAction(
+ '[Connector Provenance] Load Latest Events For Component Success',
+ props<{ events: ProvenanceEvent[] }>()
+);
+
+export const loadError = createAction('[Connector Provenance] Load Error',
props<{ error: string }>());
+
+export const resetState = createAction('[Connector Provenance] Reset State');
+
+export const openProvenanceEventDialog = createAction(
+ '[Connector Provenance] Open Provenance Event Dialog',
+ props<{ request: ProvenanceEventDialogRequest }>()
+);
+
+export const downloadContent = createAction(
+ '[Connector Provenance] Download Content',
+ props<{ request: DownloadContentRequest }>()
+);
+
+export const viewContent = createAction(
+ '[Connector Provenance] View Content',
+ props<{ request: ViewContentRequest }>()
+);
+
+export const replayEvent = createAction(
+ '[Connector Provenance] Replay Event',
+ props<{ request: ReplayEventRequest }>()
+);
+
+export const showOkDialog = createAction(
+ '[Connector Provenance] Show Ok Dialog',
+ props<{ title: string; message: string }>()
+);
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.effects.spec.ts
new file mode 100644
index 00000000000..4307643f7c0
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.effects.spec.ts
@@ -0,0 +1,314 @@
+/*
+ * 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 { provideMockActions } from '@ngrx/effects/testing';
+import { Action } from '@ngrx/store';
+import { provideMockStore } from '@ngrx/store/testing';
+import { firstValueFrom, of, ReplaySubject, Subject, throwError } from 'rxjs';
+import { HttpErrorResponse } from '@angular/common/http';
+import { MatDialog } from '@angular/material/dialog';
+
+import { ConnectorProvenanceEffects } from
'./connector-provenance-preview.effects';
+import * as ConnectorProvenanceActions from
'./connector-provenance-preview.actions';
+import * as ErrorActions from '../../../../state/error/error.actions';
+import { ProvenanceService } from
'../../../provenance/service/provenance.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { selectAbout } from '../../../../state/about/about.selectors';
+import { ProvenanceEvent } from '../../../../state/shared';
+import { ErrorContextKey } from '../../../../state/error';
+
+describe('ConnectorProvenanceEffects', () => {
+ const SAMPLE_EVENT: ProvenanceEvent = {
+ id: 'e1',
+ eventId: 42,
+ eventType: 'RECEIVE',
+ clusterNodeId: 'node-1',
+ attributes: [{ name: 'mime.type', value: 'text/plain', previousValue:
'application/json' }]
+ } as unknown as ProvenanceEvent;
+
+ interface SetupOptions {
+ about?: { uri: string; contentViewerUrl: string | null } | null;
+ }
+
+ function createMockDialogRef() {
+ return {
+ componentInstance: {
+ contentViewerAvailable: false,
+ downloadContent: new Subject<string>(),
+ viewContent: new Subject<string>(),
+ replay: new Subject<void>()
+ },
+ afterClosed: () => new Subject<void>(),
+ close: vi.fn()
+ };
+ }
+
+ async function setup(options: SetupOptions = {}) {
+ const actions$ = new ReplaySubject<Action>(1);
+
+ const mockProvenanceService = {
+ getLatestEventsForComponent: vi.fn(),
+ downloadContent: vi.fn(),
+ viewContent: vi.fn(),
+ replay: vi.fn().mockReturnValue(of({}))
+ };
+
+ const mockErrorHelper = {
+ getErrorString: vi.fn().mockReturnValue('Network failure')
+ };
+
+ const mockDialog = {
+ open: vi.fn().mockReturnValue(createMockDialogRef())
+ };
+
+ await TestBed.configureTestingModule({
+ providers: [
+ ConnectorProvenanceEffects,
+ provideMockActions(() => actions$),
+ provideMockStore({
+ selectors: [{ selector: selectAbout, value: options.about
?? null }]
+ }),
+ { provide: ProvenanceService, useValue: mockProvenanceService
},
+ { provide: ErrorHelper, useValue: mockErrorHelper },
+ { provide: MatDialog, useValue: mockDialog }
+ ]
+ }).compileComponents();
+
+ const effects = TestBed.inject(ConnectorProvenanceEffects);
+
+ return {
+ effects,
+ actions$,
+ mockProvenanceService,
+ mockErrorHelper,
+ mockDialog
+ };
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('loadLatestEventsForComponent$', () => {
+ it('should dispatch loadLatestEventsForComponentSuccess on a
successful response', async () => {
+ const { effects, actions$, mockProvenanceService } = await setup();
+ mockProvenanceService.getLatestEventsForComponent.mockReturnValue(
+ of({ latestProvenanceEvents: { provenanceEvents:
[SAMPLE_EVENT] } })
+ );
+
+
actions$.next(ConnectorProvenanceActions.loadLatestEventsForComponent({
componentId: 'proc-1' }));
+
+ const result = await
firstValueFrom(effects.loadLatestEventsForComponent$);
+
expect(mockProvenanceService.getLatestEventsForComponent).toHaveBeenCalledWith('proc-1');
+ expect(result).toEqual(
+
ConnectorProvenanceActions.loadLatestEventsForComponentSuccess({ events:
[SAMPLE_EVENT] })
+ );
+ });
+
+ it('should dispatch loadError when the request fails', async () => {
+ const { effects, actions$, mockProvenanceService, mockErrorHelper
} = await setup();
+ mockProvenanceService.getLatestEventsForComponent.mockReturnValue(
+ throwError(() => new HttpErrorResponse({ status: 500 }))
+ );
+
+
actions$.next(ConnectorProvenanceActions.loadLatestEventsForComponent({
componentId: 'proc-1' }));
+
+ const result = await
firstValueFrom(effects.loadLatestEventsForComponent$);
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalled();
+ expect(result).toEqual(ConnectorProvenanceActions.loadError({
error: 'Network failure' }));
+ });
+ });
+
+ describe('downloadContent$', () => {
+ it('should call provenanceService.downloadContent with the event
details', () =>
+ new Promise<void>((resolve) => {
+ setup().then(({ effects, actions$, mockProvenanceService }) =>
{
+ const subscription = effects.downloadContent$.subscribe(()
=> {
+ // non-dispatching effect; subscribe to keep it alive
+ });
+
+ actions$.next(
+ ConnectorProvenanceActions.downloadContent({
+ request: { event: SAMPLE_EVENT, direction: 'input'
}
+ })
+ );
+
+ queueMicrotask(() => {
+
expect(mockProvenanceService.downloadContent).toHaveBeenCalledWith(
+ SAMPLE_EVENT.eventId,
+ 'input',
+ SAMPLE_EVENT.clusterNodeId
+ );
+ subscription.unsubscribe();
+ resolve();
+ });
+ });
+ }));
+ });
+
+ describe('viewContent$', () => {
+ it('should call provenanceService.viewContent with the resolved mime
type when about is set', () =>
+ new Promise<void>((resolve) => {
+ setup({
+ about: { uri: 'http://nifi/', contentViewerUrl:
'http://viewer/' }
+ }).then(({ effects, actions$, mockProvenanceService }) => {
+ const subscription = effects.viewContent$.subscribe(() => {
+ // non-dispatching effect; subscribe to keep it alive
+ });
+
+ actions$.next(
+ ConnectorProvenanceActions.viewContent({
+ request: { event: SAMPLE_EVENT, direction: 'input'
}
+ })
+ );
+
+ queueMicrotask(() => {
+
expect(mockProvenanceService.viewContent).toHaveBeenCalledWith(
+ 'http://nifi/',
+ 'http://viewer/',
+ SAMPLE_EVENT.eventId,
+ 'input',
+ SAMPLE_EVENT.clusterNodeId,
+ 'application/json'
+ );
+ subscription.unsubscribe();
+ resolve();
+ });
+ });
+ }));
+
+ it('should NOT call provenanceService.viewContent when about is null',
() =>
+ new Promise<void>((resolve) => {
+ setup({ about: null }).then(({ effects, actions$,
mockProvenanceService }) => {
+ const subscription = effects.viewContent$.subscribe(() => {
+ // non-dispatching effect; subscribe to keep it alive
+ });
+
+ actions$.next(
+ ConnectorProvenanceActions.viewContent({
+ request: { event: SAMPLE_EVENT, direction:
'output' }
+ })
+ );
+
+ queueMicrotask(() => {
+
expect(mockProvenanceService.viewContent).not.toHaveBeenCalled();
+ subscription.unsubscribe();
+ resolve();
+ });
+ });
+ }));
+ });
+
+ describe('replayEvent$', () => {
+ it('should dispatch showOkDialog on successful replay', async () => {
+ const { effects, actions$, mockProvenanceService } = await setup();
+
+ actions$.next(
+ ConnectorProvenanceActions.replayEvent({
+ request: { event: SAMPLE_EVENT }
+ })
+ );
+
+ const result = await firstValueFrom(effects.replayEvent$);
+
expect(mockProvenanceService.replay).toHaveBeenCalledWith(SAMPLE_EVENT.eventId,
SAMPLE_EVENT.clusterNodeId);
+ expect(result).toEqual(
+ ConnectorProvenanceActions.showOkDialog({
+ title: 'Provenance',
+ message: 'Successfully submitted replay request.'
+ })
+ );
+ });
+
+ it('should dispatch addBannerError under CONNECTOR_CANVAS context when
replay fails', async () => {
+ const { effects, actions$, mockProvenanceService } = await setup();
+ mockProvenanceService.replay.mockReturnValue(throwError(() => new
HttpErrorResponse({ status: 500 })));
+
+ actions$.next(
+ ConnectorProvenanceActions.replayEvent({
+ request: { event: SAMPLE_EVENT }
+ })
+ );
+
+ const result = await firstValueFrom(effects.replayEvent$);
+ expect(result).toEqual(
+ ErrorActions.addBannerError({
+ errorContext: {
+ errors: ['Network failure'],
+ context: ErrorContextKey.CONNECTOR_CANVAS
+ }
+ })
+ );
+ });
+ });
+
+ describe('openProvenanceEventDialog$', () => {
+ it('should open a ProvenanceEventDialog with the event request', () =>
+ new Promise<void>((resolve) => {
+ setup({ about: { uri: 'http://nifi/', contentViewerUrl:
'http://viewer/' } }).then(
+ ({ effects, actions$, mockDialog }) => {
+ const subscription =
effects.openProvenanceEventDialog$.subscribe(() => {
+ // non-dispatching effect; subscribe to keep it
alive
+ });
+
+ actions$.next(
+
ConnectorProvenanceActions.openProvenanceEventDialog({
+ request: { event: SAMPLE_EVENT }
+ })
+ );
+
+ queueMicrotask(() => {
+ expect(mockDialog.open).toHaveBeenCalled();
+ const args = mockDialog.open.mock.calls[0];
+ expect(args[1].data).toEqual({ event: SAMPLE_EVENT
});
+ subscription.unsubscribe();
+ resolve();
+ });
+ }
+ );
+ }));
+ });
+
+ describe('showOkDialog$', () => {
+ it('should open an OkDialog with the supplied title and message', () =>
+ new Promise<void>((resolve) => {
+ setup().then(({ effects, actions$, mockDialog }) => {
+ const subscription = effects.showOkDialog$.subscribe(() =>
{
+ // non-dispatching effect; subscribe to keep it alive
+ });
+
+ actions$.next(
+ ConnectorProvenanceActions.showOkDialog({
+ title: 'Provenance',
+ message: 'Successfully submitted replay request.'
+ })
+ );
+
+ queueMicrotask(() => {
+ expect(mockDialog.open).toHaveBeenCalled();
+ const args = mockDialog.open.mock.calls[0];
+ expect(args[1].data).toEqual({
+ title: 'Provenance',
+ message: 'Successfully submitted replay request.'
+ });
+ subscription.unsubscribe();
+ resolve();
+ });
+ });
+ }));
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.effects.ts
new file mode 100644
index 00000000000..0f876c318d3
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.effects.ts
@@ -0,0 +1,229 @@
+/*
+ * 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, inject } from '@angular/core';
+import { HttpErrorResponse } from '@angular/common/http';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import { concatLatestFrom } from '@ngrx/operators';
+import { Store } from '@ngrx/store';
+import { MatDialog } from '@angular/material/dialog';
+import { catchError, from, map, of, switchMap, takeUntil, tap } from 'rxjs';
+
+import { MEDIUM_DIALOG, XL_DIALOG } from '@nifi/shared';
+import * as ConnectorProvenanceActions from
'./connector-provenance-preview.actions';
+import * as ErrorActions from '../../../../state/error/error.actions';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { ProvenanceService } from
'../../../provenance/service/provenance.service';
+import { ProvenanceEventDialog } from
'../../../../ui/common/provenance-event-dialog/provenance-event-dialog.component';
+import { OkDialog } from '../../../../ui/common/ok-dialog/ok-dialog.component';
+import { selectAbout } from '../../../../state/about/about.selectors';
+import { Attribute } from '../../../../state/shared';
+import { NiFiState } from '../../../../state';
+import { ErrorContextKey } from '../../../../state/error';
+
+@Injectable()
+export class ConnectorProvenanceEffects {
+ private actions$ = inject(Actions);
+ private store = inject<Store<NiFiState>>(Store);
+ private dialog = inject(MatDialog);
+ private provenanceService = inject(ProvenanceService);
+ private errorHelper = inject(ErrorHelper);
+
+ loadLatestEventsForComponent$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ConnectorProvenanceActions.loadLatestEventsForComponent),
+ map((action) => action.componentId),
+ switchMap((componentId) =>
+ from(
+
this.provenanceService.getLatestEventsForComponent(componentId).pipe(
+ map((response) =>
+
ConnectorProvenanceActions.loadLatestEventsForComponentSuccess({
+ events:
response.latestProvenanceEvents.provenanceEvents
+ })
+ ),
+ catchError((errorResponse) =>
+ of(
+ ConnectorProvenanceActions.loadError({
+ error:
this.errorHelper.getErrorString(errorResponse)
+ })
+ )
+ )
+ )
+ )
+ )
+ )
+ );
+
+ openProvenanceEventDialog$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(ConnectorProvenanceActions.openProvenanceEventDialog),
+ map((action) => action.request),
+ concatLatestFrom(() => this.store.select(selectAbout)),
+ tap(([request, about]) => {
+ const dialogReference =
this.dialog.open(ProvenanceEventDialog, {
+ ...XL_DIALOG,
+ autoFocus: 'dialog',
+ data: request
+ });
+
+ dialogReference.componentInstance.contentViewerAvailable =
about?.contentViewerUrl != null;
+
+ dialogReference.componentInstance.downloadContent
+ .pipe(takeUntil(dialogReference.afterClosed()))
+ .subscribe((direction: string) => {
+ this.store.dispatch(
+ ConnectorProvenanceActions.downloadContent({
+ request: {
+ event: request.event,
+ direction: direction as 'input' |
'output'
+ }
+ })
+ );
+ });
+
+ if (about) {
+ dialogReference.componentInstance.viewContent
+ .pipe(takeUntil(dialogReference.afterClosed()))
+ .subscribe((direction: string) => {
+ this.store.dispatch(
+ ConnectorProvenanceActions.viewContent({
+ request: {
+ event: request.event,
+ direction: direction as 'input' |
'output'
+ }
+ })
+ );
+ });
+ }
+
+ dialogReference.componentInstance.replay
+ .pipe(takeUntil(dialogReference.afterClosed()))
+ .subscribe(() => {
+ dialogReference.close();
+
+ this.store.dispatch(
+ ConnectorProvenanceActions.replayEvent({
+ request: {
+ event: request.event
+ }
+ })
+ );
+ });
+ })
+ ),
+ { dispatch: false }
+ );
+
+ downloadContent$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(ConnectorProvenanceActions.downloadContent),
+ map((action) => action.request),
+ tap((request) => {
+ this.provenanceService.downloadContent(
+ request.event.eventId,
+ request.direction,
+ request.event.clusterNodeId
+ );
+ })
+ ),
+ { dispatch: false }
+ );
+
+ viewContent$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(ConnectorProvenanceActions.viewContent),
+ map((action) => action.request),
+ concatLatestFrom(() => this.store.select(selectAbout)),
+ tap(([request, about]) => {
+ if (about) {
+ let mimeType: string | undefined;
+
+ if (request.event.attributes) {
+ const mimeTypeAttribute: Attribute | undefined =
request.event.attributes.find(
+ (attribute: Attribute) => attribute.name ===
'mime.type'
+ );
+
+ if (mimeTypeAttribute) {
+ if (request.direction === 'input') {
+ mimeType = mimeTypeAttribute.previousValue;
+ } else if (request.direction === 'output') {
+ mimeType = mimeTypeAttribute.value;
+ }
+ }
+ }
+
+ this.provenanceService.viewContent(
+ about.uri,
+ about.contentViewerUrl,
+ request.event.eventId,
+ request.direction,
+ request.event.clusterNodeId,
+ mimeType
+ );
+ }
+ })
+ ),
+ { dispatch: false }
+ );
+
+ replayEvent$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ConnectorProvenanceActions.replayEvent),
+ map((action) => action.request),
+ switchMap((request) =>
+ this.provenanceService.replay(request.event.eventId,
request.event.clusterNodeId).pipe(
+ map(() =>
+ ConnectorProvenanceActions.showOkDialog({
+ title: 'Provenance',
+ message: 'Successfully submitted replay request.'
+ })
+ ),
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(
+ ErrorActions.addBannerError({
+ errorContext: {
+ errors:
[this.errorHelper.getErrorString(errorResponse)],
+ context: ErrorContextKey.CONNECTOR_CANVAS
+ }
+ })
+ )
+ )
+ )
+ )
+ )
+ );
+
+ showOkDialog$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(ConnectorProvenanceActions.showOkDialog),
+ tap((request) => {
+ this.dialog.open(OkDialog, {
+ ...MEDIUM_DIALOG,
+ data: {
+ title: request.title,
+ message: request.message
+ }
+ });
+ })
+ ),
+ { dispatch: false }
+ );
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.reducer.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.reducer.spec.ts
new file mode 100644
index 00000000000..a6d65577015
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.reducer.spec.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 { connectorProvenancePreviewReducer, initialState } from
'./connector-provenance-preview.reducer';
+import {
+ loadError,
+ loadLatestEventsForComponent,
+ loadLatestEventsForComponentSuccess,
+ resetState
+} from './connector-provenance-preview.actions';
+import { ProvenanceEvent } from '../../../../state/shared';
+
+describe('connectorProvenancePreviewReducer', () => {
+ const SAMPLE_EVENT = { id: 'evt-1', eventId: 1, eventType: 'RECEIVE' } as
ProvenanceEvent;
+
+ it('should set status to loading and clear events / error on
loadLatestEventsForComponent', () => {
+ const seeded = {
+ events: [SAMPLE_EVENT],
+ error: 'previous',
+ status: 'error' as const
+ };
+ const next = connectorProvenancePreviewReducer(seeded,
loadLatestEventsForComponent({ componentId: 'p' }));
+
+ expect(next.status).toBe('loading');
+ expect(next.events).toEqual([]);
+ expect(next.error).toBeNull();
+ });
+
+ it('should populate events and set status to success on
loadLatestEventsForComponentSuccess', () => {
+ const events = [SAMPLE_EVENT];
+ const next = connectorProvenancePreviewReducer(initialState,
loadLatestEventsForComponentSuccess({ events }));
+
+ expect(next.status).toBe('success');
+ expect(next.events).toEqual(events);
+ });
+
+ it('should set status to error and capture error message on loadError', ()
=> {
+ const next = connectorProvenancePreviewReducer(initialState,
loadError({ error: 'boom' }));
+
+ expect(next.status).toBe('error');
+ expect(next.error).toBe('boom');
+ });
+
+ it('should reset to initial state on resetState', () => {
+ const dirty = {
+ events: [SAMPLE_EVENT],
+ error: 'something',
+ status: 'success' as const
+ };
+ const next = connectorProvenancePreviewReducer(dirty, resetState());
+
+ expect(next).toEqual(initialState);
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.reducer.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.reducer.ts
new file mode 100644
index 00000000000..e0e84fbfed0
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.reducer.ts
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createReducer, on } from '@ngrx/store';
+import { ConnectorProvenancePreviewState } from './index';
+import {
+ loadError,
+ loadLatestEventsForComponent,
+ loadLatestEventsForComponentSuccess,
+ resetState
+} from './connector-provenance-preview.actions';
+
+export const initialState: ConnectorProvenancePreviewState = {
+ events: [],
+ error: null,
+ status: 'pending'
+};
+
+export const connectorProvenancePreviewReducer = createReducer(
+ initialState,
+ on(loadLatestEventsForComponent, (state) => ({
+ ...state,
+ events: [],
+ error: null,
+ status: 'loading' as const
+ })),
+ on(loadLatestEventsForComponentSuccess, (state, { events }) => ({
+ ...state,
+ events,
+ status: 'success' as const
+ })),
+ on(loadError, (state, { error }) => ({
+ ...state,
+ error,
+ status: 'error' as const
+ })),
+ on(resetState, () => ({
+ ...initialState
+ }))
+);
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.selectors.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.selectors.spec.ts
new file mode 100644
index 00000000000..3705123f31d
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.selectors.spec.ts
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ selectConnectorProvenanceError,
+ selectConnectorProvenanceEvents,
+ selectConnectorProvenancePreviewState,
+ selectConnectorProvenanceStatus
+} from './connector-provenance-preview.selectors';
+import { ConnectorProvenancePreviewState, connectorProvenancePreviewFeatureKey
} from './index';
+import { ProvenanceEvent } from '../../../../state/shared';
+
+describe('ConnectorProvenancePreview selectors', () => {
+ const featureState: ConnectorProvenancePreviewState = {
+ events: [{ id: 'e1', eventId: 1, eventType: 'RECEIVE' } as
ProvenanceEvent],
+ error: 'something went wrong',
+ status: 'error'
+ };
+
+ const rootState = {
+ [connectorProvenancePreviewFeatureKey]: featureState
+ };
+
+ it('selectConnectorProvenancePreviewState returns the feature state', ()
=> {
+ expect(selectConnectorProvenancePreviewState(rootState as
any)).toEqual(featureState);
+ });
+
+ it('selectConnectorProvenanceEvents returns events from the state', () => {
+ expect(selectConnectorProvenanceEvents(rootState as
any)).toEqual(featureState.events);
+ });
+
+ it('selectConnectorProvenanceStatus returns status from the state', () => {
+ expect(selectConnectorProvenanceStatus(rootState as
any)).toBe('error');
+ });
+
+ it('selectConnectorProvenanceError returns error from the state', () => {
+ expect(selectConnectorProvenanceError(rootState as
any)).toBe('something went wrong');
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.selectors.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.selectors.ts
new file mode 100644
index 00000000000..7bbd24551c7
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/connector-provenance-preview.selectors.ts
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createFeatureSelector, createSelector } from '@ngrx/store';
+import { connectorProvenancePreviewFeatureKey, ConnectorProvenancePreviewState
} from './index';
+
+export const selectConnectorProvenancePreviewState =
createFeatureSelector<ConnectorProvenancePreviewState>(
+ connectorProvenancePreviewFeatureKey
+);
+
+export const selectConnectorProvenanceEvents = createSelector(
+ selectConnectorProvenancePreviewState,
+ (state) => state.events
+);
+
+export const selectConnectorProvenanceStatus = createSelector(
+ selectConnectorProvenancePreviewState,
+ (state) => state.status
+);
+
+export const selectConnectorProvenanceError = createSelector(
+ selectConnectorProvenancePreviewState,
+ (state) => state.error
+);
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/index.ts
new file mode 100644
index 00000000000..3ecf3df8fe5
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-provenance-preview/index.ts
@@ -0,0 +1,40 @@
+/*
+ * 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 { ProvenanceEvent } from '../../../../state/shared';
+
+export const connectorProvenancePreviewFeatureKey =
'connectorProvenancePreview';
+
+export interface ConnectorProvenancePreviewState {
+ events: ProvenanceEvent[];
+ error: string | null;
+ status: 'pending' | 'loading' | 'success' | 'error';
+}
+
+export interface DownloadContentRequest {
+ event: ProvenanceEvent;
+ direction: 'input' | 'output';
+}
+
+export interface ViewContentRequest {
+ event: ProvenanceEvent;
+ direction: 'input' | 'output';
+}
+
+export interface ReplayEventRequest {
+ event: ProvenanceEvent;
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
index 0477e70fa0d..dd19242b840 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
@@ -36,6 +36,12 @@
[birdseyeTransform]="birdseyeTransform()"
[canvasDimensions]="canvasDimensions()"
[canNavigateToParent]="canNavigateToParent"
+ [canAccessProvenance]="canAccessProvenance()"
+ [provenanceEvents]="provenanceEvents()"
+ [provenanceStatus]="provenanceStatus()"
+ [provenanceError]="provenanceError()"
+ [connectedToCluster]="connectedToCluster()"
+ [contentViewerAvailable]="contentViewerAvailable()"
(viewportChange)="onBirdseyeViewportChange($event)"
(birdseyeDragStart)="onBirdseyeDragStart()"
(birdseyeDragEnd)="onBirdseyeDragEnd()"
@@ -43,7 +49,13 @@
(zoomOut)="onNavigationZoomOut()"
(zoomFit)="onNavigationZoomFit()"
(zoomActual)="onNavigationZoomActual()"
- (leaveGroup)="onNavigationLeaveGroup()">
+ (leaveGroup)="onNavigationLeaveGroup()"
+ (provenanceRefresh)="onProvenanceRefresh()"
+
(provenanceCollapsedChange)="onProvenanceCollapsedChange($event)"
+ (provenanceViewDetails)="onProvenanceViewDetails($event)"
+
(provenanceDownloadContent)="onProvenanceDownloadContent($event)"
+ (provenanceViewContent)="onProvenanceViewContent($event)"
+ (provenanceReplayEvent)="onProvenanceReplayEvent($event)">
</connector-graph-controls>
</mat-sidenav>
<mat-sidenav-content>
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 d1abe53782b..5942573b570 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
@@ -41,6 +41,9 @@ import { ErrorContextKey } from '../../../../state/error';
import { Storage } from '@nifi/shared';
import { setConfiguration } from
'../../../../state/canvas-ui/canvas-ui.actions';
import * as ConnectorCanvasSelectors from
'../../state/connector-canvas/connector-canvas.selectors';
+import { selectAbout } from '../../../../state/about/about.selectors';
+import { selectClusterSummary } from
'../../../../state/cluster-summary/cluster-summary.selectors';
+import { ProvenanceEvent } from '../../../../state/shared';
import {
selectConnectorCanvasEntity,
selectConnectorCanvasEntitySaving
@@ -70,6 +73,19 @@ import {
} from '../../state/connector-canvas-entity/connector-canvas-entity.actions';
import { promptEmptyQueueRequest, promptEmptyQueuesRequest } from
'../../../../state/empty-queue/empty-queue.actions';
import { getComponentStateAndOpenDialog } from
'../../../../state/component-state/component-state.actions';
+import {
+ downloadContent as connectorProvenanceDownloadContent,
+ loadLatestEventsForComponent as connectorProvenanceLoadLatest,
+ openProvenanceEventDialog as connectorProvenanceOpenEventDialog,
+ replayEvent as connectorProvenanceReplayEvent,
+ resetState as connectorProvenanceResetState,
+ viewContent as connectorProvenanceViewContent
+} from
'../../state/connector-provenance-preview/connector-provenance-preview.actions';
+import {
+ selectConnectorProvenanceError,
+ selectConnectorProvenanceEvents,
+ selectConnectorProvenanceStatus
+} from
'../../state/connector-provenance-preview/connector-provenance-preview.selectors';
// Mock components to avoid loading complex real components
@Component({
@@ -158,6 +174,12 @@ class MockConnectorGraphControls {
birdseyeTransform = input<unknown>({ translate: { x: 0, y: 0 }, scale: 1
});
canvasDimensions = input<unknown>({ width: 0, height: 0 });
canNavigateToParent = input<boolean>(false);
+ canAccessProvenance = input<boolean>(false);
+ provenanceEvents = input<ProvenanceEvent[]>([]);
+ provenanceStatus = input<'pending' | 'loading' | 'success' |
'error'>('pending');
+ provenanceError = input<string | null>(null);
+ connectedToCluster = input<boolean>(false);
+ contentViewerAvailable = input<boolean>(false);
viewportChange = output<{ x: number; y: number }>();
birdseyeDragStart = output<void>();
birdseyeDragEnd = output<void>();
@@ -166,6 +188,12 @@ class MockConnectorGraphControls {
zoomFit = output<void>();
zoomActual = output<void>();
leaveGroup = output<void>();
+ provenanceRefresh = output<void>();
+ provenanceCollapsedChange = output<boolean>();
+ provenanceViewDetails = output<ProvenanceEvent>();
+ provenanceDownloadContent = output<{ event: ProvenanceEvent; direction:
'input' | 'output' }>();
+ provenanceViewContent = output<{ event: ProvenanceEvent; direction:
'input' | 'output' }>();
+ provenanceReplayEvent = output<ProvenanceEvent>();
}
@Component({
@@ -194,6 +222,13 @@ interface SetupOptions {
skipTransform?: boolean;
routeParams?: Record<string, string>;
canAccessProvenance?: boolean;
+ inputPorts?: any[];
+ outputPorts?: any[];
+ provenanceEvents?: ProvenanceEvent[];
+ provenanceStatus?: 'pending' | 'loading' | 'success' | 'error';
+ provenanceError?: string | null;
+ connectedToCluster?: boolean;
+ contentViewerUrl?: string | null;
}
const DEFAULT_CONNECTOR_ID = 'connector-1';
@@ -234,6 +269,8 @@ function buildMockSelectors(options: SetupOptions = {}) {
{ selector: ConnectorCanvasSelectors.selectProcessors, value: [] },
{ selector: ConnectorCanvasSelectors.selectFunnels, value: [] },
{ selector: ConnectorCanvasSelectors.selectAllPorts, value: [] },
+ { selector: ConnectorCanvasSelectors.selectInputPorts, value:
options.inputPorts ?? [] },
+ { selector: ConnectorCanvasSelectors.selectOutputPorts, value:
options.outputPorts ?? [] },
{ selector: ConnectorCanvasSelectors.selectRemoteProcessGroups, value:
[] },
{ selector: ConnectorCanvasSelectors.selectProcessGroups, value: [] },
{ selector: ConnectorCanvasSelectors.selectConnections, value: [] },
@@ -247,7 +284,15 @@ function buildMockSelectors(options: SetupOptions = {}) {
{ selector: selectRouteParams, value: routeParamsValue },
{ selector: selectCurrentUser, value:
buildMockCurrentUser(canAccessProvenance) },
{ selector: selectConnectorCanvasEntity, value: null },
- { selector: selectConnectorCanvasEntitySaving, value: false }
+ { selector: selectConnectorCanvasEntitySaving, value: false },
+ { selector: selectConnectorProvenanceEvents, value:
options.provenanceEvents ?? [] },
+ { selector: selectConnectorProvenanceStatus, value:
options.provenanceStatus ?? 'pending' },
+ { selector: selectConnectorProvenanceError, value:
options.provenanceError ?? null },
+ {
+ selector: selectClusterSummary,
+ value: { connectedToCluster: options.connectedToCluster ?? false,
clustered: false }
+ },
+ { selector: selectAbout, value: { contentViewerUrl:
options.contentViewerUrl ?? null } }
];
}
@@ -2323,4 +2368,280 @@ describe('ConnectorCanvasComponent', () => {
expect(navigateSpy).toHaveBeenCalledWith(['/connectors']);
});
});
+
+ describe('Provenance preview wiring', () => {
+ function getGraphControlsMock(fixture:
ComponentFixture<ConnectorCanvasComponent>): MockConnectorGraphControls {
+ return fixture.debugElement.query((el) => el.name ===
'connector-graph-controls')
+ .componentInstance as MockConnectorGraphControls;
+ }
+
+ const SAMPLE_EVENT: ProvenanceEvent = {
+ id: 'evt-1',
+ eventId: 100,
+ eventTime: '01/01/2025 12:00:00 UTC',
+ eventTimestamp: '2025-01-01T12:00:00Z',
+ eventType: 'RECEIVE',
+ flowFileUuid: 'ff-1',
+ fileSize: '1 KB',
+ fileSizeBytes: 1024,
+ clusterNodeId: 'node-1',
+ clusterNodeAddress: 'node-1.local',
+ groupId: 'group-1',
+ componentId: 'proc-1',
+ componentType: 'Processor',
+ componentName: 'MyProcessor',
+ eventDuration: '0 ms',
+ lineageDuration: 0,
+ sourceSystemFlowFileId: '',
+ alternateIdentifierUri: '',
+ parentUuids: [],
+ childUuids: [],
+ transitUri: '',
+ relationship: '',
+ details: '',
+ contentEqual: false,
+ inputContentAvailable: true,
+ inputContentClaimSection: '',
+ inputContentClaimContainer: '',
+ inputContentClaimIdentifier: '',
+ inputContentClaimOffset: 0,
+ inputContentClaimFileSize: '0 bytes',
+ inputContentClaimFileSizeBytes: 0,
+ outputContentAvailable: true,
+ outputContentClaimSection: '',
+ outputContentClaimContainer: '',
+ outputContentClaimIdentifier: '',
+ outputContentClaimOffset: '0',
+ outputContentClaimFileSize: '0 bytes',
+ outputContentClaimFileSizeBytes: 0,
+ replayAvailable: true,
+ replayExplanation: '',
+ sourceConnectionIdentifier: ''
+ };
+
+ it('should expose provenance signals to connector-graph-controls',
fakeAsync(() => {
+ const { fixture } = setup({
+ provenanceStatus: 'success',
+ provenanceEvents: [SAMPLE_EVENT],
+ connectedToCluster: true,
+ contentViewerUrl: 'http://viewer'
+ });
+ fixture.detectChanges();
+ tick();
+
+ const graphControls = getGraphControlsMock(fixture);
+ expect(graphControls.provenanceStatus()).toBe('success');
+ expect(graphControls.provenanceEvents()).toEqual([SAMPLE_EVENT]);
+ expect(graphControls.canAccessProvenance()).toBe(true);
+ expect(graphControls.connectedToCluster()).toBe(true);
+ expect(graphControls.contentViewerAvailable()).toBe(true);
+ }));
+
+ it('should dispatch loadLatestEventsForComponent for an eligible
Processor selection', fakeAsync(() => {
+ const { fixture, component, dispatchSpy } = setup({
+ routeParams: { type: ComponentType.Processor, componentId:
'proc-42' }
+ });
+ fixture.detectChanges();
+ tick();
+
+ // Mirror the provenance-preview child notifying the parent that
its panel is expanded,
+ // which flushes the deferred load queued during eligibility
evaluation.
+ component.onProvenanceCollapsedChange(false);
+
+
expect(dispatchSpy).toHaveBeenCalledWith(connectorProvenanceLoadLatest({
componentId: 'proc-42' }));
+ }));
+
+ it('should dispatch loadLatestEventsForComponent for an eligible
Connection selection', fakeAsync(() => {
+ const { fixture, component, dispatchSpy } = setup({
+ routeParams: { type: ComponentType.Connection, componentId:
'conn-1' }
+ });
+ fixture.detectChanges();
+ tick();
+
+ component.onProvenanceCollapsedChange(false);
+
+
expect(dispatchSpy).toHaveBeenCalledWith(connectorProvenanceLoadLatest({
componentId: 'conn-1' }));
+ }));
+
+ it('should dispatch loadLatestEventsForComponent for an InputPort that
allows remote access', fakeAsync(() => {
+ const { fixture, component, dispatchSpy } = setup({
+ routeParams: { type: ComponentType.InputPort, componentId:
'port-1' },
+ inputPorts: [{ id: 'port-1', component: { allowRemoteAccess:
true } }]
+ });
+ fixture.detectChanges();
+ tick();
+
+ component.onProvenanceCollapsedChange(false);
+
+
expect(dispatchSpy).toHaveBeenCalledWith(connectorProvenanceLoadLatest({
componentId: 'port-1' }));
+ }));
+
+ it('should NOT load events for an InputPort without
allowRemoteAccess', fakeAsync(() => {
+ const { fixture, dispatchSpy } = setup({
+ routeParams: { type: ComponentType.InputPort, componentId:
'port-1' },
+ inputPorts: [{ id: 'port-1', component: { allowRemoteAccess:
false } }]
+ });
+ fixture.detectChanges();
+ tick();
+
+ const loads = dispatchSpy.mock.calls.filter(
+ (call: unknown[]) => (call[0] as { type: string }).type ===
connectorProvenanceLoadLatest.type
+ );
+ expect(loads).toHaveLength(0);
+ }));
+
+ it('should dispatch resetState when no component is selected',
fakeAsync(() => {
+ const { fixture, dispatchSpy } = setup({
+ routeParams: { id: DEFAULT_CONNECTOR_ID, processGroupId:
DEFAULT_PROCESS_GROUP_ID }
+ });
+ fixture.detectChanges();
+ tick();
+
+
expect(dispatchSpy).toHaveBeenCalledWith(connectorProvenanceResetState());
+ }));
+
+ it('should NOT load events when an ineligible ProcessGroup is
selected', fakeAsync(() => {
+ const { fixture, dispatchSpy } = setup({
+ routeParams: { type: ComponentType.ProcessGroup, componentId:
'pg-x' }
+ });
+ fixture.detectChanges();
+ tick();
+
+ const loads = dispatchSpy.mock.calls.filter(
+ (call: unknown[]) => (call[0] as { type: string }).type ===
connectorProvenanceLoadLatest.type
+ );
+ expect(loads).toHaveLength(0);
+ }));
+
+ it('should defer loading when graph controls are collapsed and flush
when reopened', fakeAsync(() => {
+ const storage = createMockStorage();
+ storage.getItem.mockImplementation((key: string) => {
+ if (key === 'graph-control-visibility') {
+ return {
+ 'connector-graph-controls': false
+ };
+ }
+ return null;
+ });
+ const { fixture, component, dispatchSpy } = setup(
+ { routeParams: { type: ComponentType.Processor, componentId:
'proc-77' } },
+ storage
+ );
+ fixture.detectChanges();
+ tick();
+
+ const initialLoads = dispatchSpy.mock.calls.filter(
+ (call: unknown[]) => (call[0] as { type: string }).type ===
connectorProvenanceLoadLatest.type
+ );
+ expect(initialLoads).toHaveLength(0);
+
+ dispatchSpy.mockClear();
+ // Mirror the provenance-preview child notifying the parent that
its panel is expanded.
+ component.onProvenanceCollapsedChange(false);
+ component.toggleGraphControls();
+
+
expect(dispatchSpy).toHaveBeenCalledWith(connectorProvenanceLoadLatest({
componentId: 'proc-77' }));
+ }));
+
+ it('should defer loading when the provenance section is collapsed and
flush when expanded', fakeAsync(() => {
+ const storage = createMockStorage();
+ storage.getItem.mockImplementation((key: string) => {
+ if (key === 'graph-control-visibility') {
+ return {
+ 'connector-graph-controls': true
+ };
+ }
+ return null;
+ });
+ const { fixture, component, dispatchSpy } = setup(
+ { routeParams: { type: ComponentType.Processor, componentId:
'proc-88' } },
+ storage
+ );
+ fixture.detectChanges();
+ tick();
+
+ const initialLoads = dispatchSpy.mock.calls.filter(
+ (call: unknown[]) => (call[0] as { type: string }).type ===
connectorProvenanceLoadLatest.type
+ );
+ expect(initialLoads).toHaveLength(0);
+
+ dispatchSpy.mockClear();
+ component.onProvenanceCollapsedChange(false);
+
+
expect(dispatchSpy).toHaveBeenCalledWith(connectorProvenanceLoadLatest({
componentId: 'proc-88' }));
+ }));
+
+ it('should dispatch openProvenanceEventDialog from
onProvenanceViewDetails', fakeAsync(() => {
+ const { fixture, component, dispatchSpy } = setup();
+ fixture.detectChanges();
+ tick();
+ dispatchSpy.mockClear();
+
+ component.onProvenanceViewDetails(SAMPLE_EVENT);
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ connectorProvenanceOpenEventDialog({ request: { event:
SAMPLE_EVENT } })
+ );
+ }));
+
+ it('should dispatch downloadContent from onProvenanceDownloadContent',
fakeAsync(() => {
+ const { fixture, component, dispatchSpy } = setup();
+ fixture.detectChanges();
+ tick();
+ dispatchSpy.mockClear();
+
+ const request = { event: SAMPLE_EVENT, direction: 'input' as const
};
+ component.onProvenanceDownloadContent(request);
+
+
expect(dispatchSpy).toHaveBeenCalledWith(connectorProvenanceDownloadContent({
request }));
+ }));
+
+ it('should dispatch viewContent from onProvenanceViewContent',
fakeAsync(() => {
+ const { fixture, component, dispatchSpy } = setup();
+ fixture.detectChanges();
+ tick();
+ dispatchSpy.mockClear();
+
+ const request = { event: SAMPLE_EVENT, direction: 'output' as
const };
+ component.onProvenanceViewContent(request);
+
+
expect(dispatchSpy).toHaveBeenCalledWith(connectorProvenanceViewContent({
request }));
+ }));
+
+ it('should dispatch replayEvent from onProvenanceReplayEvent',
fakeAsync(() => {
+ const { fixture, component, dispatchSpy } = setup();
+ fixture.detectChanges();
+ tick();
+ dispatchSpy.mockClear();
+
+ component.onProvenanceReplayEvent(SAMPLE_EVENT);
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ connectorProvenanceReplayEvent({ request: { event:
SAMPLE_EVENT } })
+ );
+ }));
+
+ it('should dispatch loadLatestEventsForComponent from
onProvenanceRefresh when an eligible component is selected', fakeAsync(() => {
+ const { fixture, component, dispatchSpy } = setup({
+ routeParams: { type: ComponentType.Processor, componentId:
'proc-99' }
+ });
+ fixture.detectChanges();
+ tick();
+ dispatchSpy.mockClear();
+
+ component.onProvenanceRefresh();
+
+
expect(dispatchSpy).toHaveBeenCalledWith(connectorProvenanceLoadLatest({
componentId: 'proc-99' }));
+ }));
+
+ it('should dispatch resetState on destroy', () => {
+ const { fixture, dispatchSpy } = setup();
+ fixture.detectChanges();
+ dispatchSpy.mockClear();
+
+ fixture.destroy();
+
+
expect(dispatchSpy).toHaveBeenCalledWith(connectorProvenanceResetState());
+ });
+ });
});
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 1f901190aa6..e61e353ede7 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
@@ -62,9 +62,23 @@ import { ConnectorCanvasFooterComponent } from
'./footer/footer.component';
import { ConnectorGraphControls } from
'./graph-controls/connector-graph-controls.component';
import { ContextErrorBanner } from
'../../../../ui/common/context-error-banner/context-error-banner.component';
import { ErrorContextKey } from '../../../../state/error';
+import { selectAbout } from '../../../../state/about/about.selectors';
+import { selectClusterSummary } from
'../../../../state/cluster-summary/cluster-summary.selectors';
+import { ProvenanceEvent } from '../../../../state/shared';
import * as ConnectorCanvasActions from
'../../state/connector-canvas/connector-canvas.actions';
import * as ConnectorCanvasSelectors from
'../../state/connector-canvas/connector-canvas.selectors';
import * as ConnectorCanvasEntityActions from
'../../state/connector-canvas-entity/connector-canvas-entity.actions';
+import * as ConnectorProvenanceActions from
'../../state/connector-provenance-preview/connector-provenance-preview.actions';
+import {
+ selectConnectorProvenanceError,
+ selectConnectorProvenanceEvents,
+ selectConnectorProvenanceStatus
+} from
'../../state/connector-provenance-preview/connector-provenance-preview.selectors';
+import {
+ DownloadContentRequest,
+ ReplayEventRequest,
+ ViewContentRequest
+} from '../../state/connector-provenance-preview';
import * as EmptyQueueActions from
'../../../../state/empty-queue/empty-queue.actions';
import {
selectConnectorCanvasEntity,
@@ -119,6 +133,20 @@ export class ConnectorCanvasComponent implements OnInit,
OnDestroy {
connectorEntity = this.store.selectSignal(selectConnectorCanvasEntity);
entitySaving = this.store.selectSignal(selectConnectorCanvasEntitySaving);
+ provenanceEvents =
this.store.selectSignal(selectConnectorProvenanceEvents);
+ provenanceStatus =
this.store.selectSignal(selectConnectorProvenanceStatus);
+ provenanceError = this.store.selectSignal(selectConnectorProvenanceError);
+
+ private clusterSummary = this.store.selectSignal(selectClusterSummary);
+ private about = this.store.selectSignal(selectAbout);
+
+ connectedToCluster = computed(() =>
this.clusterSummary()?.connectedToCluster ?? false);
+ contentViewerAvailable = computed(() => (this.about()?.contentViewerUrl ??
null) != null);
+
+ private provenanceComponentId = signal<string | null>(null);
+ private provenanceCollapsed = true;
+ private lastDeferredComponentId: string | null = null;
+
protected readonly ErrorContextKey = ErrorContextKey;
constructor() {
@@ -395,12 +423,31 @@ export class ConnectorCanvasComponent implements OnInit,
OnDestroy {
// and the canvas's internal signals (read by
getBirdseyeComponentData) update.
queueMicrotask(() => this.refreshBirdseye());
});
+
+ // Derive provenance eligibility from the route + loaded canvas
entities. The eligible
+ // set covers Processor / Connection / RemoteProcessGroup, plus
InputPort / OutputPort
+ // gated on allowRemoteAccess. All other types (Funnel, Label,
ProcessGroup, multi-select,
+ // and no selection) clear the preview.
+ combineLatest([
+ this.store.select(selectRouteParams),
+ this.store.select(ConnectorCanvasSelectors.selectInputPorts),
+ this.store.select(ConnectorCanvasSelectors.selectOutputPorts)
+ ])
+ .pipe(
+ map(([params, inputPorts, outputPorts]) =>
+ this.computeEligibleProvenanceId(params, inputPorts,
outputPorts)
+ ),
+ distinctUntilChanged(),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe((eligibleId) =>
this.handleProvenanceEligibilityChange(eligibleId));
}
ngOnDestroy(): void {
this.store.dispatch(ConnectorCanvasActions.stopConnectorCanvasPolling());
this.store.dispatch(ConnectorCanvasActions.resetConnectorCanvasState());
this.store.dispatch(ConnectorCanvasEntityActions.resetConnectorCanvasEntityState());
+ this.store.dispatch(ConnectorProvenanceActions.resetState());
}
@HostListener('window:resize')
@@ -507,6 +554,104 @@ export class ConnectorCanvasComponent implements OnInit,
OnDestroy {
this.canvasComponent().birdseyeDragEnd();
}
+ //
=========================================================================
+ // Provenance Preview orchestration
+ //
=========================================================================
+
+ private computeEligibleProvenanceId(
+ params: { [key: string]: string } | null,
+ inputPorts: any[],
+ outputPorts: any[]
+ ): string | null {
+ if (!params || !params['type'] || !params['componentId']) {
+ return null;
+ }
+
+ const componentId = params['componentId'];
+ const componentType = params['type'] as ComponentType;
+
+ switch (componentType) {
+ case ComponentType.Processor:
+ case ComponentType.Connection:
+ case ComponentType.RemoteProcessGroup:
+ return componentId;
+ case ComponentType.InputPort: {
+ const port = inputPorts.find((p: any) => p.id === componentId);
+ return port?.component?.allowRemoteAccess === true ?
componentId : null;
+ }
+ case ComponentType.OutputPort: {
+ const port = outputPorts.find((p: any) => p.id ===
componentId);
+ return port?.component?.allowRemoteAccess === true ?
componentId : null;
+ }
+ default:
+ return null;
+ }
+ }
+
+ private handleProvenanceEligibilityChange(eligibleId: string | null): void
{
+ this.provenanceComponentId.set(eligibleId);
+
+ if (eligibleId === null) {
+ this.lastDeferredComponentId = null;
+ this.store.dispatch(ConnectorProvenanceActions.resetState());
+ return;
+ }
+
+ if (this.graphControlsOpen && !this.provenanceCollapsed) {
+ this.lastDeferredComponentId = null;
+
this.store.dispatch(ConnectorProvenanceActions.loadLatestEventsForComponent({
componentId: eligibleId }));
+ } else {
+ this.lastDeferredComponentId = eligibleId;
+ }
+ }
+
+ private flushDeferredProvenanceLoad(): void {
+ if (!this.graphControlsOpen || this.provenanceCollapsed) {
+ return;
+ }
+
+ const pending = this.lastDeferredComponentId;
+ if (pending) {
+ this.lastDeferredComponentId = null;
+
this.store.dispatch(ConnectorProvenanceActions.loadLatestEventsForComponent({
componentId: pending }));
+ }
+ }
+
+ onProvenanceCollapsedChange(collapsed: boolean): void {
+ this.provenanceCollapsed = collapsed;
+ if (!collapsed) {
+ this.flushDeferredProvenanceLoad();
+ }
+ }
+
+ onProvenanceRefresh(): void {
+ const id = this.provenanceComponentId();
+ if (id) {
+
this.store.dispatch(ConnectorProvenanceActions.loadLatestEventsForComponent({
componentId: id }));
+ }
+ }
+
+ onProvenanceViewDetails(event: ProvenanceEvent): void {
+ this.store.dispatch(
+ ConnectorProvenanceActions.openProvenanceEventDialog({
+ request: { event }
+ })
+ );
+ }
+
+ onProvenanceDownloadContent(request: DownloadContentRequest): void {
+ this.store.dispatch(ConnectorProvenanceActions.downloadContent({
request }));
+ }
+
+ onProvenanceViewContent(request: ViewContentRequest): void {
+ this.store.dispatch(ConnectorProvenanceActions.viewContent({ request
}));
+ }
+
+ onProvenanceReplayEvent(event: ProvenanceEvent): void {
+ const request: ReplayEventRequest = { event };
+ this.store.dispatch(ConnectorProvenanceActions.replayEvent({ request
}));
+ }
+
//
=========================================================================
// Shared Actions (used by both context menu and keyboard shortcuts)
//
=========================================================================
@@ -662,6 +807,10 @@ export class ConnectorCanvasComponent implements OnInit,
OnDestroy {
item[ConnectorCanvasComponent.GRAPH_CONTROL_KEY] =
this.graphControlsOpen;
this.storage.setItem(ConnectorCanvasComponent.CONTROL_VISIBILITY_KEY,
item);
+
+ if (this.graphControlsOpen) {
+ this.flushDeferredProvenanceLoad();
+ }
}
onSearchGoToComponent(event: { id: string; type: ComponentType; groupId:
string }): void {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.module.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.module.ts
index 07e11ae3d3e..50556323a5d 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.module.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.module.ts
@@ -28,6 +28,9 @@ import { connectorCanvasFeatureKey } from
'../../state/connector-canvas';
import { connectorCanvasReducer } from
'../../state/connector-canvas/connector-canvas.reducer';
import { ConnectorCanvasEffects } from
'../../state/connector-canvas/connector-canvas.effects';
import { ConnectorCanvasEntityEffects } from
'../../state/connector-canvas-entity/connector-canvas-entity.effects';
+import { connectorProvenancePreviewFeatureKey } from
'../../state/connector-provenance-preview';
+import { connectorProvenancePreviewReducer } from
'../../state/connector-provenance-preview/connector-provenance-preview.reducer';
+import { ConnectorProvenanceEffects } from
'../../state/connector-provenance-preview/connector-provenance-preview.effects';
const routes: Routes = [
{
@@ -60,7 +63,8 @@ const routes: Routes = [
ConnectorCanvasRedirector,
RouterModule.forChild(routes),
StoreModule.forFeature(connectorCanvasFeatureKey,
connectorCanvasReducer),
- EffectsModule.forFeature(ConnectorCanvasEffects,
ConnectorCanvasEntityEffects)
+ StoreModule.forFeature(connectorProvenancePreviewFeatureKey,
connectorProvenancePreviewReducer),
+ EffectsModule.forFeature(ConnectorCanvasEffects,
ConnectorCanvasEntityEffects, ConnectorProvenanceEffects)
],
providers: [ComponentTypeNamePipe]
})
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html
index 4ecfbe94ec1..e97444df019 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html
@@ -35,6 +35,21 @@
<connector-info-control
[connectorEntity]="connectorEntity()"
[entitySaving]="entitySaving()"></connector-info-control>
- <!-- Provenance preview will be added in Effort 7 (Provenance
Preview). -->
+ @if (canAccessProvenance()) {
+ <provenance-preview
+ storageKey="connector-provenance-control"
+ [events]="provenanceEvents()"
+ [status]="provenanceStatus()"
+ [error]="provenanceError()"
+ [connectedToCluster]="connectedToCluster()"
+ [contentViewerAvailable]="contentViewerAvailable()"
+ (refresh)="provenanceRefresh.emit()"
+ (collapsedChange)="provenanceCollapsedChange.emit($event)"
+ (viewDetails)="provenanceViewDetails.emit($event)"
+ (downloadContent)="provenanceDownloadContent.emit($event)"
+ (viewContent)="provenanceViewContent.emit($event)"
+ (replayEvent)="provenanceReplayEvent.emit($event)">
+ </provenance-preview>
+ }
</div>
</div>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.spec.ts
index 96b60bc05cd..8aa0ec01f1f 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.spec.ts
@@ -15,16 +15,18 @@
* limitations under the License.
*/
-import { Component, input } from '@angular/core';
+import { Component, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ConnectorGraphControls } from './connector-graph-controls.component';
import { ConnectorInfoControl } from
'./connector-info-control/connector-info-control.component';
+import { ProvenancePreview } from
'../../../../../ui/common/provenance-preview/provenance-preview.component';
import { ConnectorEntity } from '@nifi/shared';
import { BirdseyeComponentData, BirdseyeTransform } from
'../../../../../ui/common/birdseye/birdseye.types';
import { Dimension } from '../../../../../ui/common/canvas/canvas.types';
+import { ProvenanceEvent } from '../../../../../state/shared';
@Component({
selector: 'connector-info-control',
@@ -37,12 +39,39 @@ class MockConnectorInfoControl {
entitySaving = input<boolean>(false);
}
+@Component({
+ selector: 'provenance-preview',
+ standalone: true,
+ imports: [CommonModule],
+ template: ''
+})
+class MockProvenancePreview {
+ storageKey = input<string>('provenance-control');
+ events = input<ProvenanceEvent[]>([]);
+ status = input<'pending' | 'loading' | 'success' | 'error'>('pending');
+ error = input<string | null>(null);
+ connectedToCluster = input<boolean>(false);
+ contentViewerAvailable = input<boolean>(false);
+ refresh = output<void>();
+ collapsedChange = output<boolean>();
+ viewDetails = output<ProvenanceEvent>();
+ downloadContent = output<{ event: ProvenanceEvent; direction: 'input' |
'output' }>();
+ viewContent = output<{ event: ProvenanceEvent; direction: 'input' |
'output' }>();
+ replayEvent = output<ProvenanceEvent>();
+}
+
interface SetupInputs {
connectorEntity?: ConnectorEntity | null;
entitySaving?: boolean;
birdseyeComponents?: BirdseyeComponentData[];
birdseyeTransform?: BirdseyeTransform;
canvasDimensions?: Dimension;
+ canAccessProvenance?: boolean;
+ provenanceEvents?: ProvenanceEvent[];
+ provenanceStatus?: 'pending' | 'loading' | 'success' | 'error';
+ provenanceError?: string | null;
+ connectedToCluster?: boolean;
+ contentViewerAvailable?: boolean;
}
async function setup(inputs: SetupInputs = {}) {
@@ -50,8 +79,8 @@ async function setup(inputs: SetupInputs = {}) {
imports: [ConnectorGraphControls, NoopAnimationsModule]
})
.overrideComponent(ConnectorGraphControls, {
- remove: { imports: [ConnectorInfoControl] },
- add: { imports: [MockConnectorInfoControl] }
+ remove: { imports: [ConnectorInfoControl, ProvenancePreview] },
+ add: { imports: [MockConnectorInfoControl, MockProvenancePreview] }
})
.compileComponents();
@@ -64,6 +93,12 @@ async function setup(inputs: SetupInputs = {}) {
inputs.birdseyeTransform ?? { translate: { x: 0, y: 0 }, scale: 1 }
);
fixture.componentRef.setInput('canvasDimensions', inputs.canvasDimensions
?? { width: 0, height: 0 });
+ fixture.componentRef.setInput('canAccessProvenance',
inputs.canAccessProvenance ?? false);
+ fixture.componentRef.setInput('provenanceEvents', inputs.provenanceEvents
?? []);
+ fixture.componentRef.setInput('provenanceStatus', inputs.provenanceStatus
?? 'pending');
+ fixture.componentRef.setInput('provenanceError', inputs.provenanceError ??
null);
+ fixture.componentRef.setInput('connectedToCluster',
inputs.connectedToCluster ?? false);
+ fixture.componentRef.setInput('contentViewerAvailable',
inputs.contentViewerAvailable ?? false);
fixture.detectChanges();
return { fixture, component: fixture.componentInstance };
@@ -80,4 +115,111 @@ describe('ConnectorGraphControls', () => {
const infoControl =
fixture.nativeElement.querySelector('connector-info-control');
expect(infoControl).toBeTruthy();
});
+
+ describe('provenance preview gating', () => {
+ it('should NOT render provenance-preview when canAccessProvenance is
false', async () => {
+ const { fixture } = await setup({ canAccessProvenance: false });
+ const preview =
fixture.nativeElement.querySelector('provenance-preview');
+ expect(preview).toBeNull();
+ });
+
+ it('should render provenance-preview when canAccessProvenance is
true', async () => {
+ const { fixture } = await setup({ canAccessProvenance: true });
+ const preview =
fixture.nativeElement.querySelector('provenance-preview');
+ expect(preview).toBeTruthy();
+ });
+
+ it('should propagate provenance inputs to the provenance-preview
child', async () => {
+ const events = [{ id: 'e1', eventId: 1 } as ProvenanceEvent];
+ const { fixture } = await setup({
+ canAccessProvenance: true,
+ provenanceEvents: events,
+ provenanceStatus: 'success',
+ provenanceError: 'no error',
+ connectedToCluster: true,
+ contentViewerAvailable: true
+ });
+ const previewEl = fixture.debugElement.query((el) => el.name ===
'provenance-preview');
+ const preview = previewEl.componentInstance as
MockProvenancePreview;
+
+ expect(preview.events()).toEqual(events);
+ expect(preview.status()).toBe('success');
+ expect(preview.error()).toBe('no error');
+ expect(preview.connectedToCluster()).toBe(true);
+ expect(preview.contentViewerAvailable()).toBe(true);
+ expect(preview.storageKey()).toBe('connector-provenance-control');
+ });
+ });
+
+ describe('provenance preview outputs', () => {
+ async function setupWithPreview() {
+ const result = await setup({ canAccessProvenance: true });
+ const previewEl = result.fixture.debugElement.query((el) =>
el.name === 'provenance-preview');
+ return { ...result, preview: previewEl.componentInstance as
MockProvenancePreview };
+ }
+
+ it('should re-emit refresh from the provenance-preview', async () => {
+ const { component, preview } = await setupWithPreview();
+ const spy = vi.fn();
+ component.provenanceRefresh.subscribe(spy);
+
+ preview.refresh.emit();
+
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it('should re-emit collapsedChange', async () => {
+ const { component, preview } = await setupWithPreview();
+ const spy = vi.fn();
+ component.provenanceCollapsedChange.subscribe(spy);
+
+ preview.collapsedChange.emit(false);
+
+ expect(spy).toHaveBeenCalledWith(false);
+ });
+
+ it('should re-emit viewDetails', async () => {
+ const { component, preview } = await setupWithPreview();
+ const spy = vi.fn();
+ component.provenanceViewDetails.subscribe(spy);
+ const event = { id: 'evt-1' } as ProvenanceEvent;
+
+ preview.viewDetails.emit(event);
+
+ expect(spy).toHaveBeenCalledWith(event);
+ });
+
+ it('should re-emit downloadContent', async () => {
+ const { component, preview } = await setupWithPreview();
+ const spy = vi.fn();
+ component.provenanceDownloadContent.subscribe(spy);
+ const payload = { event: { id: 'evt-1' } as ProvenanceEvent,
direction: 'input' as const };
+
+ preview.downloadContent.emit(payload);
+
+ expect(spy).toHaveBeenCalledWith(payload);
+ });
+
+ it('should re-emit viewContent', async () => {
+ const { component, preview } = await setupWithPreview();
+ const spy = vi.fn();
+ component.provenanceViewContent.subscribe(spy);
+ const payload = { event: { id: 'evt-1' } as ProvenanceEvent,
direction: 'output' as const };
+
+ preview.viewContent.emit(payload);
+
+ expect(spy).toHaveBeenCalledWith(payload);
+ });
+
+ it('should re-emit replayEvent', async () => {
+ const { component, preview } = await setupWithPreview();
+ const spy = vi.fn();
+ component.provenanceReplayEvent.subscribe(spy);
+ const event = { id: 'evt-1' } as ProvenanceEvent;
+
+ preview.replayEvent.emit(event);
+
+ expect(spy).toHaveBeenCalledWith(event);
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts
index b5d6dcbcc40..40f46400da3 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts
@@ -21,11 +21,13 @@ import { CanvasNavigationControl } from
'../../../../../ui/common/navigation-con
import { BirdseyeComponentData, BirdseyeTransform } from
'../../../../../ui/common/birdseye/birdseye.types';
import { Dimension, Position } from
'../../../../../ui/common/canvas/canvas.types';
import { ConnectorInfoControl } from
'./connector-info-control/connector-info-control.component';
+import { ProvenancePreview } from
'../../../../../ui/common/provenance-preview/provenance-preview.component';
+import { ProvenanceEvent } from '../../../../../state/shared';
@Component({
selector: 'connector-graph-controls',
standalone: true,
- imports: [CanvasNavigationControl, ConnectorInfoControl],
+ imports: [CanvasNavigationControl, ConnectorInfoControl,
ProvenancePreview],
templateUrl: './connector-graph-controls.component.html',
styleUrls: ['./connector-graph-controls.component.scss']
})
@@ -38,6 +40,13 @@ export class ConnectorGraphControls {
canvasDimensions = input.required<Dimension>();
canNavigateToParent = input<boolean>(false);
+ canAccessProvenance = input<boolean>(false);
+ provenanceEvents = input<ProvenanceEvent[]>([]);
+ provenanceStatus = input<'pending' | 'loading' | 'success' |
'error'>('pending');
+ provenanceError = input<string | null>(null);
+ connectedToCluster = input<boolean>(false);
+ contentViewerAvailable = input<boolean>(false);
+
viewportChange = output<Position>();
birdseyeDragStart = output<void>();
birdseyeDragEnd = output<void>();
@@ -47,4 +56,11 @@ export class ConnectorGraphControls {
zoomFit = output<void>();
zoomActual = output<void>();
leaveGroup = output<void>();
+
+ provenanceRefresh = output<void>();
+ provenanceCollapsedChange = output<boolean>();
+ provenanceViewDetails = output<ProvenanceEvent>();
+ provenanceDownloadContent = output<{ event: ProvenanceEvent; direction:
'input' | 'output' }>();
+ provenanceViewContent = output<{ event: ProvenanceEvent; direction:
'input' | 'output' }>();
+ provenanceReplayEvent = output<ProvenanceEvent>();
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/provenance/service/provenance.service.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/provenance/service/provenance.service.ts
index 7f75252764d..235c5ebc13c 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/provenance/service/provenance.service.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/provenance/service/provenance.service.ts
@@ -66,6 +66,12 @@ export class ProvenanceService {
});
}
+ getLatestEventsForComponent(componentId: string): Observable<any> {
+ return this.httpClient.get(
+
`${ProvenanceService.API}/provenance-events/latest/${encodeURIComponent(componentId)}`
+ );
+ }
+
downloadContent(eventId: number, direction: string, clusterNodeId?:
string): void {
let dataUri =
`${ProvenanceService.API}/provenance-events/${encodeURIComponent(
eventId
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.html
new file mode 100644
index 00000000000..166bb0328c0
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.html
@@ -0,0 +1,168 @@
+<!--
+ ~ 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.
+ -->
+
+<mat-expansion-panel
+ [hideToggle]="true"
+ [expanded]="!provenanceCollapsed"
+ (opened)="toggleCollapsed(false)"
+ (closed)="toggleCollapsed(true)">
+ <mat-expansion-panel-header>
+ <mat-panel-title>
+ <div class="flex items-center gap-x-2">
+ <div class="w-5 icon icon-provenance primary-color"></div>
+ <div>Provenance</div>
+ </div>
+ </mat-panel-title>
+ @if (!provenanceCollapsed && status() !== 'pending') {
+ <mat-panel-description class="provenance-control-header flex
justify-end">
+ <button
+ mat-icon-button
+ class="primary-icon-button mr-1"
+ type="button"
+ aria-label="Refresh"
+ (click)="refreshClicked($event)">
+ <i class="fa fa-refresh" [class.fa-spin]="status() ===
'loading'"></i>
+ </button>
+ </mat-panel-description>
+ }
+ </mat-expansion-panel-header>
+ @if (status() === 'success' || status() === 'error' || status() ===
'loading') {
+ <div class="provenance-control flex flex-col">
+ @if (status() === 'loading') {
+ <div class="w-full">
+ <ngx-skeleton-loader count="1"></ngx-skeleton-loader>
+ </div>
+ } @else {
+ <div class="provenance-preview-container flex flex-col
gap-y-2">
+ @switch (status()) {
+ @case ('error') {
+ <div>{{ error() }}</div>
+ }
+ @case ('success') {
+ @if (events().length === 0) {
+ <div class="unset">No recent events for
current selection</div>
+ } @else {
+ @if (connectedToCluster() &&
availableNodes.length > 0) {
+ <form [formGroup]="clusterForm">
+ <div class="flex flex-col">
+ <mat-label
class="form-field-label">Node</mat-label>
+ <mat-form-field
subscriptSizing="dynamic">
+ <mat-select
+ formControlName="nodes"
+
(selectionChange)="filterByNode($event)">
+ @for (option of
availableNodes; track option.value) {
+ <mat-option
[value]="option.value">{{
+ option.text
+ }}</mat-option>
+ }
+ </mat-select>
+ </mat-form-field>
+ </div>
+ </form>
+ }
+ <div class="flex-1">
+ <div class="listing-table">
+ <table mat-table
[dataSource]="dataSource">
+ <!-- Type Column -->
+ <ng-container
matColumnDef="eventType">
+ <th mat-header-cell
*matHeaderCellDef>Event type</th>
+ <td mat-cell *matCellDef="let
item">
+ {{ item.eventType }}
+ </td>
+ </ng-container>
+
+ <!-- Actions Column -->
+ <ng-container
matColumnDef="actions">
+ <th mat-header-cell
*matHeaderCellDef></th>
+ <td mat-cell *matCellDef="let
item">
+ <div class="flex
items-center justify-end gap-x-2">
+ <button
+ mat-icon-button
+ type="button"
+
aria-label="Actions"
+
[matMenuTriggerFor]="actionMenu"
+
class="primary-icon-button">
+ <i class="fa
fa-ellipsis-v"></i>
+ </button>
+ <mat-menu
#actionMenu="matMenu" xPosition="before">
+ <button
mat-menu-item (click)="viewDetailsClicked(item)">
+ <i class="fa
fa-info-circle mr-2"></i>
+ View details
+ </button>
+ @if
(canViewContent(item, 'input')) {
+ <button
+
mat-menu-item
+
(click)="viewContentClicked(item, 'input')">
+ <i
class="fa fa-eye mr-2"></i>
+ View input
+ </button>
+ }
+ @if
(canViewContent(item, 'output')) {
+ <button
+
mat-menu-item
+
(click)="viewContentClicked(item, 'output')">
+ <i
class="fa fa-eye mr-2"></i>
+ View output
+ </button>
+ }
+ @if
(canDownloadContent(item, 'input')) {
+ <button
+
mat-menu-item
+
(click)="downloadContentClicked(item, 'input')">
+ <i
class="fa fa-download mr-2"></i>
+ Download
input
+ </button>
+ }
+ @if
(canDownloadContent(item, 'output')) {
+ <button
+
mat-menu-item
+
(click)="downloadContentClicked(item, 'output')">
+ <i
class="fa fa-download mr-2"></i>
+ Download
output
+ </button>
+ }
+ @if
(item.replayAvailable) {
+ <button
mat-menu-item (click)="replayClicked(item)">
+ <i
class="fa fa-repeat mr-2"></i>
+ Replay
+ </button>
+ }
+ </mat-menu>
+ </div>
+ </td>
+ </ng-container>
+
+ <tr mat-header-row
*matHeaderRowDef="displayedColumns; sticky: true"></tr>
+ <tr
+ mat-row
+ *matRowDef="let row; let even
= even; columns: displayedColumns"
+ (click)="select(row)"
+
[class.selected]="isSelected(row)"
+ [class.even]="even"></tr>
+ </table>
+ </div>
+ </div>
+ }
+ }
+ }
+ </div>
+ }
+ </div>
+ } @else {
+ <div class="unset">Current selection does not generate events</div>
+ }
+</mat-expansion-panel>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.scss
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.scss
new file mode 100644
index 00000000000..2944f981947
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.scss
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.spec.ts
new file mode 100644
index 00000000000..d2a265c90b6
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.spec.ts
@@ -0,0 +1,368 @@
+/*
+ * 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, ComponentFixture } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { NiFiCommon, Storage } from '@nifi/shared';
+import { ProvenancePreview } from './provenance-preview.component';
+import { ProvenanceEvent } from '../../../state/shared';
+
+describe('ProvenancePreview', () => {
+ function createMockEvent(overrides: Partial<ProvenanceEvent> = {}):
ProvenanceEvent {
+ return {
+ id: '1',
+ eventId: 100,
+ eventTime: '01/01/2025 12:00:00 UTC',
+ eventType: 'RECEIVE',
+ componentId: 'proc-1',
+ componentType: 'Processor',
+ componentName: 'MyProcessor',
+ clusterNodeId: 'node-1',
+ clusterNodeAddress: 'node-1.local',
+ inputContentAvailable: true,
+ outputContentAvailable: true,
+ replayAvailable: true,
+ sourceConnectionIdentifier: 'conn-1',
+ ...overrides
+ } as ProvenanceEvent;
+ }
+
+ interface SetupOptions {
+ events?: ProvenanceEvent[];
+ status?: 'pending' | 'loading' | 'success' | 'error';
+ error?: string | null;
+ connectedToCluster?: boolean;
+ contentViewerAvailable?: boolean;
+ storageKey?: string;
+ collapsed?: boolean;
+ }
+
+ async function setup(options: SetupOptions = {}) {
+ const mockNiFiCommon = {
+ parseDateTime: vi.fn().mockReturnValue(new
Date('2025-01-01T12:00:00Z')),
+ compareNumber: vi.fn().mockReturnValue(0),
+ substringBeforeFirst: vi.fn().mockImplementation((str: string,
sep: string) => {
+ const i = str.indexOf(sep);
+ return i >= 0 ? str.substring(0, i) : str;
+ })
+ };
+
+ const mockStorage = {
+ getItem: vi.fn().mockReturnValue(null),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ hasItem: vi.fn().mockReturnValue(false)
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [ProvenancePreview, NoopAnimationsModule],
+ providers: [
+ { provide: NiFiCommon, useValue: mockNiFiCommon },
+ { provide: Storage, useValue: mockStorage }
+ ]
+ }).compileComponents();
+
+ const fixture: ComponentFixture<ProvenancePreview> =
TestBed.createComponent(ProvenancePreview);
+ const component = fixture.componentInstance;
+
+ fixture.componentRef.setInput('events', options.events ?? []);
+ fixture.componentRef.setInput('status', options.status ?? 'pending');
+
+ if (options.error !== undefined) {
+ fixture.componentRef.setInput('error', options.error);
+ }
+ if (options.connectedToCluster !== undefined) {
+ fixture.componentRef.setInput('connectedToCluster',
options.connectedToCluster);
+ }
+ if (options.contentViewerAvailable !== undefined) {
+ fixture.componentRef.setInput('contentViewerAvailable',
options.contentViewerAvailable);
+ }
+ if (options.storageKey !== undefined) {
+ fixture.componentRef.setInput('storageKey', options.storageKey);
+ }
+
+ if (options.collapsed !== undefined) {
+ component.provenanceCollapsed = options.collapsed;
+ }
+
+ fixture.detectChanges();
+
+ return { fixture, component, mockNiFiCommon, mockStorage };
+ }
+
+ describe('initialization', () => {
+ it('should create the component', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should restore collapsed state from storage', async () => {
+ const restoreStorage = {
+ getItem: vi.fn().mockReturnValue({ 'provenance-control': true
}),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ hasItem: vi.fn().mockReturnValue(true)
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [ProvenancePreview, NoopAnimationsModule],
+ providers: [
+ {
+ provide: NiFiCommon,
+ useValue: {
+ parseDateTime: vi.fn().mockReturnValue(new Date()),
+ compareNumber: vi.fn().mockReturnValue(0),
+ substringBeforeFirst: vi.fn().mockReturnValue('')
+ }
+ },
+ { provide: Storage, useValue: restoreStorage }
+ ]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(ProvenancePreview);
+ fixture.componentRef.setInput('events', []);
+ fixture.componentRef.setInput('status', 'pending');
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.provenanceCollapsed).toBe(false);
+ });
+ });
+
+ describe('pending state', () => {
+ it('should show "Current selection does not generate events" message',
async () => {
+ const { fixture } = await setup({ status: 'pending' });
+ expect(fixture.nativeElement.textContent).toContain('Current
selection does not generate events');
+ });
+ });
+
+ describe('loading state', () => {
+ it('should show skeleton loader', async () => {
+ const { fixture } = await setup({
+ status: 'loading',
+ collapsed: false
+ });
+
+ const skeleton =
fixture.nativeElement.querySelector('ngx-skeleton-loader');
+ expect(skeleton).toBeTruthy();
+ });
+ });
+
+ describe('error state', () => {
+ it('should display the error message', async () => {
+ const { fixture } = await setup({
+ status: 'error',
+ error: 'Network error occurred',
+ collapsed: false
+ });
+
+ expect(fixture.nativeElement.textContent).toContain('Network error
occurred');
+ });
+ });
+
+ describe('success state', () => {
+ it('should display "No recent events" when events array is empty',
async () => {
+ const { fixture } = await setup({
+ status: 'success',
+ events: [],
+ collapsed: false
+ });
+
+ expect(fixture.nativeElement.textContent).toContain('No recent
events for current selection');
+ });
+
+ it('should display events in the table', async () => {
+ const events = [createMockEvent({ eventType: 'RECEIVE' })];
+ const { fixture } = await setup({
+ status: 'success',
+ events,
+ collapsed: false
+ });
+
+ expect(fixture.nativeElement.textContent).toContain('RECEIVE');
+ });
+ });
+
+ describe('outputs', () => {
+ it('should emit collapsedChange when panel is toggled', async () => {
+ const { component } = await setup();
+ const spy = vi.fn();
+ component.collapsedChange.subscribe(spy);
+
+ component.toggleCollapsed(false);
+
+ expect(spy).toHaveBeenCalledWith(false);
+ expect(component.provenanceCollapsed).toBe(false);
+ });
+
+ it('should persist collapsed state to storage', async () => {
+ const { component, mockStorage } = await setup();
+
+ component.toggleCollapsed(false);
+
+ expect(mockStorage.setItem).toHaveBeenCalledWith(
+ 'graph-control-visibility',
+ expect.objectContaining({ 'provenance-control': true })
+ );
+ });
+
+ it('should emit refresh on refresh button click and stop event
propagation', async () => {
+ const { component } = await setup();
+ const spy = vi.fn();
+ component.refresh.subscribe(spy);
+
+ const event = new MouseEvent('click');
+ vi.spyOn(event, 'stopPropagation');
+ component.refreshClicked(event);
+
+ expect(spy).toHaveBeenCalled();
+ expect(event.stopPropagation).toHaveBeenCalled();
+ });
+
+ it('should emit viewDetails when view details is clicked', async () =>
{
+ const { component } = await setup();
+ const spy = vi.fn();
+ component.viewDetails.subscribe(spy);
+ const event = createMockEvent();
+
+ component.viewDetailsClicked(event);
+
+ expect(spy).toHaveBeenCalledWith(event);
+ });
+
+ it('should emit downloadContent with direction', async () => {
+ const { component } = await setup();
+ const spy = vi.fn();
+ component.downloadContent.subscribe(spy);
+ const event = createMockEvent();
+
+ component.downloadContentClicked(event, 'input');
+
+ expect(spy).toHaveBeenCalledWith({ event, direction: 'input' });
+ });
+
+ it('should emit viewContent with direction', async () => {
+ const { component } = await setup({ contentViewerAvailable: true
});
+ const spy = vi.fn();
+ component.viewContent.subscribe(spy);
+ const event = createMockEvent();
+
+ component.viewContentClicked(event, 'output');
+
+ expect(spy).toHaveBeenCalledWith({ event, direction: 'output' });
+ });
+
+ it('should emit replayEvent when replay is clicked', async () => {
+ const { component } = await setup();
+ const spy = vi.fn();
+ component.replayEvent.subscribe(spy);
+ const event = createMockEvent();
+
+ component.replayClicked(event);
+
+ expect(spy).toHaveBeenCalledWith(event);
+ });
+ });
+
+ describe('content availability checks', () => {
+ it('should return true for canDownloadContent when input content is
available', async () => {
+ const { component } = await setup();
+ const event = createMockEvent({ inputContentAvailable: true });
+
+ expect(component.canDownloadContent(event, 'input')).toBe(true);
+ });
+
+ it('should return false for canDownloadContent when input content is
not available', async () => {
+ const { component } = await setup();
+ const event = createMockEvent({ inputContentAvailable: false });
+
+ expect(component.canDownloadContent(event, 'input')).toBe(false);
+ });
+
+ it('should return true for canViewContent when content viewer is
available and content exists', async () => {
+ const { component } = await setup({ contentViewerAvailable: true
});
+ const event = createMockEvent({ outputContentAvailable: true });
+
+ expect(component.canViewContent(event, 'output')).toBe(true);
+ });
+
+ it('should return false for canViewContent when content viewer is not
available', async () => {
+ const { component } = await setup({ contentViewerAvailable: false
});
+ const event = createMockEvent({ outputContentAvailable: true });
+
+ expect(component.canViewContent(event, 'output')).toBe(false);
+ });
+ });
+
+ describe('row selection', () => {
+ it('should select a row', async () => {
+ const { component } = await setup();
+ const event = createMockEvent();
+
+ component.select(event);
+
+ expect(component.selectedId).toBe(event.id);
+ });
+
+ it('should return true for isSelected when the row is selected', async
() => {
+ const { component } = await setup();
+ const event = createMockEvent();
+
+ component.select(event);
+
+ expect(component.isSelected(event)).toBe(true);
+ });
+
+ it('should return false for isSelected when no row is selected', async
() => {
+ const { component } = await setup();
+ const event = createMockEvent();
+
+ expect(component.isSelected(event)).toBe(false);
+ });
+ });
+
+ describe('formatHostname', () => {
+ it('should strip the domain from a fully qualified hostname', async ()
=> {
+ const { component } = await setup();
+
expect(component.formatHostname('node-1.example.com')).toBe('node-1');
+ });
+
+ it('should return the address unchanged when there is no separator',
async () => {
+ const { component } = await setup();
+ expect(component.formatHostname('node-1')).toBe('node-1');
+ });
+
+ it('should return empty string for undefined address', async () => {
+ const { component } = await setup();
+ expect(component.formatHostname(undefined)).toBe('');
+ });
+ });
+
+ describe('custom storageKey', () => {
+ it('should use the supplied storageKey for persistence', async () => {
+ const { component, mockStorage } = await setup({
+ storageKey: 'connector-provenance-control'
+ });
+
+ component.toggleCollapsed(false);
+
+ expect(mockStorage.setItem).toHaveBeenCalledWith(
+ 'graph-control-visibility',
+ expect.objectContaining({ 'connector-provenance-control': true
})
+ );
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.ts
new file mode 100644
index 00000000000..f2cc82cbad3
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.ts
@@ -0,0 +1,238 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component, effect, inject, input, output } from '@angular/core';
+
+import { MatButtonModule } from '@angular/material/button';
+import { MatExpansionModule } from '@angular/material/expansion';
+import { MatFormField, MatLabel } from '@angular/material/form-field';
+import { MatMenuModule } from '@angular/material/menu';
+import { MatSelectChange, MatSelectModule } from '@angular/material/select';
+import { MatTableDataSource, MatTableModule } from '@angular/material/table';
+import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
+
+import { NiFiCommon, SelectOption, Storage } from '@nifi/shared';
+import { ProvenanceEvent } from '../../../state/shared';
+
+@Component({
+ selector: 'provenance-preview',
+ templateUrl: './provenance-preview.component.html',
+ imports: [
+ MatButtonModule,
+ MatExpansionModule,
+ MatFormField,
+ MatLabel,
+ MatMenuModule,
+ MatSelectModule,
+ MatTableModule,
+ NgxSkeletonLoaderModule,
+ ReactiveFormsModule
+ ],
+ styleUrls: ['./provenance-preview.component.scss']
+})
+export class ProvenancePreview {
+ private formBuilder = inject(FormBuilder);
+ private nifiCommon = inject(NiFiCommon);
+ private storage = inject(Storage);
+
+ private static readonly CONTROL_VISIBILITY_KEY: string =
'graph-control-visibility';
+ private static readonly HOSTNAME_SEPARATOR: string = '.';
+
+ events = input.required<ProvenanceEvent[]>();
+ status = input.required<'pending' | 'loading' | 'success' | 'error'>();
+ error = input<string | null>(null);
+ connectedToCluster = input<boolean>(false);
+ contentViewerAvailable = input<boolean>(false);
+ storageKey = input<string>('provenance-control');
+
+ refresh = output<void>();
+ collapsedChange = output<boolean>();
+ viewDetails = output<ProvenanceEvent>();
+ downloadContent = output<{ event: ProvenanceEvent; direction: 'input' |
'output' }>();
+ viewContent = output<{ event: ProvenanceEvent; direction: 'input' |
'output' }>();
+ replayEvent = output<ProvenanceEvent>();
+
+ provenanceCollapsed = true;
+ clusterForm: FormGroup;
+ availableNodes: SelectOption[] = [];
+ displayedColumns: string[] = ['eventType', 'actions'];
+ dataSource: MatTableDataSource<ProvenanceEvent> = new
MatTableDataSource<ProvenanceEvent>();
+ selectedId: string | null = null;
+
+ constructor() {
+ this.clusterForm = this.formBuilder.group({
+ nodes: null
+ });
+
+ this.restoreCollapsedState();
+
+ effect(() => {
+ const events = this.events();
+ this.selectedId = null;
+ this.dataSource.data = this.sortEntities(events);
+ this.prepareNodeOptions();
+ });
+
+ effect(() => {
+ this.connectedToCluster();
+ this.prepareNodeOptions();
+ });
+ }
+
+ toggleCollapsed(collapsed: boolean): void {
+ this.provenanceCollapsed = collapsed;
+ this.persistCollapsedState(collapsed);
+ this.collapsedChange.emit(collapsed);
+ }
+
+ refreshClicked(event: MouseEvent): void {
+ event.stopPropagation();
+ this.refresh.emit();
+ }
+
+ filterByNode(event: MatSelectChange): void {
+ this.applyFilter(event.value);
+ }
+
+ select(event: ProvenanceEvent): void {
+ this.selectedId = event.id;
+ }
+
+ isSelected(event: ProvenanceEvent): boolean {
+ if (this.selectedId) {
+ return event.id === this.selectedId;
+ }
+ return false;
+ }
+
+ viewDetailsClicked(event: ProvenanceEvent): void {
+ this.viewDetails.emit(event);
+ }
+
+ canDownloadContent(provenanceEvent: ProvenanceEvent, direction: 'input' |
'output'): boolean {
+ if (direction === 'input') {
+ return provenanceEvent.inputContentAvailable;
+ } else {
+ return provenanceEvent.outputContentAvailable;
+ }
+ }
+
+ downloadContentClicked(event: ProvenanceEvent, direction: 'input' |
'output'): void {
+ this.downloadContent.emit({ event, direction });
+ }
+
+ canViewContent(provenanceEvent: ProvenanceEvent, direction: 'input' |
'output'): boolean {
+ if (this.contentViewerAvailable()) {
+ return this.canDownloadContent(provenanceEvent, direction);
+ }
+ return false;
+ }
+
+ viewContentClicked(event: ProvenanceEvent, direction: 'input' | 'output'):
void {
+ this.viewContent.emit({ event, direction });
+ }
+
+ replayClicked(event: ProvenanceEvent): void {
+ this.replayEvent.emit(event);
+ }
+
+ formatHostname(clusterNodeAddress?: string): string {
+ if (!clusterNodeAddress) {
+ return '';
+ }
+
+ const separatorIndex =
clusterNodeAddress.indexOf(ProvenancePreview.HOSTNAME_SEPARATOR);
+ if (separatorIndex >= 0) {
+ return this.nifiCommon.substringBeforeFirst(clusterNodeAddress,
ProvenancePreview.HOSTNAME_SEPARATOR);
+ }
+ return clusterNodeAddress;
+ }
+
+ private sortEntities(data: ProvenanceEvent[]): ProvenanceEvent[] {
+ if (!data) {
+ return [];
+ }
+ return data.slice().sort((a, b) => {
+ return (
+ this.nifiCommon.compareNumber(
+ this.nifiCommon.parseDateTime(a.eventTime).getTime(),
+ this.nifiCommon.parseDateTime(b.eventTime).getTime()
+ ) * -1
+ );
+ });
+ }
+
+ private prepareNodeOptions(): void {
+ if (this.connectedToCluster() && this.dataSource.data.length > 0) {
+ const nodeAddressLookup = new Map<string, string>();
+
+ this.dataSource.data.forEach((event) => {
+ nodeAddressLookup.set(event.clusterNodeId,
this.formatHostname(event.clusterNodeAddress));
+ });
+
+ const nodeIds = Array.from(nodeAddressLookup.keys());
+ this.availableNodes = nodeIds.map((nodeId) => {
+ return {
+ text: nodeAddressLookup.get(nodeId),
+ value: nodeId
+ } as SelectOption;
+ });
+
+ const selectedNodeIdFromLatestEvent =
this.dataSource.data[0].clusterNodeId;
+ const currentSelectedNodeId = this.clusterForm.get('nodes')?.value;
+
+ if (!currentSelectedNodeId ||
!nodeAddressLookup.has(currentSelectedNodeId)) {
+
this.clusterForm.get('nodes')?.setValue(selectedNodeIdFromLatestEvent);
+ this.applyFilter(selectedNodeIdFromLatestEvent);
+ } else {
+ this.applyFilter(currentSelectedNodeId);
+ }
+ } else {
+ this.availableNodes = [];
+ this.dataSource.filter = '';
+ }
+ }
+
+ private applyFilter(clusterNodeId: string): void {
+ this.dataSource.filterPredicate = (provenanceEvent: ProvenanceEvent) =>
+ provenanceEvent.clusterNodeId === clusterNodeId;
+ this.dataSource.filter = ' ';
+ }
+
+ private restoreCollapsedState(): void {
+ try {
+ const item: { [key: string]: boolean } | null =
this.storage.getItem(
+ ProvenancePreview.CONTROL_VISIBILITY_KEY
+ );
+ if (item) {
+ this.provenanceCollapsed = !item[this.storageKey()];
+ }
+ } catch (_e) {
+ // likely could not parse item... ignoring
+ }
+ }
+
+ private persistCollapsedState(collapsed: boolean): void {
+ let item: { [key: string]: boolean } | null =
this.storage.getItem(ProvenancePreview.CONTROL_VISIBILITY_KEY);
+ if (item == null) {
+ item = {};
+ }
+ item[this.storageKey()] = !collapsed;
+ this.storage.setItem(ProvenancePreview.CONTROL_VISIBILITY_KEY, item);
+ }
+}