This is an automated email from the ASF dual-hosted git repository.

rfellows pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 56543a76af8 NIFI-15823: Adding the proveance preview component into 
the connector canvas graph controls. (#11238)
56543a76af8 is described below

commit 56543a76af8430ea4c59ca888656dd6b0dfba4a8
Author: Matt Gilman <[email protected]>
AuthorDate: Thu May 14 13:09:44 2026 -0400

    NIFI-15823: Adding the proveance preview component into the connector 
canvas graph controls. (#11238)
---
 .../connector-provenance-preview.actions.ts        |  59 ++++
 .../connector-provenance-preview.effects.spec.ts   | 314 ++++++++++++++++++
 .../connector-provenance-preview.effects.ts        | 229 +++++++++++++
 .../connector-provenance-preview.reducer.spec.ts   |  68 ++++
 .../connector-provenance-preview.reducer.ts        |  54 +++
 .../connector-provenance-preview.selectors.spec.ts |  53 +++
 .../connector-provenance-preview.selectors.ts      |  38 +++
 .../state/connector-provenance-preview/index.ts    |  40 +++
 .../connector-canvas.component.html                |  14 +-
 .../connector-canvas.component.spec.ts             | 323 +++++++++++++++++-
 .../connector-canvas/connector-canvas.component.ts | 149 +++++++++
 .../ui/connector-canvas/connector-canvas.module.ts |   6 +-
 .../connector-graph-controls.component.html        |  17 +-
 .../connector-graph-controls.component.spec.ts     | 148 ++++++++-
 .../connector-graph-controls.component.ts          |  18 +-
 .../pages/provenance/service/provenance.service.ts |   6 +
 .../provenance-preview.component.html              | 168 ++++++++++
 .../provenance-preview.component.scss              |  16 +
 .../provenance-preview.component.spec.ts           | 368 +++++++++++++++++++++
 .../provenance-preview.component.ts                | 238 +++++++++++++
 20 files changed, 2318 insertions(+), 8 deletions(-)

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

Reply via email to