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