From da41832e7ed372e25b85a4f0a7c36a7c34502937 Mon Sep 17 00:00:00 2001 From: Kunwoo Park Date: Wed, 5 Aug 2026 19:11:56 -0400 Subject: [PATCH 1/6] feat(frontend): add admin computing units dashboard Add an ADMIN-only page listing every active computing unit across all users (name, owner, type, status, created, resource summary), with an expandable row revealing full specs (CPU, memory, GPU, JVM, shared memory). Read-only. Reuses the existing WorkflowComputingUnitManagingService (new listAllComputingUnits() hitting GET /computing-unit/admin/list) and DashboardWorkflowComputingUnit type, plus the shared status-badge and relative-time formatters. Follows the existing admin dashboard pattern (nz-table, admin route + guard + nav entry). Closes #6479. --- frontend/src/app/app-routing.constant.ts | 1 + frontend/src/app/app-routing.module.ts | 5 + frontend/src/app/app.module.ts | 2 + ...ow-computing-unit-managing.service.spec.ts | 12 ++ ...orkflow-computing-unit-managing.service.ts | 11 + .../admin-computing-unit.component.html | 142 +++++++++++++ .../admin-computing-unit.component.scss | 62 ++++++ .../admin-computing-unit.component.spec.ts | 190 ++++++++++++++++++ .../admin-computing-unit.component.ts | 187 +++++++++++++++++ .../component/dashboard.component.html | 11 + .../component/dashboard.component.ts | 2 + 11 files changed, 625 insertions(+) create mode 100644 frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html create mode 100644 frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.scss create mode 100644 frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts create mode 100644 frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts index f5a01300394..54b1181825c 100644 --- a/frontend/src/app/app-routing.constant.ts +++ b/frontend/src/app/app-routing.constant.ts @@ -44,6 +44,7 @@ export const ADMIN = "/admin"; export const ADMIN_USER = `${ADMIN}/user`; export const ADMIN_GMAIL = `${ADMIN}/gmail`; export const ADMIN_EXECUTION = `${ADMIN}/execution`; +export const ADMIN_COMPUTING_UNIT = `${ADMIN}/compute`; export const ADMIN_SETTINGS = `${ADMIN}/settings`; export const SEARCH = "/search"; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 58f90143006..f35ffc1800f 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -31,6 +31,7 @@ import { AboutComponent } from "./hub/component/about/about.component"; import { AuthGuardService } from "./common/service/user/auth-guard.service"; import { AdminUserComponent } from "./dashboard/component/admin/user/admin-user.component"; import { AdminExecutionComponent } from "./dashboard/component/admin/execution/admin-execution.component"; +import { AdminComputingUnitComponent } from "./dashboard/component/admin/computing-unit/admin-computing-unit.component"; import { AdminGuardService } from "./dashboard/service/admin/guard/admin-guard.service"; import { SearchComponent } from "./dashboard/component/user/search/search.component"; import { FlarumComponent } from "./dashboard/component/user/flarum/flarum.component"; @@ -164,6 +165,10 @@ routes.push({ path: "execution", component: AdminExecutionComponent, }, + { + path: "compute", + component: AdminComputingUnitComponent, + }, { path: "settings", component: AdminSettingsComponent, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index fef2fd5aa9a..027891bc856 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -99,6 +99,7 @@ import { NzModalCommentBoxComponent } from "./workspace/component/workflow-edito import { NzCommentModule } from "ng-zorro-antd/comment"; import { AdminUserComponent } from "./dashboard/component/admin/user/admin-user.component"; import { AdminExecutionComponent } from "./dashboard/component/admin/execution/admin-execution.component"; +import { AdminComputingUnitComponent } from "./dashboard/component/admin/computing-unit/admin-computing-unit.component"; import { NzPopconfirmModule } from "ng-zorro-antd/popconfirm"; import { AdminGuardService } from "./dashboard/service/admin/guard/admin-guard.service"; import { ContextMenuComponent } from "./workspace/component/workflow-editor/context-menu/context-menu/context-menu.component"; @@ -289,6 +290,7 @@ registerLocaleData(en); DashboardComponent, AdminUserComponent, AdminExecutionComponent, + AdminComputingUnitComponent, UserIconComponent, UserAvatarComponent, LocalLoginComponent, diff --git a/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.spec.ts b/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.spec.ts index 53983e0e885..646f3227880 100644 --- a/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.spec.ts +++ b/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.spec.ts @@ -22,6 +22,7 @@ import { TestBed } from "@angular/core/testing"; import { AppSettings } from "../../../app-setting"; import { WorkflowComputingUnitManagingService, + COMPUTING_UNIT_ADMIN_LIST_URL, COMPUTING_UNIT_BASE_URL, COMPUTING_UNIT_CREATE_URL, COMPUTING_UNIT_LIST_URL, @@ -154,6 +155,17 @@ describe("WorkflowComputingUnitManagingService", () => { expect(result.map(u => u.computingUnit.resource)).toEqual([{ cpuLimit: "1" }, { cpuLimit: "2" }]); }); + it("listAllComputingUnits() GETs the admin list endpoint and parses every unit's resource", () => { + let result: any[] = []; + service.listAllComputingUnits().subscribe(r => (result = r)); + + const req = httpMock.expectOne(`${api}/${COMPUTING_UNIT_ADMIN_LIST_URL}`); + expect(req.request.method).toEqual("GET"); + req.flush([unitWithResource('{"cpuLimit":"1"}'), unitWithResource('{"cpuLimit":"2"}')]); + + expect(result.map(u => u.computingUnit.resource)).toEqual([{ cpuLimit: "1" }, { cpuLimit: "2" }]); + }); + it("renameComputingUnit() PUTs to a URI-encoded rename endpoint", () => { service.renameComputingUnit(3, "my unit/name").subscribe(); diff --git a/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.ts b/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.ts index 58768bb346d..33c6d105d3f 100644 --- a/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.ts +++ b/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.ts @@ -33,6 +33,7 @@ export const COMPUTING_UNIT_BASE_URL = "computing-unit"; export const COMPUTING_UNIT_CREATE_URL = `${COMPUTING_UNIT_BASE_URL}/create`; export const COMPUTING_UNIT_LIST_URL = `${COMPUTING_UNIT_BASE_URL}`; export const COMPUTING_UNIT_TYPES_URL = `${COMPUTING_UNIT_BASE_URL}/types`; +export const COMPUTING_UNIT_ADMIN_LIST_URL = `${COMPUTING_UNIT_BASE_URL}/admin/list`; @Injectable({ providedIn: "root", @@ -173,6 +174,16 @@ export class WorkflowComputingUnitManagingService { .pipe(map(arr => arr.map(unit => this.parseDashboardUnit(unit)))); } + /** + * List every active computing unit across all users. ADMIN only. + * @returns An Observable of a list of DashboardWorkflowComputingUnit. + */ + public listAllComputingUnits(): Observable { + return this.http + .get(`${AppSettings.getApiEndpoint()}/${COMPUTING_UNIT_ADMIN_LIST_URL}`) + .pipe(map(arr => arr.map(unit => this.parseDashboardUnit(unit)))); + } + public getComputingUnit(cuid: number): Observable { return this.http .get(`${AppSettings.getApiEndpoint()}/${COMPUTING_UNIT_BASE_URL}/${cuid}`) diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html new file mode 100644 index 00000000000..ac97a2a61c5 --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html @@ -0,0 +1,142 @@ + + + +

+ Computing Units +

+
+ + + + + + + Name + + + Owner + + + Type + + + Status + + + Created + + Resources + + + + + + + {{ unit.computingUnit.name }} + + + + {{ unit.ownerName || "Unknown" }} + + + {{ unit.computingUnit.type }} + + + + + {{ formatRelativeTime(unit.computingUnit.creationTime) }} + + {{ resourceSummary(unit) }} + + + + +
+
+
CPU
+
{{ displaySpec(unit.computingUnit.resource.cpuLimit) }}
+
+
+
Memory
+
{{ displaySpec(unit.computingUnit.resource.memoryLimit) }}
+
+
+
GPU
+
{{ displaySpec(unit.computingUnit.resource.gpuLimit) }}
+
+
+
JVM Memory
+
{{ displaySpec(unit.computingUnit.resource.jvmMemorySize) }}
+
+
+
Shared Memory
+
{{ displaySpec(unit.computingUnit.resource.shmSize) }}
+
+
+ + +
+ +
+ + +
+ +
+
diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.scss b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.scss new file mode 100644 index 00000000000..4a9eb1c7d2d --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.scss @@ -0,0 +1,62 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.loading-container { + display: flex; + justify-content: center; + flex-direction: column; + height: 300px; +} + +.computing-unit-table { + display: block; + grid-row-start: 3; + grid-row-end: 4; +} + +.owner-cell { + display: flex; + align-items: center; + gap: 8px; +} + +// The expanded spec detail: a compact label/value grid that wraps on narrow viewports. +.spec-detail { + display: flex; + flex-wrap: wrap; + gap: 8px 32px; + margin: 0; + padding: 4px 0; + + div { + display: flex; + flex-direction: column; + } + + dt { + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + font-weight: 400; + } + + dd { + margin: 0; + font-variant-numeric: tabular-nums; + } +} diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts new file mode 100644 index 00000000000..47a99825b07 --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts @@ -0,0 +1,190 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { of } from "rxjs"; +import { AdminComputingUnitComponent } from "./admin-computing-unit.component"; +import { WorkflowComputingUnitManagingService } from "../../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service"; +import { DashboardWorkflowComputingUnit } from "../../../../common/type/workflow-computing-unit"; +import { commonTestProviders } from "../../../../common/testing/test-utils"; +import { UserService } from "../../../../common/service/user/user.service"; +import { StubUserService } from "../../../../common/service/user/stub-user.service"; + +function makeUnit(over: Partial = {}): DashboardWorkflowComputingUnit { + return { + computingUnit: { + cuid: 1, + uid: 100, + name: "cu", + creationTime: 1_700_000_000_000, + terminateTime: undefined, + type: "kubernetes", + uri: "uri", + resource: { + cpuLimit: "2", + memoryLimit: "4Gi", + gpuLimit: "0", + jvmMemorySize: "2G", + shmSize: "64Mi", + nodeAddresses: [], + }, + }, + status: "Running", + metrics: { cpuUsage: "NaN", memoryUsage: "NaN" }, + isOwner: false, + accessPrivilege: "WRITE", + ownerGoogleAvatar: "", + ownerName: "alice", + ...over, + }; +} + +function localUnit(): DashboardWorkflowComputingUnit { + return makeUnit({ + computingUnit: { + ...makeUnit().computingUnit, + type: "local", + resource: { + cpuLimit: "NaN", + memoryLimit: "NaN", + gpuLimit: "NaN", + jvmMemorySize: "NaN", + shmSize: "NaN", + nodeAddresses: [], + }, + }, + }); +} + +describe("AdminComputingUnitComponent", () => { + let component: AdminComputingUnitComponent; + let fixture: ComponentFixture; + let service: WorkflowComputingUnitManagingService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + WorkflowComputingUnitManagingService, + { provide: UserService, useClass: StubUserService }, + ...commonTestProviders, + ], + imports: [AdminComputingUnitComponent, HttpClientTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(AdminComputingUnitComponent); + component = fixture.componentInstance; + service = TestBed.inject(WorkflowComputingUnitManagingService); + // Keep the fetch inert/synchronous; deliberately no detectChanges() so ngOnInit's poll never starts. + vi.spyOn(service, "listAllComputingUnits").mockReturnValue(of([])); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fixture.destroy(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("fetchData loads all units and clears the loading flag", () => { + const units = [makeUnit(), makeUnit({ computingUnit: { ...makeUnit().computingUnit, cuid: 2 } })]; + vi.mocked(service.listAllComputingUnits).mockReturnValue(of(units)); + + component.fetchData(); + + expect(component.computingUnits).toEqual(units); + expect(component.isLoading).toBe(false); + }); + + describe("resourceSummary", () => { + it("joins CPU, memory and GPU with a middot and labels", () => { + const unit = makeUnit({ + computingUnit: { + ...makeUnit().computingUnit, + resource: { ...makeUnit().computingUnit.resource, gpuLimit: "1" }, + }, + }); + expect(component.resourceSummary(unit)).toBe("2 CPU · 4Gi · 1 GPU"); + }); + + it("omits GPU when there is none", () => { + expect(component.resourceSummary(makeUnit())).toBe("2 CPU · 4Gi"); + }); + + it("shows a no-limits message for local units", () => { + expect(component.resourceSummary(localUnit())).toBe("Local — no limits"); + }); + }); + + describe("displaySpec", () => { + it("renders a real value unchanged", () => { + expect(component.displaySpec("2Gi")).toBe("2Gi"); + }); + + it("renders NaN and empty as an em dash", () => { + expect(component.displaySpec("NaN")).toBe("—"); + expect(component.displaySpec("")).toBe("—"); + }); + }); + + describe("isLocal", () => { + it("is true only for local units", () => { + expect(component.isLocal(localUnit())).toBe(true); + expect(component.isLocal(makeUnit())).toBe(false); + }); + }); + + describe("onExpandChange", () => { + it("adds and removes a cuid from the expanded set", () => { + component.onExpandChange(7, true); + expect(component.expandedCuids.has(7)).toBe(true); + + component.onExpandChange(7, false); + expect(component.expandedCuids.has(7)).toBe(false); + }); + }); + + describe("client-side sort and filter", () => { + it("sorts by name", () => { + const a = makeUnit({ computingUnit: { ...makeUnit().computingUnit, name: "a" } }); + const b = makeUnit({ computingUnit: { ...makeUnit().computingUnit, name: "b" } }); + expect(component.sortByName(a, b)).toBeLessThan(0); + expect(component.sortByName(b, a)).toBeGreaterThan(0); + }); + + it("sorts by creation time numerically", () => { + const older = makeUnit({ computingUnit: { ...makeUnit().computingUnit, creationTime: 1 } }); + const newer = makeUnit({ computingUnit: { ...makeUnit().computingUnit, creationTime: 2 } }); + expect(component.sortByCreated(older, newer)).toBeLessThan(0); + }); + + it("filters by type", () => { + expect(component.filterByType(["local"], localUnit())).toBe(true); + expect(component.filterByType(["local"], makeUnit())).toBe(false); + }); + + it("filters by status", () => { + const pending = makeUnit({ status: "Pending" }); + expect(component.filterByStatus(["Pending"], pending)).toBe(true); + expect(component.filterByStatus(["Pending"], makeUnit())).toBe(false); + }); + }); +}); diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts new file mode 100644 index 00000000000..83024fff7c1 --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts @@ -0,0 +1,187 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, OnInit } from "@angular/core"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { interval } from "rxjs"; +import { switchMap } from "rxjs/operators"; +import { NgFor, NgIf } from "@angular/common"; +import { + NzTableComponent, + NzTheadComponent, + NzTbodyComponent, + NzTrDirective, + NzTableCellDirective, + NzThMeasureDirective, + NzThAddOnComponent, + NzTdAddOnComponent, + NzTableSortFn, + NzTableFilterFn, +} from "ng-zorro-antd/table"; +import { NzCardComponent } from "ng-zorro-antd/card"; +import { NzBadgeComponent } from "ng-zorro-antd/badge"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; +import { NzSpinComponent } from "ng-zorro-antd/spin"; +import { WorkflowComputingUnitManagingService } from "../../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service"; +import { DashboardWorkflowComputingUnit } from "../../../../common/type/workflow-computing-unit"; +import { getComputingUnitBadgeColor, getComputingUnitStatusTooltip } from "../../../../common/util/computing-unit.util"; +import { formatRelativeTime } from "../../../../common/util/format.util"; +import { UserAvatarComponent } from "../../user/user-avatar/user-avatar.component"; + +// How often the table refreshes so live status (Pending -> Running) and newly created +// units stay fresh, matching the poll cadence of the admin executions page. +const COMPUTING_UNIT_REFRESH_INTERVAL_MS = 5000; + +// A computing unit's specs are "NaN" placeholders for local units, which have no limits. +const NOT_APPLICABLE = "NaN"; + +@UntilDestroy() +@Component({ + templateUrl: "./admin-computing-unit.component.html", + styleUrls: ["./admin-computing-unit.component.scss"], + imports: [ + NzCardComponent, + NzTableComponent, + NzTheadComponent, + NzTbodyComponent, + NzTrDirective, + NzTableCellDirective, + NzThMeasureDirective, + NzThAddOnComponent, + NzTdAddOnComponent, + NzBadgeComponent, + NzTooltipDirective, + NzSpinComponent, + UserAvatarComponent, + NgFor, + NgIf, + ], +}) +export class AdminComputingUnitComponent implements OnInit { + computingUnits: ReadonlyArray = []; + isLoading: boolean = true; + // cuids of rows whose spec detail is expanded. + readonly expandedCuids = new Set(); + + // Expose the shared formatters to the template. + readonly getBadgeColor = getComputingUnitBadgeColor; + readonly getStatusTooltip = getComputingUnitStatusTooltip; + readonly formatRelativeTime = formatRelativeTime; + + readonly typeFilters = [ + { text: "Kubernetes", value: "kubernetes" }, + { text: "Local", value: "local" }, + ]; + readonly statusFilters = [ + { text: "Running", value: "Running" }, + { text: "Pending", value: "Pending" }, + ]; + + readonly sortByName: NzTableSortFn = (a, b) => + (a.computingUnit.name ?? "").localeCompare(b.computingUnit.name ?? ""); + readonly sortByOwner: NzTableSortFn = (a, b) => + (a.ownerName ?? "").localeCompare(b.ownerName ?? ""); + readonly sortByType: NzTableSortFn = (a, b) => + a.computingUnit.type.localeCompare(b.computingUnit.type); + readonly sortByStatus: NzTableSortFn = (a, b) => a.status.localeCompare(b.status); + readonly sortByCreated: NzTableSortFn = (a, b) => + a.computingUnit.creationTime - b.computingUnit.creationTime; + + readonly filterByType: NzTableFilterFn = (selected: string[], unit) => + selected.includes(unit.computingUnit.type); + readonly filterByStatus: NzTableFilterFn = (selected: string[], unit) => + selected.includes(unit.status); + + constructor(private computingUnitService: WorkflowComputingUnitManagingService) {} + + ngOnInit(): void { + this.fetchData(); + + // Refresh so status changes and new units surface without a manual reload; switchMap + // drops a stale in-flight request if the interval fires again before it resolves. + interval(COMPUTING_UNIT_REFRESH_INTERVAL_MS) + .pipe( + switchMap(() => this.computingUnitService.listAllComputingUnits()), + untilDestroyed(this) + ) + .subscribe(units => (this.computingUnits = units)); + } + + /** + * Load every computing unit once, showing the loading indicator (used on init only, so + * the background poll never flashes the spinner over an already-populated table). + */ + fetchData(): void { + this.isLoading = true; + this.computingUnitService + .listAllComputingUnits() + .pipe(untilDestroyed(this)) + .subscribe(units => { + this.computingUnits = units; + this.isLoading = false; + }); + } + + /** + * Toggle whether a row's full spec detail is shown. + */ + onExpandChange(cuid: number, expanded: boolean): void { + if (expanded) { + this.expandedCuids.add(cuid); + } else { + this.expandedCuids.delete(cuid); + } + } + + /** + * A local unit has no resource limits (every spec is the "NaN" placeholder). + */ + isLocal(unit: DashboardWorkflowComputingUnit): boolean { + return unit.computingUnit.type === "local"; + } + + /** + * One-line "size" summary shown in the table's Resources column, e.g. "2 CPU · 4Gi · 1 GPU". + * GPU is omitted when there is none. Local units have no limits. + */ + resourceSummary(unit: DashboardWorkflowComputingUnit): string { + if (this.isLocal(unit)) { + return "Local — no limits"; + } + const { cpuLimit, memoryLimit, gpuLimit } = unit.computingUnit.resource; + const parts: string[] = []; + if (cpuLimit && cpuLimit !== NOT_APPLICABLE) { + parts.push(`${cpuLimit} CPU`); + } + if (memoryLimit && memoryLimit !== NOT_APPLICABLE) { + parts.push(memoryLimit); + } + if (gpuLimit && gpuLimit !== NOT_APPLICABLE && gpuLimit !== "0") { + parts.push(`${gpuLimit} GPU`); + } + return parts.length > 0 ? parts.join(" · ") : "—"; + } + + /** + * Present a spec value, rendering the "NaN" placeholder as an em dash. + */ + displaySpec(value: string): string { + return !value || value === NOT_APPLICABLE ? "—" : value; + } +} diff --git a/frontend/src/app/dashboard/component/dashboard.component.html b/frontend/src/app/dashboard/component/dashboard.component.html index 6f074d692e5..2267ba3f442 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.html +++ b/frontend/src/app/dashboard/component/dashboard.component.html @@ -175,6 +175,17 @@ nzType="setting"> Executions +
  • + + Computing Units +
  • Date: Wed, 5 Aug 2026 21:45:08 -0400 Subject: [PATCH 2/6] fix(frontend): import DatePipe in the admin computing units dashboard The dashboard's template pipes creationTime through `date` for the created-at tooltip, but the standalone component imported only NgFor and NgIf from @angular/common. AOT compilation therefore failed with NG8004 ("No pipe found with name 'date'"), and since a failed build emits no index.html, `ng serve` answered every request with a 404 rather than serving the app. The existing specs did not catch this: none of them render the template, so JIT never resolved the pipe. Add the one render test that mounts a row, which fails with NG0302 without the import and passes with it, so a pipe or directive missing from `imports` is caught by the unit tests instead of only by the app build. --- .../admin-computing-unit.component.spec.ts | 17 +++++++++++++++++ .../admin-computing-unit.component.ts | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts index 47a99825b07..f1027373f0f 100644 --- a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts @@ -104,6 +104,23 @@ describe("AdminComputingUnitComponent", () => { expect(component).toBeTruthy(); }); + // The only test that renders the template. Every binding a row uses — including the + // `date` pipe on the creation-time tooltip — resolves here and nowhere else, so a pipe or + // directive missing from the component's `imports` fails this test instead of only the + // AOT app build (which the other specs, deliberately render-free, never exercise). + it("renders a row per unit", () => { + vi.mocked(service.listAllComputingUnits).mockReturnValue(of([makeUnit()])); + + fixture.detectChanges(); + + // nz-table adds a hidden measure row to tbody, so match on the owner cell every data row has. + const dataRows = Array.from(fixture.nativeElement.querySelectorAll("tbody tr")).filter( + row => row.querySelector("texera-user-avatar") !== null + ); + expect(dataRows.length).toBe(1); + expect(dataRows[0].textContent).toContain("alice"); + }); + it("fetchData loads all units and clears the loading flag", () => { const units = [makeUnit(), makeUnit({ computingUnit: { ...makeUnit().computingUnit, cuid: 2 } })]; vi.mocked(service.listAllComputingUnits).mockReturnValue(of(units)); diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts index 83024fff7c1..bc03109741f 100644 --- a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts @@ -21,7 +21,7 @@ import { Component, OnInit } from "@angular/core"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { interval } from "rxjs"; import { switchMap } from "rxjs/operators"; -import { NgFor, NgIf } from "@angular/common"; +import { DatePipe, NgFor, NgIf } from "@angular/common"; import { NzTableComponent, NzTheadComponent, @@ -71,6 +71,7 @@ const NOT_APPLICABLE = "NaN"; UserAvatarComponent, NgFor, NgIf, + DatePipe, ], }) export class AdminComputingUnitComponent implements OnInit { From 83e44d771c16a47b88c64b7f1319763466d07b48 Mon Sep 17 00:00:00 2001 From: Kunwoo Park Date: Wed, 5 Aug 2026 21:56:20 -0400 Subject: [PATCH 3/6] refactor(frontend): trackBy on the admin CU table, drop dead grid CSS --- .../computing-unit/admin-computing-unit.component.html | 2 +- .../computing-unit/admin-computing-unit.component.scss | 2 -- .../computing-unit/admin-computing-unit.component.ts | 8 ++++++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html index ac97a2a61c5..aa93414bac6 100644 --- a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html @@ -73,7 +73,7 @@ - + Date: Wed, 5 Aug 2026 22:21:58 -0400 Subject: [PATCH 4/6] fix(frontend): handle admin CU fetch errors, drop owner-relative badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface fetch/poll errors via NzMessageService and clear the loading flag so a failed load stops the spinner instead of spinning forever; catch poll errors inside switchMap so one failure does not terminate the refresh interval. Drop the isOwner star on the owner avatar — isOwner is relative to the requesting admin, so it is meaningless in an all-users view. --- .../admin-computing-unit.component.html | 3 +- .../admin-computing-unit.component.spec.ts | 16 +++++++- .../admin-computing-unit.component.ts | 39 +++++++++++++++---- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html index aa93414bac6..ebc7a71b7e0 100644 --- a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html @@ -84,8 +84,7 @@ + [userName]="unit.ownerName"> {{ unit.ownerName || "Unknown" }} diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts index f1027373f0f..f1fc1b641e5 100644 --- a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts @@ -19,7 +19,8 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { of } from "rxjs"; +import { of, throwError } from "rxjs"; +import { NzMessageModule, NzMessageService } from "ng-zorro-antd/message"; import { AdminComputingUnitComponent } from "./admin-computing-unit.component"; import { WorkflowComputingUnitManagingService } from "../../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service"; import { DashboardWorkflowComputingUnit } from "../../../../common/type/workflow-computing-unit"; @@ -85,7 +86,7 @@ describe("AdminComputingUnitComponent", () => { { provide: UserService, useClass: StubUserService }, ...commonTestProviders, ], - imports: [AdminComputingUnitComponent, HttpClientTestingModule], + imports: [AdminComputingUnitComponent, HttpClientTestingModule, NzMessageModule], }).compileComponents(); fixture = TestBed.createComponent(AdminComputingUnitComponent); @@ -131,6 +132,17 @@ describe("AdminComputingUnitComponent", () => { expect(component.isLoading).toBe(false); }); + it("fetchData clears the loading flag and shows a message when the fetch fails", () => { + const errorSpy = vi.spyOn(TestBed.inject(NzMessageService), "error").mockReturnValue({} as any); + vi.mocked(service.listAllComputingUnits).mockReturnValue(throwError(() => ({ error: { message: "boom" } }))); + + component.fetchData(); + + // On error the spinner must stop rather than spin forever, and the failure is surfaced. + expect(component.isLoading).toBe(false); + expect(errorSpy).toHaveBeenCalledWith("boom"); + }); + describe("resourceSummary", () => { it("joins CPU, memory and GPU with a middot and labels", () => { const unit = makeUnit({ diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts index f56fae27606..a1bb6c92525 100644 --- a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts @@ -19,8 +19,8 @@ import { Component, OnInit } from "@angular/core"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { interval } from "rxjs"; -import { switchMap } from "rxjs/operators"; +import { EMPTY, interval } from "rxjs"; +import { catchError, switchMap } from "rxjs/operators"; import { DatePipe, NgFor, NgIf } from "@angular/common"; import { NzTableComponent, @@ -38,6 +38,7 @@ import { NzCardComponent } from "ng-zorro-antd/card"; import { NzBadgeComponent } from "ng-zorro-antd/badge"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { NzSpinComponent } from "ng-zorro-antd/spin"; +import { NzMessageService } from "ng-zorro-antd/message"; import { WorkflowComputingUnitManagingService } from "../../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service"; import { DashboardWorkflowComputingUnit } from "../../../../common/type/workflow-computing-unit"; import { getComputingUnitBadgeColor, getComputingUnitStatusTooltip } from "../../../../common/util/computing-unit.util"; @@ -109,16 +110,27 @@ export class AdminComputingUnitComponent implements OnInit { readonly filterByStatus: NzTableFilterFn = (selected: string[], unit) => selected.includes(unit.status); - constructor(private computingUnitService: WorkflowComputingUnitManagingService) {} + constructor( + private computingUnitService: WorkflowComputingUnitManagingService, + private messageService: NzMessageService + ) {} ngOnInit(): void { this.fetchData(); // Refresh so status changes and new units surface without a manual reload; switchMap - // drops a stale in-flight request if the interval fires again before it resolves. + // drops a stale in-flight request if the interval fires again before it resolves. A failed + // poll is caught inside switchMap so one error does not terminate the interval. interval(COMPUTING_UNIT_REFRESH_INTERVAL_MS) .pipe( - switchMap(() => this.computingUnitService.listAllComputingUnits()), + switchMap(() => + this.computingUnitService.listAllComputingUnits().pipe( + catchError(err => { + this.messageService.error(this.errorMessage(err)); + return EMPTY; + }) + ) + ), untilDestroyed(this) ) .subscribe(units => (this.computingUnits = units)); @@ -133,12 +145,23 @@ export class AdminComputingUnitComponent implements OnInit { this.computingUnitService .listAllComputingUnits() .pipe(untilDestroyed(this)) - .subscribe(units => { - this.computingUnits = units; - this.isLoading = false; + .subscribe({ + next: units => { + this.computingUnits = units; + this.isLoading = false; + }, + error: err => { + // Clear the spinner so the table does not spin forever on a failed load. + this.isLoading = false; + this.messageService.error(this.errorMessage(err)); + }, }); } + private errorMessage(err: unknown): string { + return (err as { error?: { message?: string } }).error?.message || (err as Error).message; + } + /** * Track rows by cuid so a poll (which replaces every row object) reuses DOM instead of * rebuilding each row's avatar/badge/tooltip every 5s. From 2425439bfcf3115ce60b62e3f071e6ac3027f2d8 Mon Sep 17 00:00:00 2001 From: Kunwoo Park Date: Wed, 5 Aug 2026 22:36:47 -0400 Subject: [PATCH 5/6] refactor(frontend): reuse extractErrorMessage, trim comments to essentials --- .../admin-computing-unit.component.spec.ts | 10 +++---- .../admin-computing-unit.component.ts | 30 +++++++------------ 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts index f1fc1b641e5..da5cafbf5c6 100644 --- a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts @@ -20,7 +20,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { of, throwError } from "rxjs"; -import { NzMessageModule, NzMessageService } from "ng-zorro-antd/message"; +import { NzMessageService } from "ng-zorro-antd/message"; import { AdminComputingUnitComponent } from "./admin-computing-unit.component"; import { WorkflowComputingUnitManagingService } from "../../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service"; import { DashboardWorkflowComputingUnit } from "../../../../common/type/workflow-computing-unit"; @@ -86,7 +86,7 @@ describe("AdminComputingUnitComponent", () => { { provide: UserService, useClass: StubUserService }, ...commonTestProviders, ], - imports: [AdminComputingUnitComponent, HttpClientTestingModule, NzMessageModule], + imports: [AdminComputingUnitComponent, HttpClientTestingModule], }).compileComponents(); fixture = TestBed.createComponent(AdminComputingUnitComponent); @@ -105,10 +105,8 @@ describe("AdminComputingUnitComponent", () => { expect(component).toBeTruthy(); }); - // The only test that renders the template. Every binding a row uses — including the - // `date` pipe on the creation-time tooltip — resolves here and nowhere else, so a pipe or - // directive missing from the component's `imports` fails this test instead of only the - // AOT app build (which the other specs, deliberately render-free, never exercise). + // The only test that renders the template, so a pipe/directive missing from the component's + // `imports` (e.g. the `date` pipe) fails here instead of only in the AOT app build. it("renders a row per unit", () => { vi.mocked(service.listAllComputingUnits).mockReturnValue(of([makeUnit()])); diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts index a1bb6c92525..9822f281295 100644 --- a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts @@ -43,13 +43,13 @@ import { WorkflowComputingUnitManagingService } from "../../../../common/service import { DashboardWorkflowComputingUnit } from "../../../../common/type/workflow-computing-unit"; import { getComputingUnitBadgeColor, getComputingUnitStatusTooltip } from "../../../../common/util/computing-unit.util"; import { formatRelativeTime } from "../../../../common/util/format.util"; +import { extractErrorMessage } from "../../../../common/util/error"; import { UserAvatarComponent } from "../../user/user-avatar/user-avatar.component"; -// How often the table refreshes so live status (Pending -> Running) and newly created -// units stay fresh, matching the poll cadence of the admin executions page. +// Poll cadence for live status, matching the admin executions page. const COMPUTING_UNIT_REFRESH_INTERVAL_MS = 5000; -// A computing unit's specs are "NaN" placeholders for local units, which have no limits. +// Local units have no limits; their specs come back as this placeholder. const NOT_APPLICABLE = "NaN"; @UntilDestroy() @@ -118,15 +118,14 @@ export class AdminComputingUnitComponent implements OnInit { ngOnInit(): void { this.fetchData(); - // Refresh so status changes and new units surface without a manual reload; switchMap - // drops a stale in-flight request if the interval fires again before it resolves. A failed - // poll is caught inside switchMap so one error does not terminate the interval. + // switchMap drops a stale in-flight request; catchError is inside it so one failed poll + // shows a message but does not terminate the interval. interval(COMPUTING_UNIT_REFRESH_INTERVAL_MS) .pipe( switchMap(() => this.computingUnitService.listAllComputingUnits().pipe( catchError(err => { - this.messageService.error(this.errorMessage(err)); + this.messageService.error(extractErrorMessage(err)); return EMPTY; }) ) @@ -137,8 +136,7 @@ export class AdminComputingUnitComponent implements OnInit { } /** - * Load every computing unit once, showing the loading indicator (used on init only, so - * the background poll never flashes the spinner over an already-populated table). + * Initial load, with the spinner. Only init calls this, so the poll never re-flashes it. */ fetchData(): void { this.isLoading = true; @@ -151,20 +149,15 @@ export class AdminComputingUnitComponent implements OnInit { this.isLoading = false; }, error: err => { - // Clear the spinner so the table does not spin forever on a failed load. + // Clear the spinner so a failed load doesn't spin forever. this.isLoading = false; - this.messageService.error(this.errorMessage(err)); + this.messageService.error(extractErrorMessage(err)); }, }); } - private errorMessage(err: unknown): string { - return (err as { error?: { message?: string } }).error?.message || (err as Error).message; - } - /** - * Track rows by cuid so a poll (which replaces every row object) reuses DOM instead of - * rebuilding each row's avatar/badge/tooltip every 5s. + * Track by cuid so the 5s poll (which replaces every row object) reuses row DOM. */ trackByCuid(_index: number, unit: DashboardWorkflowComputingUnit): number { return unit.computingUnit.cuid; @@ -189,8 +182,7 @@ export class AdminComputingUnitComponent implements OnInit { } /** - * One-line "size" summary shown in the table's Resources column, e.g. "2 CPU · 4Gi · 1 GPU". - * GPU is omitted when there is none. Local units have no limits. + * The Resources-column summary, e.g. "2 CPU · 4Gi · 1 GPU" (GPU omitted when none). */ resourceSummary(unit: DashboardWorkflowComputingUnit): string { if (this.isLocal(unit)) { From dcf1c94d0175a0eeff5bd8e9c33c803850351dc0 Mon Sep 17 00:00:00 2001 From: Kunwoo Park Date: Thu, 6 Aug 2026 11:23:39 -0400 Subject: [PATCH 6/6] fix(frontend): type catch/error callbacks as unknown for rxjs lint The rxjs/no-implicit-any-catch rule (run in format:ci) requires an explicit `: unknown` on catchError and subscribe error callbacks, and crashes on an untyped parameter. Annotate both to match the codebase convention. --- .../admin/computing-unit/admin-computing-unit.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts index 9822f281295..721b290f30b 100644 --- a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts @@ -124,7 +124,7 @@ export class AdminComputingUnitComponent implements OnInit { .pipe( switchMap(() => this.computingUnitService.listAllComputingUnits().pipe( - catchError(err => { + catchError((err: unknown) => { this.messageService.error(extractErrorMessage(err)); return EMPTY; }) @@ -148,7 +148,7 @@ export class AdminComputingUnitComponent implements OnInit { this.computingUnits = units; this.isLoading = false; }, - error: err => { + error: (err: unknown) => { // Clear the spinner so a failed load doesn't spin forever. this.isLoading = false; this.messageService.error(extractErrorMessage(err));