rfellows commented on code in PR #8173:
URL: https://github.com/apache/nifi/pull/8173#discussion_r1433174489


##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/state/lineage/lineage.effects.ts:
##########
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import * as LineageActions from './lineage.actions';
+import * as ProvenanceActions from 
'../provenance-event-listing/provenance-event-listing.actions';
+import {
+    asyncScheduler,
+    catchError,
+    from,
+    interval,
+    map,
+    NEVER,
+    of,
+    switchMap,
+    take,
+    takeUntil,
+    tap,
+    withLatestFrom
+} from 'rxjs';
+import { MatDialog } from '@angular/material/dialog';
+import { Store } from '@ngrx/store';
+import { NiFiState } from '../../../../state';
+import { ProvenanceService } from '../../service/provenance.service';
+import { Lineage } from './index';
+import { selectClusterNodeId } from 
'../provenance-event-listing/provenance-event-listing.selectors';
+import { selectLineageId } from './lineage.selectors';
+
+@Injectable()
+export class LineageEffects {
+    constructor(
+        private actions$: Actions,
+        private store: Store<NiFiState>,
+        private provenanceService: ProvenanceService,
+        private dialog: MatDialog
+    ) {}
+
+    submitProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.submitLineageQuery),
+            map((action) => action.request),
+            switchMap((request) =>
+                from(this.provenanceService.submitLineageQuery(request)).pipe(
+                    map((response) =>
+                        LineageActions.submitLineageQuerySuccess({
+                            response: {
+                                lineage: response.lineage
+                            }
+                        })
+                    ),
+                    catchError((error) => {
+                        this.store.dispatch(
+                            ProvenanceActions.showOkDialog({
+                                title: 'Error',
+                                message: error.error
+                            })
+                        );
+
+                        return of(
+                            LineageActions.lineageApiError({
+                                error: error.error
+                            })
+                        );
+                    })
+                )
+            )
+        )
+    );
+
+    submitProvenanceQuerySuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.submitLineageQuerySuccess),
+            map((action) => action.response),
+            switchMap((response) => {
+                const query: Lineage = response.lineage;
+                if (query.finished) {
+                    this.dialog.closeAll();
+                    return of(LineageActions.deleteLineageQuery());
+                } else {
+                    return of(LineageActions.startPollingLineageQuery());
+                }
+            })
+        )
+    );
+
+    startPollingProvenanceQuery$ = createEffect(() =>

Review Comment:
   rename to be more contextual
   ```suggestion
       startPollingLineageQuery$ = createEffect(() =>
   ```



##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/state/lineage/lineage.effects.ts:
##########
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import * as LineageActions from './lineage.actions';
+import * as ProvenanceActions from 
'../provenance-event-listing/provenance-event-listing.actions';
+import {
+    asyncScheduler,
+    catchError,
+    from,
+    interval,
+    map,
+    NEVER,
+    of,
+    switchMap,
+    take,
+    takeUntil,
+    tap,
+    withLatestFrom
+} from 'rxjs';
+import { MatDialog } from '@angular/material/dialog';
+import { Store } from '@ngrx/store';
+import { NiFiState } from '../../../../state';
+import { ProvenanceService } from '../../service/provenance.service';
+import { Lineage } from './index';
+import { selectClusterNodeId } from 
'../provenance-event-listing/provenance-event-listing.selectors';
+import { selectLineageId } from './lineage.selectors';
+
+@Injectable()
+export class LineageEffects {
+    constructor(
+        private actions$: Actions,
+        private store: Store<NiFiState>,
+        private provenanceService: ProvenanceService,
+        private dialog: MatDialog
+    ) {}
+
+    submitProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.submitLineageQuery),
+            map((action) => action.request),
+            switchMap((request) =>
+                from(this.provenanceService.submitLineageQuery(request)).pipe(
+                    map((response) =>
+                        LineageActions.submitLineageQuerySuccess({
+                            response: {
+                                lineage: response.lineage
+                            }
+                        })
+                    ),
+                    catchError((error) => {
+                        this.store.dispatch(
+                            ProvenanceActions.showOkDialog({
+                                title: 'Error',
+                                message: error.error
+                            })
+                        );
+
+                        return of(
+                            LineageActions.lineageApiError({
+                                error: error.error
+                            })
+                        );
+                    })
+                )
+            )
+        )
+    );
+
+    submitProvenanceQuerySuccess$ = createEffect(() =>

Review Comment:
   rename to be more contextual
   ```suggestion
       submitLineageQuerySuccess$ = createEffect(() =>
   ```



##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/state/lineage/lineage.effects.ts:
##########
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import * as LineageActions from './lineage.actions';
+import * as ProvenanceActions from 
'../provenance-event-listing/provenance-event-listing.actions';
+import {
+    asyncScheduler,
+    catchError,
+    from,
+    interval,
+    map,
+    NEVER,
+    of,
+    switchMap,
+    take,
+    takeUntil,
+    tap,
+    withLatestFrom
+} from 'rxjs';
+import { MatDialog } from '@angular/material/dialog';
+import { Store } from '@ngrx/store';
+import { NiFiState } from '../../../../state';
+import { ProvenanceService } from '../../service/provenance.service';
+import { Lineage } from './index';
+import { selectClusterNodeId } from 
'../provenance-event-listing/provenance-event-listing.selectors';
+import { selectLineageId } from './lineage.selectors';
+
+@Injectable()
+export class LineageEffects {
+    constructor(
+        private actions$: Actions,
+        private store: Store<NiFiState>,
+        private provenanceService: ProvenanceService,
+        private dialog: MatDialog
+    ) {}
+
+    submitProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.submitLineageQuery),
+            map((action) => action.request),
+            switchMap((request) =>
+                from(this.provenanceService.submitLineageQuery(request)).pipe(
+                    map((response) =>
+                        LineageActions.submitLineageQuerySuccess({
+                            response: {
+                                lineage: response.lineage
+                            }
+                        })
+                    ),
+                    catchError((error) => {
+                        this.store.dispatch(
+                            ProvenanceActions.showOkDialog({
+                                title: 'Error',
+                                message: error.error
+                            })
+                        );
+
+                        return of(
+                            LineageActions.lineageApiError({
+                                error: error.error
+                            })
+                        );
+                    })
+                )
+            )
+        )
+    );
+
+    submitProvenanceQuerySuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.submitLineageQuerySuccess),
+            map((action) => action.response),
+            switchMap((response) => {
+                const query: Lineage = response.lineage;
+                if (query.finished) {
+                    this.dialog.closeAll();
+                    return of(LineageActions.deleteLineageQuery());
+                } else {
+                    return of(LineageActions.startPollingLineageQuery());
+                }
+            })
+        )
+    );
+
+    startPollingProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.startPollingLineageQuery),
+            switchMap(() =>
+                interval(2000, asyncScheduler).pipe(
+                    
takeUntil(this.actions$.pipe(ofType(LineageActions.stopPollingLineageQuery)))
+                )
+            ),
+            switchMap(() => of(LineageActions.pollLineageQuery()))
+        )
+    );
+
+    pollProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.pollLineageQuery),
+            withLatestFrom(this.store.select(selectLineageId), 
this.store.select(selectClusterNodeId)),
+            switchMap(([action, id, clusterNodeId]) => {
+                if (id) {
+                    return from(this.provenanceService.getLineageQuery(id, 
clusterNodeId)).pipe(
+                        map((response) =>
+                            LineageActions.pollLineageQuerySuccess({
+                                response: {
+                                    lineage: response.lineage
+                                }
+                            })
+                        ),
+                        catchError((error) =>
+                            of(
+                                LineageActions.lineageApiError({
+                                    error: error.error
+                                })
+                            )
+                        )
+                    );
+                } else {
+                    return NEVER;
+                }
+            })
+        )
+    );
+
+    pollProvenanceQuerySuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.pollLineageQuerySuccess),
+            map((action) => action.response),
+            switchMap((response) => {
+                const query: Lineage = response.lineage;
+                if (query.finished) {
+                    this.dialog.closeAll();
+                    return of(LineageActions.stopPollingLineageQuery());
+                } else {
+                    return NEVER;
+                }
+            })
+        )
+    );
+
+    stopPollingProvenanceQuery$ = createEffect(() =>

Review Comment:
   rename to be more contextual
   ```suggestion
       stopPollingLineageQuery$ = createEffect(() =>
   ```



##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/ui/provenance-event-listing/provenance-event-table/provenance-event-table.component.ts:
##########
@@ -77,16 +86,69 @@ export class ProvenanceEventTable implements AfterViewInit {
             if (filterTerm?.length > 0) {
                 const filterColumn = 
this.filterForm.get('filterColumn')?.value;
                 this.applyFilter(filterTerm, filterColumn);
+            } else {
+                this.resetPaginator();
             }
         }
     }
     @Input() oldestEventAvailable!: string;
+    @Input() timeOffset!: number;
     @Input() resultsMessage!: string;
     @Input() hasRequest!: boolean;
+    @Input() loading!: boolean;
+    @Input() loadedTimestamp!: string;
+    @Input() set lineage$(lineage$: Observable<Lineage | null>) {
+        this.provenanceLineage$ = lineage$.pipe(
+            tap((lineage) => {
+                let minMillis: number = -1;
+                let maxMillis: number = -1;
+
+                lineage?.results.nodes.forEach((node) => {
+                    // ensure this event has an event time
+                    if (minMillis < 0 || minMillis > node.millis) {
+                        minMillis = node.millis;
+                        // minTimestamp = node.timestamp;

Review Comment:
   is the minTimestamp not needed? can we remove this?



##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/state/lineage/lineage.effects.ts:
##########
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import * as LineageActions from './lineage.actions';
+import * as ProvenanceActions from 
'../provenance-event-listing/provenance-event-listing.actions';
+import {
+    asyncScheduler,
+    catchError,
+    from,
+    interval,
+    map,
+    NEVER,
+    of,
+    switchMap,
+    take,
+    takeUntil,
+    tap,
+    withLatestFrom
+} from 'rxjs';
+import { MatDialog } from '@angular/material/dialog';
+import { Store } from '@ngrx/store';
+import { NiFiState } from '../../../../state';
+import { ProvenanceService } from '../../service/provenance.service';
+import { Lineage } from './index';
+import { selectClusterNodeId } from 
'../provenance-event-listing/provenance-event-listing.selectors';
+import { selectLineageId } from './lineage.selectors';
+
+@Injectable()
+export class LineageEffects {
+    constructor(
+        private actions$: Actions,
+        private store: Store<NiFiState>,
+        private provenanceService: ProvenanceService,
+        private dialog: MatDialog
+    ) {}
+
+    submitProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.submitLineageQuery),
+            map((action) => action.request),
+            switchMap((request) =>
+                from(this.provenanceService.submitLineageQuery(request)).pipe(
+                    map((response) =>
+                        LineageActions.submitLineageQuerySuccess({
+                            response: {
+                                lineage: response.lineage
+                            }
+                        })
+                    ),
+                    catchError((error) => {
+                        this.store.dispatch(
+                            ProvenanceActions.showOkDialog({
+                                title: 'Error',
+                                message: error.error
+                            })
+                        );
+
+                        return of(
+                            LineageActions.lineageApiError({
+                                error: error.error
+                            })
+                        );
+                    })
+                )
+            )
+        )
+    );
+
+    submitProvenanceQuerySuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.submitLineageQuerySuccess),
+            map((action) => action.response),
+            switchMap((response) => {
+                const query: Lineage = response.lineage;
+                if (query.finished) {
+                    this.dialog.closeAll();
+                    return of(LineageActions.deleteLineageQuery());
+                } else {
+                    return of(LineageActions.startPollingLineageQuery());
+                }
+            })
+        )
+    );
+
+    startPollingProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.startPollingLineageQuery),
+            switchMap(() =>
+                interval(2000, asyncScheduler).pipe(
+                    
takeUntil(this.actions$.pipe(ofType(LineageActions.stopPollingLineageQuery)))
+                )
+            ),
+            switchMap(() => of(LineageActions.pollLineageQuery()))
+        )
+    );
+
+    pollProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.pollLineageQuery),
+            withLatestFrom(this.store.select(selectLineageId), 
this.store.select(selectClusterNodeId)),
+            switchMap(([action, id, clusterNodeId]) => {
+                if (id) {
+                    return from(this.provenanceService.getLineageQuery(id, 
clusterNodeId)).pipe(
+                        map((response) =>
+                            LineageActions.pollLineageQuerySuccess({
+                                response: {
+                                    lineage: response.lineage
+                                }
+                            })
+                        ),
+                        catchError((error) =>
+                            of(
+                                LineageActions.lineageApiError({
+                                    error: error.error
+                                })
+                            )
+                        )
+                    );
+                } else {
+                    return NEVER;
+                }
+            })
+        )
+    );
+
+    pollProvenanceQuerySuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.pollLineageQuerySuccess),
+            map((action) => action.response),
+            switchMap((response) => {
+                const query: Lineage = response.lineage;
+                if (query.finished) {
+                    this.dialog.closeAll();
+                    return of(LineageActions.stopPollingLineageQuery());
+                } else {
+                    return NEVER;
+                }
+            })
+        )
+    );
+
+    stopPollingProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.stopPollingLineageQuery),
+            switchMap((response) => of(LineageActions.deleteLineageQuery()))
+        )
+    );
+
+    deleteProvenanceQuery$ = createEffect(

Review Comment:
   rename to be more contextual
   ```suggestion
       deleteLineageQuery$ = createEffect(() =>
   ```



##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/ui/provenance-event-listing/provenance-event-listing.component.ts:
##########
@@ -159,19 +169,44 @@ export class ProvenanceEventListing {
         this.store.dispatch(openSearchDialog());
     }
 
-    openEventDialog(event: ProvenanceEventSummary): void {
+    openEventDialog(request: ProvenanceEventRequest): void {
         this.store.dispatch(
             openProvenanceEventDialog({
-                id: event.id
+                request
             })
         );
     }
 
-    refreshParameterContextListing(): void {
+    goToEventSource(request: GoToProvenanceEventSourceRequest): void {
+        this.store.dispatch(
+            goToProvenanceEventSource({
+                request
+            })
+        );
+    }
+
+    resubmitProvenanceQuery(): void {
         this.store.dispatch(
             resubmitProvenanceQuery({
                 request: this.request
             })
         );
     }
+
+    queryLineage(request: LineageRequest): void {
+        this.store.dispatch(
+            submitLineageQuery({
+                request
+            })
+        );
+    }
+
+    resetLineage(): void {
+        this.store.dispatch(resetLineage());
+    }
+
+    ngOnDestroy(): void {
+        this.stateReset = true;
+        this.store.dispatch(resetProvenanceState());

Review Comment:
   should also clean up the lineage from the store here as well. if the user 
closes the provenance page when viewing a lineage graph, its state isn't 
getting reset now and leads to some odd rendering issues.
   ```suggestion
           this.store.dispatch(resetProvenanceState());
           this.store.dispatch(resetLineage());
   ```



##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/state/lineage/lineage.effects.ts:
##########
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import * as LineageActions from './lineage.actions';
+import * as ProvenanceActions from 
'../provenance-event-listing/provenance-event-listing.actions';
+import {
+    asyncScheduler,
+    catchError,
+    from,
+    interval,
+    map,
+    NEVER,
+    of,
+    switchMap,
+    take,
+    takeUntil,
+    tap,
+    withLatestFrom
+} from 'rxjs';
+import { MatDialog } from '@angular/material/dialog';
+import { Store } from '@ngrx/store';
+import { NiFiState } from '../../../../state';
+import { ProvenanceService } from '../../service/provenance.service';
+import { Lineage } from './index';
+import { selectClusterNodeId } from 
'../provenance-event-listing/provenance-event-listing.selectors';
+import { selectLineageId } from './lineage.selectors';
+
+@Injectable()
+export class LineageEffects {
+    constructor(
+        private actions$: Actions,
+        private store: Store<NiFiState>,
+        private provenanceService: ProvenanceService,
+        private dialog: MatDialog
+    ) {}
+
+    submitProvenanceQuery$ = createEffect(() =>

Review Comment:
   rename to be more contextual
   ```suggestion
       submitLineageQuery$ = createEffect(() =>
   ```



##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/ui/provenance-event-listing/provenance-event-table/lineage/lineage.component.ts:
##########
@@ -0,0 +1,975 @@
+/*
+ * 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, DestroyRef, EventEmitter, inject, Input, OnInit, Output } 
from '@angular/core';
+import * as d3 from 'd3';
+import { Lineage, LineageLink, LineageNode, LineageRequest } from 
'../../../../state/lineage';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { GoToProvenanceEventSourceRequest, ProvenanceEventRequest } from 
'../../../../state/provenance-event-listing';
+import {
+    ContextMenu,
+    ContextMenuDefinition,
+    ContextMenuDefinitionProvider,
+    ContextMenuItemDefinition
+} from '../../../../../../ui/common/context-menu/context-menu.component';
+import { CdkContextMenuTrigger } from '@angular/cdk/menu';
+
+@Component({
+    selector: 'lineage',
+    standalone: true,
+    templateUrl: './lineage.component.html',
+    imports: [ContextMenu, CdkContextMenuTrigger],
+    styleUrls: ['./lineage.component.scss']
+})
+export class LineageComponent implements OnInit {
+    private static readonly DEFAULT_NODE_SPACING: number = 100;
+    private static readonly DEFAULT_LEVEL_DIFFERENCE: number = 120;
+
+    private destroyRef = inject(DestroyRef);
+
+    @Input() set lineage(lineage: Lineage) {
+        if (lineage && lineage.finished) {
+            this.addLineage(lineage.results.nodes, lineage.results.links);
+        }
+    }
+    @Input() eventId: string | null = null;
+    @Input() set eventTimestampThreshold(eventTimestampThreshold: number) {
+        if (this.previousEventTimestampThreshold >= 0) {
+            let nodes: any = this.lineageContainerElement.selectAll('g.node');
+            let links: any = 
this.lineageContainerElement.selectAll('path.link');
+
+            if (this.previousEventTimestampThreshold > 
eventTimestampThreshold) {
+                // the threshold is descending
+
+                // determine the nodes to hide
+                const nodesToHide = nodes.filter((d: any) => {
+                    return d.millis > eventTimestampThreshold && d.millis <= 
this.previousEventTimestampThreshold;
+                });
+                const linksToHide = links.filter((d: any) => {
+                    return d.millis > eventTimestampThreshold && d.millis <= 
this.previousEventTimestampThreshold;
+                });
+
+                // hide applicable nodes and lines
+                
nodesToHide.transition().delay(200).duration(400).style('opacity', 0);
+                linksToHide.transition().duration(400).style('opacity', 0);
+            } else {
+                // the threshold is ascending
+
+                // determine the nodes to show
+                const nodesToShow = nodes.filter((d: any) => {
+                    return d.millis <= eventTimestampThreshold && d.millis > 
this.previousEventTimestampThreshold;
+                });
+                const linksToShow = links.filter((d: any) => {
+                    return d.millis <= eventTimestampThreshold && d.millis > 
this.previousEventTimestampThreshold;
+                });
+
+                // show applicable nodes and lines
+                
linksToShow.transition().delay(200).duration(400).style('opacity', 1);
+                nodesToShow.transition().duration(400).style('opacity', 1);
+            }
+        }
+
+        this.previousEventTimestampThreshold = eventTimestampThreshold;
+    }
+    @Input() set reset(reset: EventEmitter<void>) {
+        reset.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
+            this.nodeLookup.clear();
+            this.linkLookup.clear();
+            this.refresh();
+        });
+    }
+
+    @Output() submitLineageQuery: EventEmitter<LineageRequest> = new 
EventEmitter<LineageRequest>();
+    @Output() openEventDialog: EventEmitter<ProvenanceEventRequest> = new 
EventEmitter<ProvenanceEventRequest>();
+    @Output() goToProvenanceEventSource: 
EventEmitter<GoToProvenanceEventSourceRequest> =
+        new EventEmitter<GoToProvenanceEventSourceRequest>();
+    @Output() closeLineage: EventEmitter<void> = new EventEmitter<void>();
+
+    readonly ROOT_MENU: ContextMenuDefinition = {
+        id: 'root',
+        menuItems: [
+            {
+                condition: function (selection: any) {
+                    return selection.empty();
+                },
+                clazz: 'fa fa-long-arrow-left',
+                text: 'Back to events',
+                action: function (selection: any, self: LineageComponent) {
+                    self.closeLineage.next();
+                }
+            },
+            {
+                condition: function (selection: any) {
+                    return !selection.empty();
+                },
+                clazz: 'fa fa-info-circle',
+                text: 'View details',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+
+                    // TODO cluster node id
+                    self.openEventDialog.next({
+                        id: selectionData.id
+                    });
+                }
+            },
+            {
+                condition: function (selection: any) {
+                    return !selection.empty();
+                },
+                clazz: 'fa fa-long-arrow-right',
+                text: 'Go to component',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+                    self.goToProvenanceEventSource.next({
+                        eventId: selectionData.id
+                    });
+                }
+            },
+            {
+                condition: function (selection: any, self: LineageComponent) {
+                    if (selection.empty()) {
+                        return false;
+                    }
+
+                    const selectionData: any = selection.datum();
+                    return self.supportsExpandCollapse(selectionData);
+                },
+                clazz: 'fa fa-binoculars',
+                text: 'Find parents',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+
+                    // TODO - cluster node id
+                    self.submitLineageQuery.next({
+                        lineageRequestType: 'PARENTS',
+                        eventId: selectionData.id
+                        // clusterNodeId: clusterNodeId
+                    });
+                }
+            },
+            {
+                condition: function (selection: any, self: LineageComponent) {
+                    if (selection.empty()) {
+                        return false;
+                    }
+
+                    const selectionData: any = selection.datum();
+                    return self.supportsExpandCollapse(selectionData);
+                },
+                clazz: 'fa fa-plus-square',
+                text: 'Expand',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+
+                    // TODO - cluster node id
+                    self.submitLineageQuery.next({
+                        lineageRequestType: 'CHILDREN',
+                        eventId: selectionData.id
+                        // clusterNodeId: clusterNodeId
+                    });
+                }
+            },
+            {
+                condition: function (selection: any, self: LineageComponent) {
+                    if (selection.empty()) {
+                        return false;
+                    }
+
+                    const selectionData: any = selection.datum();
+                    return self.supportsExpandCollapse(selectionData);
+                },
+                clazz: 'fa fa-minus-square',
+                text: 'Collapse',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+                    self.collapseLineage(selectionData);
+                }
+            }
+        ]
+    };
+
+    private allMenus: Map<string, ContextMenuDefinition>;
+
+    lineageElement: any;
+    lineageContainerElement: any;
+    lineageContextmenu: ContextMenuDefinitionProvider;
+
+    private nodeLookup: Map<string, any> = new Map<string, any>();
+    private linkLookup: Map<string, any> = new Map<string, any>();
+    private previousEventTimestampThreshold: number = -1;
+
+    constructor() {
+        this.allMenus = new Map<string, ContextMenuDefinition>();
+        this.allMenus.set(this.ROOT_MENU.id, this.ROOT_MENU);
+
+        const self: LineageComponent = this;
+        this.lineageContextmenu = {
+            getMenu(menuId: string): ContextMenuDefinition | undefined {
+                return self.allMenus.get(menuId);
+            },
+            filterMenuItem(menuItem: ContextMenuItemDefinition): boolean {
+                // include if the condition matches
+                if (menuItem.condition) {
+                    const selection: any = d3.select('circle.context');
+                    return menuItem.condition(selection, self);
+                }
+
+                // include if there is no condition (non conditional item, 
separator, sub menu, etc)
+                return true;
+            },
+            menuItemClicked(menuItem: ContextMenuItemDefinition, event: 
MouseEvent) {
+                if (menuItem.action) {
+                    const selection: any = d3.select('circle.context');
+                    return menuItem.action(selection, self);

Review Comment:
   Assuming you take the suggestion about not requiring `self` not needing to 
be passed in, you can remove it as an argument here.



##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/state/lineage/lineage.effects.ts:
##########
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import * as LineageActions from './lineage.actions';
+import * as ProvenanceActions from 
'../provenance-event-listing/provenance-event-listing.actions';
+import {
+    asyncScheduler,
+    catchError,
+    from,
+    interval,
+    map,
+    NEVER,
+    of,
+    switchMap,
+    take,
+    takeUntil,
+    tap,
+    withLatestFrom
+} from 'rxjs';
+import { MatDialog } from '@angular/material/dialog';
+import { Store } from '@ngrx/store';
+import { NiFiState } from '../../../../state';
+import { ProvenanceService } from '../../service/provenance.service';
+import { Lineage } from './index';
+import { selectClusterNodeId } from 
'../provenance-event-listing/provenance-event-listing.selectors';
+import { selectLineageId } from './lineage.selectors';
+
+@Injectable()
+export class LineageEffects {
+    constructor(
+        private actions$: Actions,
+        private store: Store<NiFiState>,
+        private provenanceService: ProvenanceService,
+        private dialog: MatDialog
+    ) {}
+
+    submitProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.submitLineageQuery),
+            map((action) => action.request),
+            switchMap((request) =>
+                from(this.provenanceService.submitLineageQuery(request)).pipe(
+                    map((response) =>
+                        LineageActions.submitLineageQuerySuccess({
+                            response: {
+                                lineage: response.lineage
+                            }
+                        })
+                    ),
+                    catchError((error) => {
+                        this.store.dispatch(
+                            ProvenanceActions.showOkDialog({
+                                title: 'Error',
+                                message: error.error
+                            })
+                        );
+
+                        return of(
+                            LineageActions.lineageApiError({
+                                error: error.error
+                            })
+                        );
+                    })
+                )
+            )
+        )
+    );
+
+    submitProvenanceQuerySuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.submitLineageQuerySuccess),
+            map((action) => action.response),
+            switchMap((response) => {
+                const query: Lineage = response.lineage;
+                if (query.finished) {
+                    this.dialog.closeAll();
+                    return of(LineageActions.deleteLineageQuery());
+                } else {
+                    return of(LineageActions.startPollingLineageQuery());
+                }
+            })
+        )
+    );
+
+    startPollingProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.startPollingLineageQuery),
+            switchMap(() =>
+                interval(2000, asyncScheduler).pipe(
+                    
takeUntil(this.actions$.pipe(ofType(LineageActions.stopPollingLineageQuery)))
+                )
+            ),
+            switchMap(() => of(LineageActions.pollLineageQuery()))
+        )
+    );
+
+    pollProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.pollLineageQuery),
+            withLatestFrom(this.store.select(selectLineageId), 
this.store.select(selectClusterNodeId)),
+            switchMap(([action, id, clusterNodeId]) => {
+                if (id) {
+                    return from(this.provenanceService.getLineageQuery(id, 
clusterNodeId)).pipe(
+                        map((response) =>
+                            LineageActions.pollLineageQuerySuccess({
+                                response: {
+                                    lineage: response.lineage
+                                }
+                            })
+                        ),
+                        catchError((error) =>
+                            of(
+                                LineageActions.lineageApiError({
+                                    error: error.error
+                                })
+                            )
+                        )
+                    );
+                } else {
+                    return NEVER;
+                }
+            })
+        )
+    );
+
+    pollProvenanceQuerySuccess$ = createEffect(() =>

Review Comment:
   rename to be more contextual
   ```suggestion
       pollLineageQuerySuccess$ = createEffect(() =>
   ```



##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/state/lineage/lineage.effects.ts:
##########
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import * as LineageActions from './lineage.actions';
+import * as ProvenanceActions from 
'../provenance-event-listing/provenance-event-listing.actions';
+import {
+    asyncScheduler,
+    catchError,
+    from,
+    interval,
+    map,
+    NEVER,
+    of,
+    switchMap,
+    take,
+    takeUntil,
+    tap,
+    withLatestFrom
+} from 'rxjs';
+import { MatDialog } from '@angular/material/dialog';
+import { Store } from '@ngrx/store';
+import { NiFiState } from '../../../../state';
+import { ProvenanceService } from '../../service/provenance.service';
+import { Lineage } from './index';
+import { selectClusterNodeId } from 
'../provenance-event-listing/provenance-event-listing.selectors';
+import { selectLineageId } from './lineage.selectors';
+
+@Injectable()
+export class LineageEffects {
+    constructor(
+        private actions$: Actions,
+        private store: Store<NiFiState>,
+        private provenanceService: ProvenanceService,
+        private dialog: MatDialog
+    ) {}
+
+    submitProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.submitLineageQuery),
+            map((action) => action.request),
+            switchMap((request) =>
+                from(this.provenanceService.submitLineageQuery(request)).pipe(
+                    map((response) =>
+                        LineageActions.submitLineageQuerySuccess({
+                            response: {
+                                lineage: response.lineage
+                            }
+                        })
+                    ),
+                    catchError((error) => {
+                        this.store.dispatch(
+                            ProvenanceActions.showOkDialog({
+                                title: 'Error',
+                                message: error.error
+                            })
+                        );
+
+                        return of(
+                            LineageActions.lineageApiError({
+                                error: error.error
+                            })
+                        );
+                    })
+                )
+            )
+        )
+    );
+
+    submitProvenanceQuerySuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.submitLineageQuerySuccess),
+            map((action) => action.response),
+            switchMap((response) => {
+                const query: Lineage = response.lineage;
+                if (query.finished) {
+                    this.dialog.closeAll();
+                    return of(LineageActions.deleteLineageQuery());
+                } else {
+                    return of(LineageActions.startPollingLineageQuery());
+                }
+            })
+        )
+    );
+
+    startPollingProvenanceQuery$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(LineageActions.startPollingLineageQuery),
+            switchMap(() =>
+                interval(2000, asyncScheduler).pipe(
+                    
takeUntil(this.actions$.pipe(ofType(LineageActions.stopPollingLineageQuery)))
+                )
+            ),
+            switchMap(() => of(LineageActions.pollLineageQuery()))
+        )
+    );
+
+    pollProvenanceQuery$ = createEffect(() =>

Review Comment:
   rename to be more contextual
   ```suggestion
       pollLineageQuery$ = createEffect(() =>
   ```



##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/ui/provenance-event-listing/provenance-event-table/lineage/lineage.component.ts:
##########
@@ -0,0 +1,975 @@
+/*
+ * 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, DestroyRef, EventEmitter, inject, Input, OnInit, Output } 
from '@angular/core';
+import * as d3 from 'd3';
+import { Lineage, LineageLink, LineageNode, LineageRequest } from 
'../../../../state/lineage';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { GoToProvenanceEventSourceRequest, ProvenanceEventRequest } from 
'../../../../state/provenance-event-listing';
+import {
+    ContextMenu,
+    ContextMenuDefinition,
+    ContextMenuDefinitionProvider,
+    ContextMenuItemDefinition
+} from '../../../../../../ui/common/context-menu/context-menu.component';
+import { CdkContextMenuTrigger } from '@angular/cdk/menu';
+
+@Component({
+    selector: 'lineage',
+    standalone: true,
+    templateUrl: './lineage.component.html',
+    imports: [ContextMenu, CdkContextMenuTrigger],
+    styleUrls: ['./lineage.component.scss']
+})
+export class LineageComponent implements OnInit {
+    private static readonly DEFAULT_NODE_SPACING: number = 100;
+    private static readonly DEFAULT_LEVEL_DIFFERENCE: number = 120;
+
+    private destroyRef = inject(DestroyRef);
+
+    @Input() set lineage(lineage: Lineage) {
+        if (lineage && lineage.finished) {
+            this.addLineage(lineage.results.nodes, lineage.results.links);
+        }
+    }
+    @Input() eventId: string | null = null;
+    @Input() set eventTimestampThreshold(eventTimestampThreshold: number) {
+        if (this.previousEventTimestampThreshold >= 0) {
+            let nodes: any = this.lineageContainerElement.selectAll('g.node');
+            let links: any = 
this.lineageContainerElement.selectAll('path.link');
+
+            if (this.previousEventTimestampThreshold > 
eventTimestampThreshold) {
+                // the threshold is descending
+
+                // determine the nodes to hide
+                const nodesToHide = nodes.filter((d: any) => {
+                    return d.millis > eventTimestampThreshold && d.millis <= 
this.previousEventTimestampThreshold;
+                });
+                const linksToHide = links.filter((d: any) => {
+                    return d.millis > eventTimestampThreshold && d.millis <= 
this.previousEventTimestampThreshold;
+                });
+
+                // hide applicable nodes and lines
+                
nodesToHide.transition().delay(200).duration(400).style('opacity', 0);
+                linksToHide.transition().duration(400).style('opacity', 0);
+            } else {
+                // the threshold is ascending
+
+                // determine the nodes to show
+                const nodesToShow = nodes.filter((d: any) => {
+                    return d.millis <= eventTimestampThreshold && d.millis > 
this.previousEventTimestampThreshold;
+                });
+                const linksToShow = links.filter((d: any) => {
+                    return d.millis <= eventTimestampThreshold && d.millis > 
this.previousEventTimestampThreshold;
+                });
+
+                // show applicable nodes and lines
+                
linksToShow.transition().delay(200).duration(400).style('opacity', 1);
+                nodesToShow.transition().duration(400).style('opacity', 1);
+            }
+        }
+
+        this.previousEventTimestampThreshold = eventTimestampThreshold;
+    }
+    @Input() set reset(reset: EventEmitter<void>) {
+        reset.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
+            this.nodeLookup.clear();
+            this.linkLookup.clear();
+            this.refresh();
+        });
+    }
+
+    @Output() submitLineageQuery: EventEmitter<LineageRequest> = new 
EventEmitter<LineageRequest>();
+    @Output() openEventDialog: EventEmitter<ProvenanceEventRequest> = new 
EventEmitter<ProvenanceEventRequest>();
+    @Output() goToProvenanceEventSource: 
EventEmitter<GoToProvenanceEventSourceRequest> =
+        new EventEmitter<GoToProvenanceEventSourceRequest>();
+    @Output() closeLineage: EventEmitter<void> = new EventEmitter<void>();
+
+    readonly ROOT_MENU: ContextMenuDefinition = {
+        id: 'root',
+        menuItems: [
+            {
+                condition: function (selection: any) {
+                    return selection.empty();
+                },
+                clazz: 'fa fa-long-arrow-left',
+                text: 'Back to events',
+                action: function (selection: any, self: LineageComponent) {
+                    self.closeLineage.next();
+                }
+            },
+            {
+                condition: function (selection: any) {
+                    return !selection.empty();
+                },
+                clazz: 'fa fa-info-circle',
+                text: 'View details',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+
+                    // TODO cluster node id
+                    self.openEventDialog.next({
+                        id: selectionData.id
+                    });
+                }
+            },
+            {
+                condition: function (selection: any) {
+                    return !selection.empty();
+                },
+                clazz: 'fa fa-long-arrow-right',
+                text: 'Go to component',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+                    self.goToProvenanceEventSource.next({
+                        eventId: selectionData.id
+                    });
+                }
+            },
+            {
+                condition: function (selection: any, self: LineageComponent) {
+                    if (selection.empty()) {
+                        return false;
+                    }
+
+                    const selectionData: any = selection.datum();
+                    return self.supportsExpandCollapse(selectionData);
+                },
+                clazz: 'fa fa-binoculars',
+                text: 'Find parents',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+
+                    // TODO - cluster node id
+                    self.submitLineageQuery.next({
+                        lineageRequestType: 'PARENTS',
+                        eventId: selectionData.id
+                        // clusterNodeId: clusterNodeId
+                    });
+                }
+            },
+            {
+                condition: function (selection: any, self: LineageComponent) {
+                    if (selection.empty()) {
+                        return false;
+                    }
+
+                    const selectionData: any = selection.datum();
+                    return self.supportsExpandCollapse(selectionData);
+                },
+                clazz: 'fa fa-plus-square',
+                text: 'Expand',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+
+                    // TODO - cluster node id
+                    self.submitLineageQuery.next({
+                        lineageRequestType: 'CHILDREN',
+                        eventId: selectionData.id
+                        // clusterNodeId: clusterNodeId
+                    });
+                }
+            },
+            {
+                condition: function (selection: any, self: LineageComponent) {
+                    if (selection.empty()) {
+                        return false;
+                    }
+
+                    const selectionData: any = selection.datum();
+                    return self.supportsExpandCollapse(selectionData);
+                },
+                clazz: 'fa fa-minus-square',
+                text: 'Collapse',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+                    self.collapseLineage(selectionData);
+                }
+            }
+        ]

Review Comment:
   I would try to simplify by not requiring the `self` references. You can 
accomplish this by using arrow functions, then `this` is the LineageComponent.
   ```suggestion
           menuItems: [
               {
                   condition: (selection: any) => selection.empty(),
                   clazz: 'fa fa-long-arrow-left',
                   text: 'Back to events',
                   action: (selection: any) => {
                       this.closeLineage.next();
                   }
               },
               {
                   condition: (selection: any) => !selection.empty(),
                   clazz: 'fa fa-info-circle',
                   text: 'View details',
                   action: (selection: any) => {
                       const selectionData: any = selection.datum();
   
                       // TODO cluster node id
                       this.openEventDialog.next({
                           id: selectionData.id
                       });
                   }
               },
               {
                   condition: (selection: any) => !selection.empty(),
                   clazz: 'fa fa-long-arrow-right',
                   text: 'Go to component',
                   action: (selection: any) =>  {
                       const selectionData: any = selection.datum();
                       this.goToProvenanceEventSource.next({
                           eventId: selectionData.id
                       });
                   }
               },
               {
                   condition: (selection: any) => {
                       if (selection.empty()) {
                           return false;
                       }
   
                       const selectionData: any = selection.datum();
                       return this.supportsExpandCollapse(selectionData);
                   },
                   clazz: 'fa fa-binoculars',
                   text: 'Find parents',
                   action: (selection: any) => {
                       const selectionData: any = selection.datum();
   
                       // TODO - cluster node id
                       this.submitLineageQuery.next({
                           lineageRequestType: 'PARENTS',
                           eventId: selectionData.id
                           // clusterNodeId: clusterNodeId
                       });
                   }
               },
               {
                   condition: (selection: any) => {
                       if (selection.empty()) {
                           return false;
                       }
   
                       const selectionData: any = selection.datum();
                       return this.supportsExpandCollapse(selectionData);
                   },
                   clazz: 'fa fa-plus-square',
                   text: 'Expand',
                   action: (selection: any) => {
                       const selectionData: any = selection.datum();
   
                       // TODO - cluster node id
                       this.submitLineageQuery.next({
                           lineageRequestType: 'CHILDREN',
                           eventId: selectionData.id
                           // clusterNodeId: clusterNodeId
                       });
                   }
               },
               {
                   condition: (selection: any) => {
                       if (selection.empty()) {
                           return false;
                       }
   
                       const selectionData: any = selection.datum();
                       return this.supportsExpandCollapse(selectionData);
                   },
                   clazz: 'fa fa-minus-square',
                   text: 'Collapse',
                   action: (selection: any) => {
                       const selectionData: any = selection.datum();
                       this.collapseLineage(selectionData);
                   }
               }
           ]
   ```



##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/ui/provenance-event-listing/provenance-event-table/provenance-event-table.component.html:
##########
@@ -15,142 +15,184 @@
   ~ limitations under the License.
   -->
 
-<div class="provenance-event-table h-full flex flex-col">
-    <div class="flex flex-col">
-        <div class="value font-bold">Displaying {{ filteredCount }} of {{ 
totalCount }}</div>
-        <div class="flex justify-between">
-            <div>
-                Oldest event available: <span class="value">{{ 
oldestEventAvailable }}</span>
-            </div>
-            <div>
-                {{ resultsMessage }}
-                <a *ngIf="hasRequest" (click)="clearRequestClicked()">Clear 
Search</a>
+<div class="provenance-event-table h-full">
+    <div [class.hidden]="showLineage" class="h-full flex flex-col gap-y-2">
+        <div class="flex flex-col">
+            <div class="value font-bold">Displaying {{ filteredCount }} of {{ 
totalCount }}</div>

Review Comment:
   Consider hiding this unless there is a filter active. It can actually be a 
bit confusing now that there is pagination. It tells us that 1000 of 1000 are 
displayed, but with pagination we only ever show 100 so it can be a bit 
confusing.
   Might also consider re-wording it so it is clear that it is referring to the 
number of items that pass the filter, but not necessarily how many are 
displayed.



##########
nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/provenance/ui/provenance-event-listing/provenance-event-table/lineage/lineage.component.ts:
##########
@@ -0,0 +1,975 @@
+/*
+ * 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, DestroyRef, EventEmitter, inject, Input, OnInit, Output } 
from '@angular/core';
+import * as d3 from 'd3';
+import { Lineage, LineageLink, LineageNode, LineageRequest } from 
'../../../../state/lineage';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { GoToProvenanceEventSourceRequest, ProvenanceEventRequest } from 
'../../../../state/provenance-event-listing';
+import {
+    ContextMenu,
+    ContextMenuDefinition,
+    ContextMenuDefinitionProvider,
+    ContextMenuItemDefinition
+} from '../../../../../../ui/common/context-menu/context-menu.component';
+import { CdkContextMenuTrigger } from '@angular/cdk/menu';
+
+@Component({
+    selector: 'lineage',
+    standalone: true,
+    templateUrl: './lineage.component.html',
+    imports: [ContextMenu, CdkContextMenuTrigger],
+    styleUrls: ['./lineage.component.scss']
+})
+export class LineageComponent implements OnInit {
+    private static readonly DEFAULT_NODE_SPACING: number = 100;
+    private static readonly DEFAULT_LEVEL_DIFFERENCE: number = 120;
+
+    private destroyRef = inject(DestroyRef);
+
+    @Input() set lineage(lineage: Lineage) {
+        if (lineage && lineage.finished) {
+            this.addLineage(lineage.results.nodes, lineage.results.links);
+        }
+    }
+    @Input() eventId: string | null = null;
+    @Input() set eventTimestampThreshold(eventTimestampThreshold: number) {
+        if (this.previousEventTimestampThreshold >= 0) {
+            let nodes: any = this.lineageContainerElement.selectAll('g.node');
+            let links: any = 
this.lineageContainerElement.selectAll('path.link');
+
+            if (this.previousEventTimestampThreshold > 
eventTimestampThreshold) {
+                // the threshold is descending
+
+                // determine the nodes to hide
+                const nodesToHide = nodes.filter((d: any) => {
+                    return d.millis > eventTimestampThreshold && d.millis <= 
this.previousEventTimestampThreshold;
+                });
+                const linksToHide = links.filter((d: any) => {
+                    return d.millis > eventTimestampThreshold && d.millis <= 
this.previousEventTimestampThreshold;
+                });
+
+                // hide applicable nodes and lines
+                
nodesToHide.transition().delay(200).duration(400).style('opacity', 0);
+                linksToHide.transition().duration(400).style('opacity', 0);
+            } else {
+                // the threshold is ascending
+
+                // determine the nodes to show
+                const nodesToShow = nodes.filter((d: any) => {
+                    return d.millis <= eventTimestampThreshold && d.millis > 
this.previousEventTimestampThreshold;
+                });
+                const linksToShow = links.filter((d: any) => {
+                    return d.millis <= eventTimestampThreshold && d.millis > 
this.previousEventTimestampThreshold;
+                });
+
+                // show applicable nodes and lines
+                
linksToShow.transition().delay(200).duration(400).style('opacity', 1);
+                nodesToShow.transition().duration(400).style('opacity', 1);
+            }
+        }
+
+        this.previousEventTimestampThreshold = eventTimestampThreshold;
+    }
+    @Input() set reset(reset: EventEmitter<void>) {
+        reset.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
+            this.nodeLookup.clear();
+            this.linkLookup.clear();
+            this.refresh();
+        });
+    }
+
+    @Output() submitLineageQuery: EventEmitter<LineageRequest> = new 
EventEmitter<LineageRequest>();
+    @Output() openEventDialog: EventEmitter<ProvenanceEventRequest> = new 
EventEmitter<ProvenanceEventRequest>();
+    @Output() goToProvenanceEventSource: 
EventEmitter<GoToProvenanceEventSourceRequest> =
+        new EventEmitter<GoToProvenanceEventSourceRequest>();
+    @Output() closeLineage: EventEmitter<void> = new EventEmitter<void>();
+
+    readonly ROOT_MENU: ContextMenuDefinition = {
+        id: 'root',
+        menuItems: [
+            {
+                condition: function (selection: any) {
+                    return selection.empty();
+                },
+                clazz: 'fa fa-long-arrow-left',
+                text: 'Back to events',
+                action: function (selection: any, self: LineageComponent) {
+                    self.closeLineage.next();
+                }
+            },
+            {
+                condition: function (selection: any) {
+                    return !selection.empty();
+                },
+                clazz: 'fa fa-info-circle',
+                text: 'View details',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+
+                    // TODO cluster node id
+                    self.openEventDialog.next({
+                        id: selectionData.id
+                    });
+                }
+            },
+            {
+                condition: function (selection: any) {
+                    return !selection.empty();
+                },
+                clazz: 'fa fa-long-arrow-right',
+                text: 'Go to component',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+                    self.goToProvenanceEventSource.next({
+                        eventId: selectionData.id
+                    });
+                }
+            },
+            {
+                condition: function (selection: any, self: LineageComponent) {
+                    if (selection.empty()) {
+                        return false;
+                    }
+
+                    const selectionData: any = selection.datum();
+                    return self.supportsExpandCollapse(selectionData);
+                },
+                clazz: 'fa fa-binoculars',
+                text: 'Find parents',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+
+                    // TODO - cluster node id
+                    self.submitLineageQuery.next({
+                        lineageRequestType: 'PARENTS',
+                        eventId: selectionData.id
+                        // clusterNodeId: clusterNodeId
+                    });
+                }
+            },
+            {
+                condition: function (selection: any, self: LineageComponent) {
+                    if (selection.empty()) {
+                        return false;
+                    }
+
+                    const selectionData: any = selection.datum();
+                    return self.supportsExpandCollapse(selectionData);
+                },
+                clazz: 'fa fa-plus-square',
+                text: 'Expand',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+
+                    // TODO - cluster node id
+                    self.submitLineageQuery.next({
+                        lineageRequestType: 'CHILDREN',
+                        eventId: selectionData.id
+                        // clusterNodeId: clusterNodeId
+                    });
+                }
+            },
+            {
+                condition: function (selection: any, self: LineageComponent) {
+                    if (selection.empty()) {
+                        return false;
+                    }
+
+                    const selectionData: any = selection.datum();
+                    return self.supportsExpandCollapse(selectionData);
+                },
+                clazz: 'fa fa-minus-square',
+                text: 'Collapse',
+                action: function (selection: any, self: LineageComponent) {
+                    const selectionData: any = selection.datum();
+                    self.collapseLineage(selectionData);
+                }
+            }
+        ]
+    };
+
+    private allMenus: Map<string, ContextMenuDefinition>;
+
+    lineageElement: any;
+    lineageContainerElement: any;
+    lineageContextmenu: ContextMenuDefinitionProvider;
+
+    private nodeLookup: Map<string, any> = new Map<string, any>();
+    private linkLookup: Map<string, any> = new Map<string, any>();
+    private previousEventTimestampThreshold: number = -1;
+
+    constructor() {
+        this.allMenus = new Map<string, ContextMenuDefinition>();
+        this.allMenus.set(this.ROOT_MENU.id, this.ROOT_MENU);
+
+        const self: LineageComponent = this;
+        this.lineageContextmenu = {
+            getMenu(menuId: string): ContextMenuDefinition | undefined {
+                return self.allMenus.get(menuId);
+            },
+            filterMenuItem(menuItem: ContextMenuItemDefinition): boolean {
+                // include if the condition matches
+                if (menuItem.condition) {
+                    const selection: any = d3.select('circle.context');
+                    return menuItem.condition(selection, self);

Review Comment:
   Assuming you take the suggestion about not requiring `self` not needing to 
be passed in, you can remove it as an argument here.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscr...@nifi.apache.org

For queries about this service, please contact Infrastructure at:
us...@infra.apache.org

Reply via email to