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

xuang7 pushed a commit to branch release/v1.2
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/release/v1.2 by this push:
     new 1b7a02f502 fix(frontend, v1.2): stabilize admin execution pagination 
bar  (#6993)
1b7a02f502 is described below

commit 1b7a02f5021c8da0fcc1a31386dad8ff55cc69a2
Author: Yicong Huang <[email protected]>
AuthorDate: Wed Jul 29 14:10:22 2026 -0400

    fix(frontend, v1.2): stabilize admin execution pagination bar  (#6993)
    
    ### What changes were proposed in this PR?
    
    Backport of #6050 to `release/v1.2`, cherry-picked from
    28ec7d64c5a053b170ce2c7cd332c1f1352f7523. The cherry-pick applied
    cleanly.
    
    Follows the Direct Backport Push convention; opened as a PR (rather than
    a direct push) per a backport-coverage audit.
    
    ### Any related issues, documentation, discussions?
    
    Backport of #6050. Originally linked #3586.
    
    ### How was this PR tested?
    
    Release-branch CI runs on this PR. Cherry-pick applied cleanly onto
    `release/v1.2`; no manual conflict resolution was needed.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes — backport prepared with Claude Code (mechanical cherry-pick; the
    change itself is #6050 by its original author).
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-authored-by: Raja-Hamid <[email protected]>
---
 .../execution/admin-execution.component.spec.ts    | 119 +++++++++++++++++++
 .../admin/execution/admin-execution.component.ts   | 126 ++++++++++++++++-----
 2 files changed, 218 insertions(+), 27 deletions(-)

diff --git 
a/frontend/src/app/dashboard/component/admin/execution/admin-execution.component.spec.ts
 
b/frontend/src/app/dashboard/component/admin/execution/admin-execution.component.spec.ts
index caaa71a220..653b47ee53 100644
--- 
a/frontend/src/app/dashboard/component/admin/execution/admin-execution.component.spec.ts
+++ 
b/frontend/src/app/dashboard/component/admin/execution/admin-execution.component.spec.ts
@@ -26,6 +26,8 @@ import { NzDropDownModule } from "ng-zorro-antd/dropdown";
 import { NzModalModule } from "ng-zorro-antd/modal";
 import { commonTestProviders } from "../../../../common/testing/test-utils";
 import { Execution } from "../../../../common/type/execution";
+import { NzTableQueryParams } from "ng-zorro-antd/table";
+import { of } from "rxjs";
 
 describe("AdminDashboardComponent", () => {
   let component: AdminExecutionComponent;
@@ -44,6 +46,8 @@ describe("AdminDashboardComponent", () => {
     fixture.detectChanges();
   });
 
+  afterEach(() => fixture.destroy());
+
   it("should create", inject([HttpTestingController], () => {
     expect(component).toBeTruthy();
   }));
@@ -67,3 +71,118 @@ describe("AdminDashboardComponent", () => {
     expect(anchor).toBeTruthy();
   });
 });
+
+describe("AdminExecutionComponent pagination (#3586)", () => {
+  let component: AdminExecutionComponent;
+  let fixture: ComponentFixture<AdminExecutionComponent>;
+  let service: AdminExecutionService;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      providers: [AdminExecutionService, ...commonTestProviders],
+      imports: [AdminExecutionComponent, HttpClientTestingModule, 
NzDropDownModule, NzModalModule],
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(AdminExecutionComponent);
+    component = fixture.componentInstance;
+    service = TestBed.inject(AdminExecutionService);
+    // Keep any data fetch inert and synchronous so the page-bar state can be 
asserted directly.
+    // Note: we deliberately do NOT call fixture.detectChanges() (no 
ngOnInit/auto-refresh) and
+    // drive onQueryParamsChange() directly, the way ng-zorro's nz-table does 
on a page click.
+    vi.spyOn(service, "getExecutionList").mockReturnValue(of([]));
+    vi.spyOn(service, "getTotalWorkflows").mockImplementation(() => 
of(component.totalWorkflows));
+  });
+
+  afterEach(() => fixture.destroy());
+
+  function changeParams(pageSize: number, pageIndex: number): void {
+    component.onQueryParamsChange({ pageSize, pageIndex, sort: [], filter: [] 
} as NzTableQueryParams);
+  }
+
+  it("moves to the page the user clicks (page 1 -> page 5)", () => {
+    component.totalWorkflows = 65; // 13 pages at size 5
+    component.pageSize = 5;
+    component.currentPageIndex = 0;
+
+    changeParams(5, 5);
+
+    expect(component.pageSize).toBe(5);
+    expect(component.currentPageIndex).toBe(4); // 0-indexed page 5
+  });
+
+  it("follows the emitted page index when the page size changes (reset to 
first page)", () => {
+    // On a page-size change ng-zorro emits the new size together with 
pageIndex=1.
+    component.totalWorkflows = 65;
+    component.pageSize = 5;
+    component.currentPageIndex = 4; // currently on page 5
+
+    changeParams(20, 1);
+
+    expect(component.pageSize).toBe(20);
+    expect(component.currentPageIndex).toBe(0); // must follow pageIndex=1, 
not stay on page 5
+  });
+
+  it("syncs both page size and page index from a single event, 
order-independently", () => {
+    component.totalWorkflows = 65;
+    component.pageSize = 5;
+    component.currentPageIndex = 4;
+
+    changeParams(20, 3); // size and index change together
+
+    expect(component.pageSize).toBe(20);
+    expect(component.currentPageIndex).toBe(2); // page 3 at the new size
+  });
+
+  it("clamps to the last existing page when a larger page size removes pages", 
() => {
+    component.totalWorkflows = 65; // 2 pages at size 50
+    component.pageSize = 5;
+    component.currentPageIndex = 12; // last page at size 5
+
+    changeParams(50, 13);
+
+    expect(component.pageSize).toBe(50);
+    expect(component.currentPageIndex).toBe(1); // clamp to page 2 (the last 
page)
+  });
+
+  it("handles single-page results (stays on the only page)", () => {
+    component.totalWorkflows = 3; // 1 page
+    component.pageSize = 5;
+    component.currentPageIndex = 0;
+
+    changeParams(5, 1);
+
+    expect(component.currentPageIndex).toBe(0);
+  });
+
+  it("clamps to the first page for empty results even if a later page is 
requested", () => {
+    component.totalWorkflows = 0;
+    component.pageSize = 5;
+    component.currentPageIndex = 0;
+
+    changeParams(5, 5); // requesting page 5 with no data at all
+
+    expect(component.currentPageIndex).toBe(0);
+  });
+
+  it("does not refetch when neither page size nor page index changed 
(sort/filter handled elsewhere)", () => {
+    component.totalWorkflows = 65;
+    component.pageSize = 5;
+    component.currentPageIndex = 4; // page 5
+    vi.mocked(service.getExecutionList).mockClear();
+
+    changeParams(5, 5); // page 5 again, same size -> no-op for pagination
+
+    expect(service.getExecutionList).not.toHaveBeenCalled();
+  });
+
+  it("fetches exactly once for a real page change", () => {
+    component.totalWorkflows = 65;
+    component.pageSize = 5;
+    component.currentPageIndex = 0;
+    vi.mocked(service.getExecutionList).mockClear();
+
+    changeParams(5, 5);
+
+    expect(service.getExecutionList).toHaveBeenCalledTimes(1);
+  });
+});
diff --git 
a/frontend/src/app/dashboard/component/admin/execution/admin-execution.component.ts
 
b/frontend/src/app/dashboard/component/admin/execution/admin-execution.component.ts
index 2bf2f9773e..14861539a6 100644
--- 
a/frontend/src/app/dashboard/component/admin/execution/admin-execution.component.ts
+++ 
b/frontend/src/app/dashboard/component/admin/execution/admin-execution.component.ts
@@ -19,6 +19,8 @@
 
 import { Component, OnDestroy, OnInit } from "@angular/core";
 import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { forkJoin, interval, Observable } from "rxjs";
+import { switchMap } from "rxjs/operators";
 import { AdminExecutionService } from 
"../../../service/admin/execution/admin-execution.service";
 import { Execution } from "../../../../common/type/execution";
 import {
@@ -48,6 +50,11 @@ import { NzSpinComponent } from "ng-zorro-antd/spin";
 
 export const NO_SORT = "NO_SORTING";
 
+// How often the on-screen elapsed-time / status of already-loaded rows is 
recomputed (client-side only).
+const TIME_TICK_INTERVAL_MS = 1000;
+// How often the current page's data is refreshed from the server (decoupled 
from the page bar).
+const EXECUTION_REFRESH_INTERVAL_MS = 5000;
+
 @UntilDestroy()
 @Component({
   templateUrl: "./admin-execution.component.html",
@@ -84,9 +91,8 @@ export class AdminExecutionComponent implements OnInit, 
OnDestroy {
   sortDirection: string = NO_SORT;
   filter: string[] = [];
 
-  // This interval function fetches the latest execution list.
-  // The interval runs every 1 second (1000 milliseconds).
-  timer = setInterval(() => this.ngOnInit(), 1000); // 1 second interval
+  // Handle for the client-side time/status tick; cleared on destroy.
+  private clockTimer?: ReturnType<typeof setInterval>;
 
   constructor(
     private adminExecutionService: AdminExecutionService,
@@ -95,23 +101,81 @@ export class AdminExecutionComponent implements OnInit, 
OnDestroy {
   ) {}
 
   ngOnInit() {
-    this.adminExecutionService
-      .getExecutionList(this.pageSize, this.currentPageIndex, this.sortField, 
this.sortDirection, this.filter)
+    // Initial load of the current page.
+    this.fetchData();
+
+    // Tick the elapsed-time / "JUST COMPLETED" status of already-loaded rows 
every
+    // second. This is purely client-side (no server call) and never touches 
the page bar.
+    this.clockTimer = setInterval(() => this.updateTimeStatus(), 
TIME_TICK_INTERVAL_MS);
+
+    // Periodically refresh the current page's data so live status changes and 
new
+    // executions stay fresh. switchMap cancels a stale in-flight request, and 
the page
+    // index/size are not disturbed here, so the page bar stays stable while 
polling.
+    interval(EXECUTION_REFRESH_INTERVAL_MS)
+      .pipe(
+        switchMap(() => this.currentPage$()),
+        untilDestroyed(this)
+      )
+      .subscribe(result => this.applyCurrentPage(result));
+  }
+
+  ngOnDestroy(): void {
+    if (this.clockTimer !== undefined) {
+      clearInterval(this.clockTimer);
+    }
+  }
+
+  /**
+   * Fetch the current page of executions plus the total count, used on init 
and on
+   * user-driven pagination / sort / filter changes (a brief loading state is 
fine here).
+   */
+  fetchData(): void {
+    this.isLoading = true;
+    this.currentPage$()
       .pipe(untilDestroyed(this))
-      .subscribe(executionList => {
-        this.listOfExecutions = [...executionList];
-        this.updateTimeStatus();
+      .subscribe(result => {
+        this.applyCurrentPage(result);
         this.isLoading = false;
       });
+  }
 
-    this.adminExecutionService
-      .getTotalWorkflows()
+  /**
+   * Refresh only the current page's rows without changing the page index/size 
— used by
+   * the background poll and immediately after kill/pause/resume so the 
control surface
+   * reflects the new status without waiting for the next refresh tick.
+   */
+  private fetchCurrentPage(): void {
+    this.currentPage$()
       .pipe(untilDestroyed(this))
-      .subscribe(total => (this.totalWorkflows = total));
+      .subscribe(result => this.applyCurrentPage(result));
   }
 
-  ngOnDestroy(): void {
-    clearInterval(this.timer);
+  /**
+   * Build the request for the current page's executions and the total 
workflow count.
+   */
+  private currentPage$(): Observable<{ executions: ReadonlyArray<Execution>; 
total: number }> {
+    return forkJoin({
+      executions: this.adminExecutionService.getExecutionList(
+        this.pageSize,
+        this.currentPageIndex,
+        this.sortField,
+        this.sortDirection,
+        this.filter
+      ),
+      total: this.adminExecutionService.getTotalWorkflows(),
+    });
+  }
+
+  /**
+   * Apply a fetched page to the view. The total is only reassigned when it 
actually
+   * changed, so a background refresh never churns the page count 
mid-interaction.
+   */
+  private applyCurrentPage(result: { executions: ReadonlyArray<Execution>; 
total: number }): void {
+    this.listOfExecutions = [...result.executions];
+    if (result.total !== this.totalWorkflows) {
+      this.totalWorkflows = result.total;
+    }
+    this.updateTimeStatus();
   }
 
   maxStringLength(input: string, length: number): string {
@@ -262,6 +326,8 @@ export class AdminExecutionComponent implements OnInit, 
OnDestroy {
     let socket = new WorkflowWebsocketService(this.config);
     socket.openWebsocket(wid);
     socket.send("WorkflowKillRequest", {});
+    // Reflect the new status promptly instead of waiting for the next refresh 
tick.
+    this.fetchCurrentPage();
     // socket.closeWebsocket();
   }
 
@@ -273,6 +339,8 @@ export class AdminExecutionComponent implements OnInit, 
OnDestroy {
     let socket = new WorkflowWebsocketService(this.config);
     socket.openWebsocket(wid);
     socket.send("WorkflowPauseRequest", {});
+    // Reflect the new status promptly instead of waiting for the next refresh 
tick.
+    this.fetchCurrentPage();
     // socket.closeWebsocket();
   }
 
@@ -284,6 +352,8 @@ export class AdminExecutionComponent implements OnInit, 
OnDestroy {
     let socket = new WorkflowWebsocketService(this.config);
     socket.openWebsocket(wid);
     socket.send("WorkflowResumeRequest", {});
+    // Reflect the new status promptly instead of waiting for the next refresh 
tick.
+    this.fetchCurrentPage();
     // socket.closeWebsocket();
   }
 
@@ -297,11 +367,11 @@ export class AdminExecutionComponent implements OnInit, 
OnDestroy {
     if (sortField == this.sortField && sortOrder == null) {
       this.sortField = NO_SORT;
       this.sortDirection = NO_SORT;
-      this.ngOnInit();
+      this.fetchData();
     } else if (sortOrder != null) {
       this.sortField = sortField;
       this.sortDirection = sortOrder === "descend" ? "desc" : "asc";
-      this.ngOnInit();
+      this.fetchData();
     }
   }
 
@@ -310,18 +380,20 @@ export class AdminExecutionComponent implements OnInit, 
OnDestroy {
    * @param params
    */
   onQueryParamsChange(params: NzTableQueryParams): void {
-    const { pageSize, pageIndex, sort, filter } = params;
-    if (pageSize != this.pageSize) {
-      this.pageSize = pageSize;
-      // If the user is at the last page, and increment the pageSize, move 
user to new last page index if necessary.
-      if (Math.ceil(this.totalWorkflows / pageSize) < this.currentPageIndex + 
1) {
-        this.currentPageIndex = Math.ceil(this.totalWorkflows / pageSize) - 1;
-      }
-      this.ngOnInit();
-    } else if (pageIndex - 1 != this.currentPageIndex) {
-      this.currentPageIndex = pageIndex - 1;
-      this.ngOnInit();
+    const { pageSize, pageIndex } = params;
+    const pageSizeChanged = pageSize !== this.pageSize;
+    const pageIndexChanged = pageIndex - 1 !== this.currentPageIndex;
+    // Sort/filter changes also emit nzQueryParams, but they are handled by
+    // onSortChange/onFilterChange; ignore them here to avoid a double fetch.
+    if (!pageSizeChanged && !pageIndexChanged) {
+      return;
     }
+    this.pageSize = pageSize;
+    // Read both the size and the (1-indexed) page from the event and clamp 
the page
+    // into range, independent of the order ng-zorro reports size vs. index 
changes.
+    const lastPageIndex = Math.max(1, Math.ceil(this.totalWorkflows / 
pageSize));
+    this.currentPageIndex = Math.min(pageIndex, lastPageIndex) - 1;
+    this.fetchData();
   }
 
   /**
@@ -331,6 +403,6 @@ export class AdminExecutionComponent implements OnInit, 
OnDestroy {
    */
   onFilterChange(filter: any[]): void {
     this.filter = filter.map(item => String(item));
-    this.ngOnInit();
+    this.fetchData();
   }
 }

Reply via email to