You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/07/06 06:02:23 UTC
(texera) branch gh-readonly-queue/main/pr-6050-8a2f17fb2c824d55726afe2737f7780da0ff2db2 created (now 28ec7d64c5)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-6050-8a2f17fb2c824d55726afe2737f7780da0ff2db2
in repository https://gitbox.apache.org/repos/asf/texera.git
at 28ec7d64c5 fix(frontend): stabilize admin execution pagination bar (#6050)
This branch includes the following new commits:
new 28ec7d64c5 fix(frontend): stabilize admin execution pagination bar (#6050)
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(texera) 01/01: fix(frontend): stabilize admin execution pagination bar (#6050)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6050-8a2f17fb2c824d55726afe2737f7780da0ff2db2
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 28ec7d64c5a053b170ce2c7cd332c1f1352f7523
Author: Raja-Hamid <12...@users.noreply.github.com>
AuthorDate: Mon Jul 6 11:02:04 2026 +0500
fix(frontend): stabilize admin execution pagination bar (#6050)
### What changes were proposed in this PR?
The **Admin → Executions** page had an inconsistent, "jumpy" page-number
bar compared to every other list in the app (#3586).
**Root cause.** It was the only paginated page that re-ran its entire
query every second via `setInterval(() => this.ngOnInit(), 1000)` while
driving a *manual server-side* pager (`[nzFrontPagination]="false"` with
bound `[nzPageIndex]`/`[nzPageSize]`/`[nzTotal]`). Every tick reassigned
the data and total under the pager, with no request cancellation, so the
bar reshuffled every second and raced the user's clicks. Separately,
`onQueryParamsChange` used an `else-if` that dropped the page index
whenever the page size changed, desyncing the highlighted page from the
data.
**Fix (frontend-only, `admin-execution.component.ts`):**
- The 1s interval now only ticks the client-side elapsed-time/status of
already-loaded rows (`updateTimeStatus()`); data is refreshed by a
decoupled **5s `switchMap`-cancelled poll** that refetches only the
current page, never touches page index/size, and updates the total only
when it actually changed.
- Rewrote `onQueryParamsChange` to read page size **and** index from
every event and clamp the page into range (order-independent) — fixing
the size-change desync and the out-of-range/empty cases.
- Split `fetchData()` (user-initiated) and `fetchCurrentPage()`
(background/silent); kill/pause/resume now trigger a prompt refresh
instead of waiting for the next tick.
**Before:** the bar flickered/reshuffled every second and could jump on
click or page-size change.
**After:** the bar is stable when idle, follows clicks, re-centers
predictably, and clamps correctly on page-size change — consistent with
the rest of the app (e.g. Admin → Users).
<img width="1918" height="870" alt="Page Bar Demo (After)"
src="https://github.com/user-attachments/assets/ee19485e-03fd-4f51-b1c3-45dd8349ce06"
/>
### Any related issues, documentation, discussions?
Closes #3586
### How was this PR tested?
**Unit tests (Vitest)** — added 8 cases in
`admin-execution.component.spec.ts` covering first/last page, page-size
change, order-independent size+index change, single-page, empty results,
and no-double-fetch on sort/filter:
corepack yarn ng test --watch=false
--include="**/admin-execution.component.spec.ts"
→ **10/10 passing** (8 new + 2 existing). Three of the new cases were
red against the old code (page-size-change desync, order-independent
size+index change, empty-results clamp) and green after the fix.
**Manual** — ran the `bin/single-node` stack + `ng serve`, seeded ~65
workflows/executions, and verified on Admin → Executions that the page
bar follows clicks, re-centers, stays idle-stable, and clamps on
page-size change; compared side-by-side against Admin → Users (built-in
pager) — now consistent.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Yes, alongside Claude Code
---
.../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();
}
}