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 b5a2ba0216 NIFI-15215: Increase max component state entries (#10523)
b5a2ba0216 is described below

commit b5a2ba02161f93bab344d029945505d2a7479fcd
Author: Matt Gilman <[email protected]>
AuthorDate: Thu Nov 13 16:04:58 2025 -0500

    NIFI-15215: Increase max component state entries (#10523)
    
    * NIFI-15215: Increase max component state entries
    - Bump number of returned state entries
    - Introduce virtual scrolling to the component state dialog
    - Update DebugFlow to support state generation
    
    * NIFI-15215: Addressing review feedback.
    
    This closes #10523
---
 .../apache/nifi/processors/standard/DebugFlow.java |  62 ++++-
 .../nifi/controller/state/SortedStateUtils.java    |   2 +-
 .../component-state/component-state.effects.ts     |   4 +-
 .../component-state/component-state.component.html | 141 +++++++-----
 .../component-state/component-state.component.scss |  75 ++++++-
 .../component-state.component.spec.ts              | 250 ++++++++++++++++++---
 .../component-state/component-state.component.ts   |  79 ++++++-
 7 files changed, 509 insertions(+), 104 deletions(-)

diff --git 
a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/DebugFlow.java
 
b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/DebugFlow.java
index 6849ea9fde..5547d1ec3a 100644
--- 
a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/DebugFlow.java
+++ 
b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/DebugFlow.java
@@ -16,6 +16,7 @@
  */
 package org.apache.nifi.processors.standard;
 
+import org.apache.nifi.annotation.behavior.Stateful;
 import org.apache.nifi.annotation.documentation.CapabilityDescription;
 import org.apache.nifi.annotation.documentation.Tags;
 import org.apache.nifi.annotation.lifecycle.OnScheduled;
@@ -25,6 +26,7 @@ import org.apache.nifi.components.PropertyDescriptor;
 import org.apache.nifi.components.ValidationContext;
 import org.apache.nifi.components.ValidationResult;
 import org.apache.nifi.components.Validator;
+import org.apache.nifi.components.state.Scope;
 import org.apache.nifi.expression.ExpressionLanguageScope;
 import org.apache.nifi.flowfile.FlowFile;
 import org.apache.nifi.flowfile.attributes.CoreAttributes;
@@ -37,19 +39,27 @@ import org.apache.nifi.processor.Relationship;
 import org.apache.nifi.processor.exception.ProcessException;
 import org.apache.nifi.processor.util.StandardValidators;
 
+import java.io.IOException;
 import java.lang.reflect.InvocationTargetException;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Random;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 
-@Tags({"test", "debug", "processor", "utility", "flow", "FlowFile"})
+@Tags({"test", "debug", "processor", "utility", "flow", "FlowFile", "state"})
 @CapabilityDescription("The DebugFlow processor aids testing and debugging the 
FlowFile framework by allowing various "
         + "responses to be explicitly triggered in response to the receipt of 
a FlowFile or a timer event without a "
         + "FlowFile if using timer or cron based scheduling.  It can force 
responses needed to exercise or test "
-        + "various failure modes that can occur when a processor runs.")
+        + "various failure modes that can occur when a processor runs. It can 
also generate large numbers of component "
+        + "state entries for testing state management limits.")
+@Stateful(scopes = {Scope.LOCAL, Scope.CLUSTER}, description = "When 'Generate 
State Entries' is set to a positive integer, "
+        + "the processor will generate that many state entries with random 
values. This is useful for testing component state "
+        + "storage and display limits. State entries are stored with keys like 
'debug_state_key_00000' with randomly generated values.",
+        dropStateKeySupported = true)
 public class DebugFlow extends AbstractProcessor {
 
     private final AtomicReference<Set<Relationship>> relationships = new 
AtomicReference<>();
@@ -224,6 +234,21 @@ public class DebugFlow extends AbstractProcessor {
         .defaultValue("false")
         .required(true)
         .build();
+    static final PropertyDescriptor GENERATE_STATE_ENTRIES = new 
PropertyDescriptor.Builder()
+        .name("Generate State Entries")
+        .description("If set to a positive integer, the processor will ensure 
that exactly this many state entries exist on each trigger, "
+            + "updating their values with random data. This is useful for 
testing component state limits. Set to 0 to disable state generation.")
+        .required(true)
+        .defaultValue("0")
+        .addValidator(StandardValidators.NON_NEGATIVE_INTEGER_VALIDATOR)
+        .build();
+    static final PropertyDescriptor STATE_SCOPE = new 
PropertyDescriptor.Builder()
+        .name("State Scope")
+        .description("The scope to use when storing component state entries")
+        .required(true)
+        .allowableValues("LOCAL", "CLUSTER")
+        .defaultValue("LOCAL")
+        .build();
 
     private volatile Integer flowFileMaxSuccess = 0;
     private volatile Integer flowFileMaxFailure = 0;
@@ -289,7 +314,9 @@ public class DebugFlow extends AbstractProcessor {
                         ON_STOPPED_FAIL,
                         ON_TRIGGER_SLEEP_TIME,
                         CUSTOM_VALIDATE_SLEEP_TIME,
-                        IGNORE_INTERRUPTS
+                        IGNORE_INTERRUPTS,
+                        GENERATE_STATE_ENTRIES,
+                        STATE_SCOPE
                 );
 
                 this.properties.compareAndSet(null, properties);
@@ -377,11 +404,40 @@ public class DebugFlow extends AbstractProcessor {
         }
     }
 
+    private void handleStateGeneration(final ProcessContext context, final 
ComponentLog logger) {
+        final int numStateEntries = 
context.getProperty(GENERATE_STATE_ENTRIES).asInteger();
+
+        if (numStateEntries > 0) {
+            final String scopeValue = 
context.getProperty(STATE_SCOPE).getValue();
+            final Scope scope = "CLUSTER".equals(scopeValue) ? Scope.CLUSTER : 
Scope.LOCAL;
+
+            try {
+                final Map<String, String> stateMap = new HashMap<>();
+
+                // Ensure exactly numStateEntries entries exist, updating 
their values with random data
+                final Random random = new Random();
+                for (int i = 0; i < numStateEntries; i++) {
+                    final String key = String.format("debug_state_key_%05d", 
i);
+                    final String value = "value_" + random.nextInt(1000000) + 
"_" + System.currentTimeMillis();
+                    stateMap.put(key, value);
+                }
+
+                // Save state
+                context.getStateManager().setState(stateMap, scope);
+            } catch (IOException e) {
+                logger.error("Failed to generate state entries", e);
+                throw new ProcessException("Failed to generate state entries", 
e);
+            }
+        }
+    }
 
     @Override
     public void onTrigger(ProcessContext context, ProcessSession session) 
throws ProcessException {
         final ComponentLog logger = getLogger();
 
+        // Handle state generation if configured
+        handleStateGeneration(context, logger);
+
         FlowFile ff = session.get();
 
         try {
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/SortedStateUtils.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/SortedStateUtils.java
index b0ab748db1..b584d4b376 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/SortedStateUtils.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/SortedStateUtils.java
@@ -28,7 +28,7 @@ public class SortedStateUtils {
     /**
      * The maximum number of state entries to return to a client
      */
-    public static final int MAX_COMPONENT_STATE_ENTRIES = 500;
+    public static final int MAX_COMPONENT_STATE_ENTRIES = 5000;
 
     /**
      * Gets a comparator for comparing state entry keys.
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.effects.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.effects.ts
index 5568ca64aa..4d6721d07d 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.effects.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.effects.ts
@@ -27,7 +27,7 @@ import { MatDialog } from '@angular/material/dialog';
 import { ComponentStateService } from '../../service/component-state.service';
 import { ComponentStateDialog } from 
'../../ui/common/component-state/component-state.component';
 import { selectComponentUri, selectComponentState } from 
'./component-state.selectors';
-import { isDefinedAndNotNull, LARGE_DIALOG } from '@nifi/shared';
+import { isDefinedAndNotNull, XL_DIALOG } from '@nifi/shared';
 import * as ErrorActions from '../error/error.actions';
 import { HttpErrorResponse } from '@angular/common/http';
 import { ErrorHelper } from '../../service/error-helper.service';
@@ -86,7 +86,7 @@ export class ComponentStateEffects {
                 ofType(ComponentStateActions.openComponentStateDialog),
                 tap(() => {
                     const dialogReference = 
this.dialog.open(ComponentStateDialog, {
-                        ...LARGE_DIALOG,
+                        ...XL_DIALOG,
                         autoFocus: false
                     });
 
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.html
index 6a7ba21974..29a379d79f 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.html
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.html
@@ -69,66 +69,91 @@
                     <div class="my-2 tertiary-color leading-none font-medium">
                         Displaying {{ filteredEntries }} of {{ totalEntries }}
                     </div>
+                    <!--
+                        mat-table does not support virtual scrolling and this 
component requires
+                        virtual scrolling support. we moved away from 
mat-table in favor of a native
+                        html table with cdk virtual scrolling. styles are 
still applied with mat-table
+                        classes. in order for those styles to be added to this 
component a mat-table
+                        instance is required. this is an empty hidden table on 
this page to get the
+                        necessary styles on the page for our native virtual 
scrolling table to inherit
+                        and use the existing global styles.
+                    -->
+                    <table mat-table class="hidden">
+                        <tr mat-header-row *matHeaderRowDef="[]; sticky: 
true"></tr>
+                        <tr
+                            mat-row
+                            *matRowDef="let row; columns: []"></tr>
+                    </table>
                 </div>
                 <div class="listing-table flex-1 relative">
-                    <div class="absolute inset-0 overflow-y-auto 
overflow-x-hidden">
-                        <table
-                            mat-table
-                            [dataSource]="dataSource"
-                            matSort
-                            matSortDisableClear
-                            (matSortChange)="sortData($event)"
-                            [matSortActive]="initialSortColumn"
-                            [matSortDirection]="initialSortDirection">
-                            <!-- Key Column -->
-                            <ng-container matColumnDef="key">
-                                <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Key</th>
-                                <td mat-cell *matCellDef="let item">
-                                    {{ item.key }}
-                                </td>
-                            </ng-container>
-
-                            <!-- Value Column -->
-                            <ng-container matColumnDef="value">
-                                <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Value</th>
-                                <td mat-cell *matCellDef="let item" 
[title]="item.value">
-                                    {{ item.value }}
-                                </td>
-                            </ng-container>
-
-                            <!-- Scope Column -->
-                            @if (displayedColumns.includes('scope')) {
-                                <ng-container matColumnDef="scope">
-                                    <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Scope</th>
-                                    <td mat-cell *matCellDef="let item" 
[title]="item.scope">
-                                        {{ item.scope }}
-                                    </td>
-                                </ng-container>
-                            }
-
-                            <!-- Actions Column -->
-                            @if (displayedColumns.includes('actions')) {
-                                <ng-container matColumnDef="actions">
-                                    <th mat-header-cell *matHeaderCellDef></th>
-                                    <td mat-cell *matCellDef="let item">
-                                        <button
-                                            [disabled]="isClearing"
-                                            mat-icon-button
-                                            color="primary"
-                                            
(click)="clearComponentStateEntry(item)"
-                                            [title]="'Clear state entry: ' + 
item.key">
-                                            <i class="fa fa-trash"></i>
-                                        </button>
-                                    </td>
-                                </ng-container>
-                            }
-
-                            <tr mat-header-row 
*matHeaderRowDef="displayedColumns; sticky: true"></tr>
-                            <tr
-                                mat-row
-                                *matRowDef="let row; let even = even; columns: 
displayedColumns"
-                                [class.even]="even"></tr>
-                        </table>
+                    <div class="absolute inset-0 table-container">
+                        <div class="header-wrapper">
+                            <table class="mat-mdc-table mdc-data-table__table 
cdk-table-virtual">
+                                <thead>
+                                    <tr
+                                        class="mat-mdc-header-row 
mdc-data-table__header-row"
+                                        matSort
+                                        matSortDisableClear
+                                        (matSortChange)="sortData($event)"
+                                        [matSortActive]="currentSortColumn"
+                                        
[matSortDirection]="currentSortDirection">
+                                        <th
+                                            class="mat-mdc-header-cell 
mdc-data-table__header-cell"
+                                            mat-sort-header="key">
+                                            Key
+                                        </th>
+                                        <th
+                                            class="mat-mdc-header-cell 
mdc-data-table__header-cell"
+                                            mat-sort-header="value">
+                                            Value
+                                        </th>
+                                        @if 
(displayedColumns.includes('scope')) {
+                                            <th
+                                                class="mat-mdc-header-cell 
mdc-data-table__header-cell"
+                                                mat-sort-header="scope">
+                                                Scope
+                                            </th>
+                                        }
+                                        @if 
(displayedColumns.includes('actions')) {
+                                            <th class="mat-mdc-header-cell 
mdc-data-table__header-cell action-column"></th>
+                                        }
+                                    </tr>
+                                </thead>
+                            </table>
+                        </div>
+                        <cdk-virtual-scroll-viewport [itemSize]="ROW_HEIGHT" 
class="table-body-viewport">
+                            <table class="mat-mdc-table mdc-data-table__table 
cdk-table-virtual">
+                                <tbody>
+                                    <tr
+                                        *cdkVirtualFor="let item of 
dataSource; let i = index; trackBy: trackByKey"
+                                        class="mat-mdc-row mdc-data-table__row 
data-row"
+                                        [class.even]="i % 2 === 0"
+                                        [class.odd]="i % 2 !== 0">
+                                        <td class="mat-mdc-cell 
mdc-data-table__cell">{{ item.key }}</td>
+                                        <td class="mat-mdc-cell 
mdc-data-table__cell" [title]="item.value">
+                                            {{ item.value }}
+                                        </td>
+                                        @if 
(displayedColumns.includes('scope')) {
+                                            <td class="mat-mdc-cell 
mdc-data-table__cell" [title]="item.scope">
+                                                {{ item.scope }}
+                                            </td>
+                                        }
+                                        @if 
(displayedColumns.includes('actions')) {
+                                            <td class="mat-mdc-cell 
mdc-data-table__cell action-column">
+                                                <button
+                                                    [disabled]="clearing()"
+                                                    mat-icon-button
+                                                    color="primary"
+                                                    
(click)="clearComponentStateEntry(item)"
+                                                    [title]="'Clear state 
entry: ' + item.key">
+                                                    <i class="fa fa-trash"></i>
+                                                </button>
+                                            </td>
+                                        }
+                                    </tr>
+                                </tbody>
+                            </table>
+                        </cdk-virtual-scroll-viewport>
                     </div>
                 </div>
             </div>
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.scss
index 9821471e4d..fc2a375f66 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.scss
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.scss
@@ -21,10 +21,79 @@
     @include mat.button-density(-1);
 
     .listing-table {
-        table {
-            .mat-column-key {
-                width: 200px;
+        @include mat.table-density(-4);
+
+        .table-container {
+            display: flex;
+            flex-direction: column;
+        }
+
+        .header-wrapper {
+            padding-right: 15px; // Account for scrollbar width
+            background-color: var(--mat-sys-secondary);
+            border-bottom-width: 1px;
+            border-bottom-style: solid;
+        }
+
+        .cdk-table-virtual {
+            width: 100%;
+            table-layout: fixed;
+
+            thead {
+                display: table;
+                width: 100%;
+                table-layout: fixed;
+
+                tr {
+                    height: 40px;
+                }
+
+                th {
+                    height: 40px;
+                    color: var(--mat-sys-on-secondary);
+                    border-bottom-width: 0; // Override global listing-table 
border
+
+                    // Key column fixed width
+                    &:first-child {
+                        width: 200px;
+                    }
+
+                    // Actions column fixed width
+                    &.action-column {
+                        width: 52px;
+                    }
+                }
             }
+
+            tbody {
+                display: table;
+                width: 100%;
+                table-layout: fixed;
+
+                tr {
+                    height: 36px; // Must match ROW_HEIGHT constant
+                }
+
+                td {
+                    height: 36px; // Must match ROW_HEIGHT constant
+
+                    // Key column fixed width
+                    &:first-child {
+                        width: 200px;
+                    }
+
+                    // Actions column fixed width
+                    &.action-column {
+                        width: 52px;
+                    }
+                }
+            }
+        }
+
+        .table-body-viewport {
+            flex: 1;
+            overflow-y: scroll; // Always show scrollbar for consistent width
+            overflow-x: hidden;
         }
     }
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.spec.ts
index 24b2965fb4..cab58d84fa 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.spec.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { ComponentFixture, TestBed, fakeAsync, tick } from 
'@angular/core/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { NoopAnimationsModule } from '@angular/platform-browser/animations';
 import { MockStore, provideMockStore } from '@ngrx/store/testing';
 import { MatDialogRef } from '@angular/material/dialog';
@@ -113,15 +113,16 @@ describe('ComponentStateDialog', () => {
 
     describe('component initialization', () => {
         it('should process component state and populate data source', () => {
-            expect(component.dataSource.data.length).toBe(4); // 2 local + 2 
cluster
+            expect(component.dataSource.length).toBe(4); // 2 local + 2 cluster
+            expect(component.allStateItems.length).toBe(4);
             expect(component.totalEntries).toBe(4);
             expect(component.filteredEntries).toBe(4);
             expect(component.stateDescription).toBe('Test state description 
for processor');
         });
 
         it('should set correct scope for local and cluster state items', () => 
{
-            const localItems = component.dataSource.data.filter((item) => 
item.scope !== 'Cluster');
-            const clusterItems = component.dataSource.data.filter((item) => 
item.scope === 'Cluster');
+            const localItems = component.dataSource.filter((item) => 
item.scope !== 'Cluster');
+            const clusterItems = component.dataSource.filter((item) => 
item.scope === 'Cluster');
 
             expect(localItems.length).toBe(2);
             expect(clusterItems.length).toBe(2);
@@ -133,52 +134,82 @@ describe('ComponentStateDialog', () => {
             expect(component.displayedColumns).toContain('actions');
             expect(component.canClear).toBe(true);
         });
+
+        it('should initialize currentSortColumn and currentSortDirection', () 
=> {
+            expect(component.currentSortColumn).toBe('key');
+            expect(component.currentSortDirection).toBe('asc');
+        });
     });
 
     describe('filtering', () => {
-        it('should filter data source based on filter term', fakeAsync(() => {
-            component.filterForm.get('filterTerm')?.setValue('local-key1');
-            tick(500); // Wait for debounce
+        it('should filter data source based on filter term', () => {
+            component.applyFilter('local-key1');
 
-            expect(component.dataSource.filteredData.length).toBe(1);
+            expect(component.dataSource.length).toBe(1);
             expect(component.filteredEntries).toBe(1);
-            
expect(component.dataSource.filteredData[0].key).toBe('local-key1');
-        }));
+            expect(component.dataSource[0].key).toBe('local-key1');
+        });
 
-        it('should filter case-insensitively', fakeAsync(() => {
-            component.filterForm.get('filterTerm')?.setValue('CLUSTER');
-            tick(500); // Wait for debounce
+        it('should filter case-insensitively', () => {
+            component.applyFilter('CLUSTER');
 
-            expect(component.dataSource.filteredData.length).toBe(2);
+            expect(component.dataSource.length).toBe(2);
             expect(component.filteredEntries).toBe(2);
-            expect(component.dataSource.filteredData.every((item) => 
item.key.includes('cluster'))).toBe(true);
-        }));
+            expect(component.dataSource.every((item) => 
item.key.includes('cluster'))).toBe(true);
+        });
 
-        it('should show all items when filter is cleared', fakeAsync(() => {
-            component.filterForm.get('filterTerm')?.setValue('local');
-            tick(500);
+        it('should show all items when filter is cleared', () => {
+            component.applyFilter('local');
             expect(component.filteredEntries).toBe(2);
 
-            component.filterForm.get('filterTerm')?.setValue('');
-            tick(500);
+            component.applyFilter('');
             expect(component.filteredEntries).toBe(4);
-        }));
+        });
+
+        it('should filter by value field', () => {
+            component.applyFilter('value1');
+
+            expect(component.dataSource.length).toBeGreaterThan(0);
+            expect(component.dataSource.every((item) => 
item.value.includes('value1'))).toBe(true);
+        });
+
+        it('should filter by scope field', () => {
+            component.applyFilter('node1');
+
+            expect(component.dataSource.length).toBe(2);
+            expect(component.dataSource.every((item) => 
item.scope?.includes('node1'))).toBe(true);
+        });
+
+        it('should preserve allStateItems when filtering', () => {
+            const originalLength = component.allStateItems.length;
+            component.applyFilter('local');
+
+            expect(component.allStateItems.length).toBe(originalLength);
+            expect(component.dataSource.length).toBeLessThan(originalLength);
+        });
+
+        it('should trim filter term before applying', () => {
+            component.applyFilter('  local-key1  ');
+
+            expect(component.dataSource.length).toBe(1);
+            expect(component.dataSource[0].key).toBe('local-key1');
+        });
     });
 
     describe('sorting', () => {
         it('should sort data by key in ascending order by default', () => {
             // Data is sorted alphabetically, so check we have the expected 
structure
-            expect(component.dataSource.data.length).toBe(4);
-            expect(component.dataSource.data.some((item) => 
item.key.includes('cluster'))).toBe(true);
-            expect(component.dataSource.data.some((item) => 
item.key.includes('local'))).toBe(true);
+            expect(component.dataSource.length).toBe(4);
+            expect(component.dataSource.some((item) => 
item.key.includes('cluster'))).toBe(true);
+            expect(component.dataSource.some((item) => 
item.key.includes('local'))).toBe(true);
         });
 
         it('should sort data by key in descending order', () => {
             component.sortData({ active: 'key', direction: 'desc' });
 
             // Verify that data is still properly structured after sorting
-            expect(component.dataSource.data.length).toBe(4);
-            expect(component.dataSource.data.every((item) => item.key && 
item.value)).toBe(true);
+            expect(component.dataSource.length).toBe(4);
+            expect(component.dataSource.every((item) => item.key && 
item.value)).toBe(true);
         });
 
         it('should sort data by value', () => {
@@ -192,9 +223,37 @@ describe('ComponentStateDialog', () => {
             component.sortData({ active: 'scope', direction: 'asc' });
 
             // Cluster scope should come before node addresses
-            const clusterItems = component.dataSource.data.filter((item) => 
item.scope === 'Cluster');
+            const clusterItems = component.dataSource.filter((item) => 
item.scope === 'Cluster');
             expect(clusterItems.length).toBe(2);
         });
+
+        it('should update currentSortColumn and currentSortDirection when 
sorting', () => {
+            component.sortData({ active: 'value', direction: 'desc' });
+
+            expect(component.currentSortColumn).toBe('value');
+            expect(component.currentSortDirection).toBe('desc');
+        });
+
+        it('should update allStateItems when sorting without filter', () => {
+            const originalFirstItem = component.allStateItems[0];
+            component.sortData({ active: 'key', direction: 'desc' });
+
+            // After sorting descending, first item should be different
+            expect(component.allStateItems[0]).not.toBe(originalFirstItem);
+        });
+
+        it('should preserve allStateItems when sorting with active filter', () 
=> {
+            const originalAllStateItems = [...component.allStateItems];
+
+            // Apply filter directly
+            component.applyFilter('local');
+
+            // Sort the filtered data
+            component.sortData({ active: 'key', direction: 'desc' });
+
+            // allStateItems should remain unchanged (same keys, though may be 
reordered)
+            
expect(component.allStateItems.length).toBe(originalAllStateItems.length);
+        });
     });
 
     describe('clearState', () => {
@@ -287,5 +346,140 @@ describe('ComponentStateDialog', () => {
             expect(result[0].key).toBe('local-key');
             expect(result[0].value).toBe('local-value');
         });
+
+        it('should handle empty state map', () => {
+            const emptyStateMap = {
+                scope: 'LOCAL',
+                state: [],
+                totalEntryCount: 0
+            };
+
+            const result = component.processStateMap(emptyStateMap, false);
+
+            expect(result.length).toBe(0);
+        });
+
+        it('should handle state map with null state array', () => {
+            const nullStateMap = {
+                scope: 'LOCAL',
+                state: null,
+                totalEntryCount: 0
+            } as any;
+
+            const result = component.processStateMap(nullStateMap, false);
+
+            expect(result.length).toBe(0);
+        });
+    });
+
+    describe('trackByKey', () => {
+        it('should return a unique identifier combining key and scope', () => {
+            const item = { key: 'test-key', value: 'test-value', scope: 
'LOCAL' };
+            const result = component.trackByKey(0, item);
+
+            expect(result).toBe('test-key-LOCAL');
+        });
+
+        it('should return unique identifiers for different items', () => {
+            const item1 = { key: 'key-1', value: 'value-1', scope: 'LOCAL' };
+            const item2 = { key: 'key-2', value: 'value-2', scope: 'LOCAL' };
+
+            const result1 = component.trackByKey(0, item1);
+            const result2 = component.trackByKey(1, item2);
+
+            expect(result1).not.toBe(result2);
+        });
+
+        it('should differentiate items with same key but different scopes', () 
=> {
+            const localItem = { key: 'same-key', value: 'value-1', scope: 
'node1:8443' };
+            const clusterItem = { key: 'same-key', value: 'value-2', scope: 
'Cluster' };
+
+            const result1 = component.trackByKey(0, localItem);
+            const result2 = component.trackByKey(1, clusterItem);
+
+            expect(result1).toBe('same-key-node1:8443');
+            expect(result2).toBe('same-key-Cluster');
+            expect(result1).not.toBe(result2);
+        });
+
+        it('should handle items with undefined scope', () => {
+            const item = { key: 'test-key', value: 'test-value', scope: 
undefined };
+            const result = component.trackByKey(0, item);
+
+            expect(result).toBe('test-key-none');
+        });
+    });
+
+    describe('virtual scrolling support', () => {
+        it('should handle large datasets efficiently', () => {
+            // Simulate a large dataset
+            const largeStateMap = {
+                scope: 'LOCAL',
+                state: Array.from({ length: 1000 }, (_, i) => ({
+                    key: `key-${i}`,
+                    value: `value-${i}`,
+                    clusterNodeAddress: 'node1:8443'
+                })),
+                totalEntryCount: 1000
+            };
+
+            const result = component.processStateMap(largeStateMap, false);
+
+            expect(result.length).toBe(1000);
+            expect(component.dataSource.length).toBeGreaterThan(0);
+        });
+
+        it('should maintain all data in allStateItems for virtual scrolling', 
() => {
+            
expect(component.allStateItems.length).toBe(component.dataSource.length);
+        });
+    });
+
+    describe('input properties', () => {
+        it('should use custom initial sort column when provided', () => {
+            const customFixture = 
TestBed.createComponent(ComponentStateDialog);
+            const customComponent = customFixture.componentInstance;
+            customComponent.initialSortColumn = 'value';
+            customComponent.initialSortDirection = 'desc';
+
+            customFixture.detectChanges();
+
+            expect(customComponent.initialSortColumn).toBe('value');
+            expect(customComponent.initialSortDirection).toBe('desc');
+        });
+    });
+
+    describe('edge cases', () => {
+        it('should handle sorting with empty dataset', () => {
+            component.dataSource = [];
+            component.allStateItems = [];
+
+            component.sortData({ active: 'key', direction: 'asc' });
+
+            expect(component.dataSource.length).toBe(0);
+        });
+
+        it('should handle filtering with empty dataset', () => {
+            component.dataSource = [];
+            component.allStateItems = [];
+
+            component.applyFilter('test');
+
+            expect(component.dataSource.length).toBe(0);
+            expect(component.filteredEntries).toBe(0);
+        });
+
+        it('should handle scope sorting when scope is undefined', () => {
+            const itemsWithoutScope = [
+                { key: 'key1', value: 'value1', scope: undefined },
+                { key: 'key2', value: 'value2', scope: 'Cluster' }
+            ];
+            component.dataSource = itemsWithoutScope;
+            component.allStateItems = itemsWithoutScope;
+
+            component.sortData({ active: 'scope', direction: 'asc' });
+
+            // Should not throw error
+            expect(component.dataSource.length).toBe(2);
+        });
     });
 });
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.ts
index ddf4763b67..70986dc000 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.ts
@@ -18,9 +18,10 @@
 import { AfterViewInit, Component, DestroyRef, inject, Input, Signal } from 
'@angular/core';
 import { MatButtonModule } from '@angular/material/button';
 import { MatDialogModule } from '@angular/material/dialog';
-import { MatTableDataSource, MatTableModule } from '@angular/material/table';
 import { MatSortModule, Sort } from '@angular/material/sort';
 import { AsyncPipe } from '@angular/common';
+import { Observable } from 'rxjs';
+import { ScrollingModule } from '@angular/cdk/scrolling';
 import { isDefinedAndNotNull, CloseOnEscapeDialog, NiFiCommon, 
NifiTooltipDirective, TextTip } from '@nifi/shared';
 import { ComponentStateState, StateEntry, StateItem, StateMap } from 
'../../../state/component-state';
 import { Store } from '@ngrx/store';
@@ -32,7 +33,7 @@ import {
     selectComponentState,
     selectDropStateKeySupported
 } from '../../../state/component-state/component-state.selectors';
-import { debounceTime, Observable } from 'rxjs';
+import { debounceTime } from 'rxjs';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
 import { MatFormFieldModule } from '@angular/material/form-field';
@@ -42,13 +43,14 @@ import { ErrorContextKey } from '../../../state/error';
 import { ContextErrorBanner } from 
'../context-error-banner/context-error-banner.component';
 import { concatLatestFrom } from '@ngrx/operators';
 import { NifiSpinnerDirective } from '../spinner/nifi-spinner.directive';
+import { MatTableModule } from '@angular/material/table';
 
 @Component({
     selector: 'component-state',
+    standalone: true,
     imports: [
         MatButtonModule,
         MatDialogModule,
-        MatTableModule,
         MatSortModule,
         AsyncPipe,
         ReactiveFormsModule,
@@ -56,7 +58,9 @@ import { NifiSpinnerDirective } from 
'../spinner/nifi-spinner.directive';
         MatInputModule,
         ContextErrorBanner,
         NifiTooltipDirective,
-        NifiSpinnerDirective
+        NifiSpinnerDirective,
+        ScrollingModule,
+        MatTableModule
     ],
     templateUrl: './component-state.component.html',
     styleUrls: ['./component-state.component.scss']
@@ -74,7 +78,15 @@ export class ComponentStateDialog extends 
CloseOnEscapeDialog implements AfterVi
     clearing: Signal<boolean> = this.store.selectSignal(selectClearing);
 
     displayedColumns: string[] = ['key', 'value'];
-    dataSource: MatTableDataSource<StateItem> = new 
MatTableDataSource<StateItem>();
+    dataSource: StateItem[] = [];
+    allStateItems: StateItem[] = []; // Full dataset for filtering
+
+    // Virtual scroll configuration
+    readonly ROW_HEIGHT = 36; // Height of each table row in pixels (density 
-4)
+
+    // Sort state
+    currentSortColumn: 'key' | 'value' | 'scope' = this.initialSortColumn;
+    currentSortDirection: 'asc' | 'desc' = this.initialSortDirection;
 
     filterForm: FormGroup;
 
@@ -97,19 +109,26 @@ export class ComponentStateDialog extends 
CloseOnEscapeDialog implements AfterVi
                 this.stateDescription = componentState.stateDescription;
 
                 const stateItems: StateItem[] = [];
+
                 if (componentState.localState) {
                     const localStateItems: StateItem[] = 
this.processStateMap(componentState.localState, false);
                     stateItems.push(...localStateItems);
                 }
+
                 if (componentState.clusterState) {
                     const clusterStateItems: StateItem[] = 
this.processStateMap(componentState.clusterState, true);
                     stateItems.push(...clusterStateItems);
                 }
 
-                this.dataSource.data = this.sortStateItems(stateItems, {
+                const sortedItems = this.sortStateItems(stateItems, {
                     active: this.initialSortColumn,
                     direction: this.initialSortDirection
                 });
+
+                // Store full dataset for virtual scrolling
+                this.allStateItems = sortedItems;
+                this.dataSource = sortedItems;
+
                 this.filteredEntries = stateItems.length;
 
                 // apply any filtering to the new data
@@ -189,12 +208,50 @@ export class ComponentStateDialog extends 
CloseOnEscapeDialog implements AfterVi
     }
 
     applyFilter(filterTerm: string) {
-        this.dataSource.filter = filterTerm.trim().toLowerCase();
-        this.filteredEntries = this.dataSource.filteredData.length;
+        const term = filterTerm.trim().toLowerCase();
+
+        if (!term) {
+            // No filter - show all items
+            this.dataSource = this.allStateItems;
+            this.filteredEntries = this.allStateItems.length;
+        } else {
+            // Filter the full dataset
+            const filtered = this.filterStateItems(term);
+            this.dataSource = filtered;
+            this.filteredEntries = filtered.length;
+        }
     }
 
     sortData(sort: Sort) {
-        this.dataSource.data = this.sortStateItems(this.dataSource.data, sort);
+        this.currentSortColumn = sort.active as 'key' | 'value' | 'scope';
+        this.currentSortDirection = sort.direction as 'asc' | 'desc';
+
+        // Determine what data to sort (filtered or all)
+        const filterTerm = 
this.filterForm.get('filterTerm')?.value?.trim().toLowerCase();
+        let dataToSort = this.allStateItems;
+
+        if (filterTerm) {
+            dataToSort = this.filterStateItems(filterTerm);
+        }
+
+        const sortedData = this.sortStateItems(dataToSort, sort);
+
+        // Update allStateItems with sorted data if not filtering
+        if (!filterTerm) {
+            this.allStateItems = sortedData;
+        }
+
+        this.dataSource = sortedData;
+        this.filteredEntries = sortedData.length;
+    }
+
+    private filterStateItems(filterTerm: string): StateItem[] {
+        return this.allStateItems.filter(
+            (item) =>
+                item.key.toLowerCase().includes(filterTerm) ||
+                item.value.toLowerCase().includes(filterTerm) ||
+                (item.scope && item.scope.toLowerCase().includes(filterTerm))
+        );
     }
 
     private sortStateItems(data: StateItem[], sort: Sort): StateItem[] {
@@ -242,6 +299,10 @@ export class ComponentStateDialog extends 
CloseOnEscapeDialog implements AfterVi
         );
     }
 
+    trackByKey(index: number, item: StateItem): string {
+        return `${item.key}-${item.scope || 'none'}`;
+    }
+
     protected readonly ErrorContextKey = ErrorContextKey;
     protected readonly TextTip = TextTip;
 }


Reply via email to