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;
}