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 000000000000..22e5a29ca79e --- /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 000000000000..4307643f7c03 --- /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(), + viewContent: new Subject(), + replay: new Subject() + }, + afterClosed: () => new Subject(), + close: vi.fn() + }; + } + + async function setup(options: SetupOptions = {}) { + const actions$ = new ReplaySubject(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((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((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((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((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((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 000000000000..0f876c318d3e --- /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); + 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 000000000000..a6d655770159 --- /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 000000000000..e0e84fbfed06 --- /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 000000000000..3705123f31de --- /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 000000000000..7bbd24551c79 --- /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( + 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 000000000000..3ecf3df8fe50 --- /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 0477e70fa0db..dd19242b8409 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)"> 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 d28f81a04e04..d5ead9ec1301 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 @@ -68,6 +71,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({ @@ -156,6 +172,12 @@ class MockConnectorGraphControls { birdseyeTransform = input({ translate: { x: 0, y: 0 }, scale: 1 }); canvasDimensions = input({ width: 0, height: 0 }); canNavigateToParent = input(false); + canAccessProvenance = input(false); + provenanceEvents = input([]); + provenanceStatus = input<'pending' | 'loading' | 'success' | 'error'>('pending'); + provenanceError = input(null); + connectedToCluster = input(false); + contentViewerAvailable = input(false); viewportChange = output<{ x: number; y: number }>(); birdseyeDragStart = output(); birdseyeDragEnd = output(); @@ -164,6 +186,12 @@ class MockConnectorGraphControls { zoomFit = output(); zoomActual = output(); leaveGroup = output(); + provenanceRefresh = output(); + provenanceCollapsedChange = output(); + provenanceViewDetails = output(); + provenanceDownloadContent = output<{ event: ProvenanceEvent; direction: 'input' | 'output' }>(); + provenanceViewContent = output<{ event: ProvenanceEvent; direction: 'input' | 'output' }>(); + provenanceReplayEvent = output(); } @Component({ @@ -192,6 +220,13 @@ interface SetupOptions { skipTransform?: boolean; routeParams?: Record; 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'; @@ -232,6 +267,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: [] }, @@ -245,7 +282,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 } } ]; } @@ -2124,4 +2169,280 @@ describe('ConnectorCanvasComponent', () => { expect(graphControls.canNavigateToParent()).toBe(true); })); }); + + describe('Provenance preview wiring', () => { + function getGraphControlsMock(fixture: ComponentFixture): 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 84e762ea1eeb..6e9bd7c91851 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(null); + private provenanceCollapsed = true; + private lastDeferredComponentId: string | null = null; + protected readonly ErrorContextKey = ErrorContextKey; constructor() { @@ -391,11 +419,30 @@ 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.resetConnectorCanvasState()); this.store.dispatch(ConnectorCanvasEntityActions.resetConnectorCanvasEntityState()); + this.store.dispatch(ConnectorProvenanceActions.resetState()); } @HostListener('window:resize') @@ -502,6 +549,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) // ========================================================================= @@ -657,6 +802,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 07e11ae3d3e9..50556323a5dc 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 4ecfbe94ec13..e97444df0190 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 @@ - + @if (canAccessProvenance()) { + + + } 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 96b60bc05cde..8aa0ec01f1f8 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(false); } +@Component({ + selector: 'provenance-preview', + standalone: true, + imports: [CommonModule], + template: '' +}) +class MockProvenancePreview { + storageKey = input('provenance-control'); + events = input([]); + status = input<'pending' | 'loading' | 'success' | 'error'>('pending'); + error = input(null); + connectedToCluster = input(false); + contentViewerAvailable = input(false); + refresh = output(); + collapsedChange = output(); + viewDetails = output(); + downloadContent = output<{ event: ProvenanceEvent; direction: 'input' | 'output' }>(); + viewContent = output<{ event: ProvenanceEvent; direction: 'input' | 'output' }>(); + replayEvent = output(); +} + 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 b5d6dcbcc403..40f46400da38 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(); canNavigateToParent = input(false); + canAccessProvenance = input(false); + provenanceEvents = input([]); + provenanceStatus = input<'pending' | 'loading' | 'success' | 'error'>('pending'); + provenanceError = input(null); + connectedToCluster = input(false); + contentViewerAvailable = input(false); + viewportChange = output(); birdseyeDragStart = output(); birdseyeDragEnd = output(); @@ -47,4 +56,11 @@ export class ConnectorGraphControls { zoomFit = output(); zoomActual = output(); leaveGroup = output(); + + provenanceRefresh = output(); + provenanceCollapsedChange = output(); + provenanceViewDetails = output(); + provenanceDownloadContent = output<{ event: ProvenanceEvent; direction: 'input' | 'output' }>(); + provenanceViewContent = output<{ event: ProvenanceEvent; direction: 'input' | 'output' }>(); + provenanceReplayEvent = output(); } 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 7f75252764d6..235c5ebc13c0 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 { + 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 000000000000..166bb0328c02 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/provenance-preview/provenance-preview.component.html @@ -0,0 +1,168 @@ + + + + + +
+
+
Provenance
+
+
+ @if (!provenanceCollapsed && status() !== 'pending') { + + + + } +
+ @if (status() === 'success' || status() === 'error' || status() === 'loading') { +
+ @if (status() === 'loading') { +
+ +
+ } @else { +
+ @switch (status()) { + @case ('error') { +
{{ error() }}
+ } + @case ('success') { + @if (events().length === 0) { +
No recent events for current selection
+ } @else { + @if (connectedToCluster() && availableNodes.length > 0) { +
+
+ Node + + + @for (option of availableNodes; track option.value) { + {{ + option.text + }} + } + + +
+
+ } +
+
+ + + + + + + + + + + + + + + +
Event type + {{ item.eventType }} + +
+ + + + @if (canViewContent(item, 'input')) { + + } + @if (canViewContent(item, 'output')) { + + } + @if (canDownloadContent(item, 'input')) { + + } + @if (canDownloadContent(item, 'output')) { + + } + @if (item.replayAvailable) { + + } + +
+
+
+
+ } + } + } +
+ } +
+ } @else { +
Current selection does not generate events
+ } +
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 000000000000..2944f9819474 --- /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 000000000000..d2a265c90b69 --- /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 { + 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 = 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 000000000000..f2cc82cbad3e --- /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(); + status = input.required<'pending' | 'loading' | 'success' | 'error'>(); + error = input(null); + connectedToCluster = input(false); + contentViewerAvailable = input(false); + storageKey = input('provenance-control'); + + refresh = output(); + collapsedChange = output(); + viewDetails = output(); + downloadContent = output<{ event: ProvenanceEvent; direction: 'input' | 'output' }>(); + viewContent = output<{ event: ProvenanceEvent; direction: 'input' | 'output' }>(); + replayEvent = output(); + + provenanceCollapsed = true; + clusterForm: FormGroup; + availableNodes: SelectOption[] = []; + displayedColumns: string[] = ['eventType', 'actions']; + dataSource: MatTableDataSource = new MatTableDataSource(); + 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(); + + 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); + } +}