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-8888-ea0fc10cd8478b3b7368910680bede73a3246e73 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 53114a7594c3ad9fa787b449e732a54946f5bf92 Author: Kunwoo (Chris) <[email protected]> AuthorDate: Sat Oct 10 22:17:54 2026 +0000 feat(frontend): add admin computing units dashboard (#8888) ### What changes were proposed in this PR? Problem. An admin cannot see computing units across users in the UI. On a full Kubernetes cluster they run `kubectl` and match each pod's `cuid` to its `workflow_computing_unit` row by hand. Fix. Adds an ADMIN-only Computing Units page, reached from Admin in the dashboard sidebar. It lists every active unit from the existing `GET /computing-unit/admin/list`, one row each with name, owner, type, status, creation time and a resource summary. A Kubernetes row expands to its CPU, memory, GPU, JVM memory and shared memory, while a local unit has no limits and does not expand. Type and status are filterable, status covers every state including `Failed`, `Unknown` and `Terminating`, and a failing unit's `statusReason` shows in the status tooltip. The table polls every 5 seconds with `exhaustMap`, so a response slower than the interval finishes instead of being cancelled by the next tick, and a failed poll shows a message and keeps polling. `parseDashboardUnit` now gives a `NULL` or malformed `resource` the `NaN` placeholder, so one bad row cannot break the table. ``` Before: admin -> ssh -> kubectl get pods -> match cuid to a workflow_computing_unit row After: admin -> Admin > Computing Units -> filter by status -> expand a row for its specs ``` Not included. Terminating a unit from the page (#6480), the cluster capacity panel (#6481, #6482) and the owner notification (#6483) are separate issues. ### Any related issues, documentation, discussions? Closes #6479. Part of #6476. Replaces #7356, which the stale bot closed after 37 days without review activity. This is the same change rebased onto current `main`. ### How was this PR tested? Tests were added for the page, for `listAllComputingUnits` and for the sidebar entry. They cover the poll (a failed poll, a slow response, teardown), the expander, the status filter, sorting, and a `NULL`, `"null"` or malformed `resource`. ``` cd frontend && npx ng test --watch=false --include='**/admin-computing-unit.component.spec.ts' --include='**/dashboard.component.spec.ts' --include='**/workflow-computing-unit-managing.service.spec.ts' cd frontend && npx ng test --watch=false ``` The first command passes 59 tests and the second passes 6,600 (1 skipped). `ngc -p src/tsconfig.app.json --noEmit`, `prettier-eslint` and `eslint` ran clean on the changed files. Checked in a browser against a local stack: sign in as an admin, open Admin > Computing Units, and the local unit lists with a green `Running` badge. The first request fires at once and then every 5 seconds without overlap. No Kubernetes cluster was available, so the Kubernetes rows (expand, limits, `Pending`, `Failed`) are covered by unit tests only. https://github.com/user-attachments/assets/f91ce142-1f4e-46af-8560-4706bce984a4 ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code, Claude Sonnet 5.5 --- frontend/src/app/app-routing.constant.ts | 1 + frontend/src/app/app-routing.module.ts | 5 + frontend/src/app/app.module.ts | 2 + ...orkflow-computing-unit-managing.service.spec.ts | 33 +- .../workflow-computing-unit-managing.service.ts | 33 +- .../admin-computing-unit.component.html | 139 +++++++ .../admin-computing-unit.component.scss | 64 +++ .../admin-computing-unit.component.spec.ts | 435 +++++++++++++++++++++ .../admin-computing-unit.component.ts | 209 ++++++++++ .../dashboard/component/dashboard.component.html | 11 + .../component/dashboard.component.spec.ts | 45 ++- .../app/dashboard/component/dashboard.component.ts | 2 + 12 files changed, 949 insertions(+), 30 deletions(-) diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts index 224e283a00..417e575336 100644 --- a/frontend/src/app/app-routing.constant.ts +++ b/frontend/src/app/app-routing.constant.ts @@ -90,6 +90,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 ADMIN_CU_IMAGE = `${ADMIN}/cu-image`; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 7e0fc4e411..bab01f2dd8 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -32,6 +32,7 @@ import { TexeraLoginComponent } from "./hub/component/login/texera-login.compone 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"; @@ -210,6 +211,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 ca45b08289..a5422b74a6 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -95,6 +95,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"; @@ -280,6 +281,7 @@ registerLocaleData(en); DashboardComponent, AdminUserComponent, AdminExecutionComponent, + AdminComputingUnitComponent, UserIconComponent, UserAvatarComponent, UserWorkflowComponent, 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 53983e0e88..b7aa8218bc 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 @@ -18,10 +18,12 @@ */ import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { HttpErrorResponse } from "@angular/common/http"; 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, @@ -95,11 +97,16 @@ describe("WorkflowComputingUnitManagingService", () => { }); describe("resource parsing", () => { - it("falls back to a NaN-filled resource object when the resource JSON is malformed", () => { + // A malformed, `NULL` or `"null"` resource gets the placeholder instead of crashing the readers of the unit. + it.each([ + ["malformed JSON", "not-json"], + ["null", null], + ['the string "null"', "null"], + ])("falls back to a NaN-filled resource object when the resource is %s", (_shape, resource) => { let result: any; service.getComputingUnit(5).subscribe(r => (result = r)); - httpMock.expectOne(`${api}/${COMPUTING_UNIT_BASE_URL}/5`).flush(unitWithResource("not-json")); + httpMock.expectOne(`${api}/${COMPUTING_UNIT_BASE_URL}/5`).flush(unitWithResource(resource)); expect(result.computingUnit.resource).toEqual({ cpuLimit: "NaN", @@ -154,6 +161,28 @@ 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("listAllComputingUnits() surfaces an HTTP error, e.g. a non-admin's 403, to the caller", () => { + let status: number | undefined; + service.listAllComputingUnits().subscribe({ error: (e: unknown) => (status = (e as HttpErrorResponse).status) }); + + httpMock + .expectOne(`${api}/${COMPUTING_UNIT_ADMIN_LIST_URL}`) + .flush("forbidden", { status: 403, statusText: "Forbidden" }); + + expect(status).toBe(403); + }); + 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 8c683ff440..6c4c1f0138 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", @@ -46,21 +47,26 @@ export class WorkflowComputingUnitManagingService { resource: string | WorkflowComputingUnitResourceLimit; }; + let resource: WorkflowComputingUnitResourceLimit | null = null; if (typeof cu.resource === "string") { try { - cu.resource = JSON.parse(cu.resource) as WorkflowComputingUnitResourceLimit; + resource = JSON.parse(cu.resource) as WorkflowComputingUnitResourceLimit | null; } catch { - // fall back to an empty object, so the UI never crashes - cu.resource = { - cpuLimit: "NaN", - memoryLimit: "NaN", - gpuLimit: "NaN", - jvmMemorySize: "NaN", - shmSize: "NaN", - nodeAddresses: [], - }; + // Falls back to the placeholder below. } + } else { + resource = cu.resource; } + // A `NULL` resource (only a hand-edited row has one) and the string `"null"` get the placeholder too, + // since readers use its fields unguarded. + cu.resource = resource ?? { + cpuLimit: "NaN", + memoryLimit: "NaN", + gpuLimit: "NaN", + jvmMemorySize: "NaN", + shmSize: "NaN", + nodeAddresses: [], + }; return { ...raw, computingUnit: cu }; }; @@ -187,6 +193,13 @@ export class WorkflowComputingUnitManagingService { .pipe(map(arr => arr.map(unit => this.parseDashboardUnit(unit)))); } + /** Lists every active computing unit across all users. Admin only. */ + public listAllComputingUnits(): Observable<DashboardWorkflowComputingUnit[]> { + return this.http + .get<DashboardWorkflowComputingUnit[]>(`${AppSettings.getApiEndpoint()}/${COMPUTING_UNIT_ADMIN_LIST_URL}`) + .pipe(map(arr => arr.map(unit => this.parseDashboardUnit(unit)))); + } + public getComputingUnit(cuid: number): Observable<DashboardWorkflowComputingUnit> { return this.http .get<DashboardWorkflowComputingUnit>(`${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 0000000000..49f5a60520 --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html @@ -0,0 +1,139 @@ +<!-- + 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. +--> + +<nz-card + class="section-title" + style="background-color: lightcoral"> + <h2 + class="page-title" + style="color: white"> + Computing Units + </h2> +</nz-card> + +<nz-alert + *ngIf="pollFailing" + class="refresh-warning" + nzType="warning" + nzShowIcon + [nzMessage]="refreshWarning"></nz-alert> + +<nz-table + #cuTable + nzShowSizeChanger + [nzScroll]="{ y: '500px' }" + [nzData]="computingUnits" + [nzLoading]="isLoading" + [nzLoadingIndicator]="loadingTemplate" + [nzPageSize]="20" + [nzPageSizeOptions]="[10, 20, 50, 100]" + class="computing-unit-table"> + <thead> + <tr> + <th nzWidth="40px"></th> + <th + [nzSortFn]="sortByName" + nzWidth="20%"> + Name + </th> + <th + [nzSortFn]="sortByOwner" + nzWidth="20%"> + Owner + </th> + <th + [nzSortFn]="sortByType" + [nzFilters]="typeFilters" + [nzFilterFn]="filterByType" + nzWidth="12%"> + Type + </th> + <th + [nzSortFn]="sortByStatus" + [nzFilters]="statusFilters" + [nzFilterFn]="filterByStatus" + nzWidth="12%"> + Status + </th> + <th + [nzSortFn]="sortByCreated" + nzWidth="16%"> + Created + </th> + <th nzWidth="20%">Resources</th> + </tr> + </thead> + <tbody> + <ng-container *ngFor="let unit of cuTable.data; trackBy: trackByCuid"> + <tr> + <td + [nzShowExpand]="!isLocal(unit)" + [nzExpand]="expandedCuids.has(unit.computingUnit.cuid)" + (nzExpandChange)="onExpandChange(unit.computingUnit.cuid, $event)"></td> + <td>{{ unit.computingUnit.name }}</td> + <td> + <span class="owner-cell"> + <texera-user-avatar + [avatar]="unit.ownerAvatar" + [userName]="unit.ownerName"></texera-user-avatar> + <span>{{ unit.ownerName || "Unknown" }}</span> + </span> + </td> + <td>{{ unit.computingUnit.type }}</td> + <td> + <nz-badge + [nzColor]="getBadgeColor(unit.status)" + [nz-tooltip]="getStatusTooltip(unit)" + [nzText]="unit.status"></nz-badge> + </td> + <td [nz-tooltip]="unit.computingUnit.creationTime | date: 'medium'"> + {{ formatRelativeTime(unit.computingUnit.creationTime) }} + </td> + <td>{{ resourceSummary(unit) }}</td> + </tr> + <tr *ngIf="expandedCuids.has(unit.computingUnit.cuid)"> + <td></td> + <td colspan="6"> + <dl class="spec-detail"> + <div *ngFor="let field of specFields"> + <dt>{{ field.label }}</dt> + <dd>{{ displaySpec(unit.computingUnit.resource[field.key]) }}</dd> + </div> + </dl> + </td> + </tr> + </ng-container> + </tbody> +</nz-table> + +<ng-template #refreshWarning> + <ng-container *ngIf="lastUpdated; else neverLoaded"> + Showing data from {{ lastUpdated | date: "mediumTime" }}. The refresh is failing, so some units may have changed. + </ng-container> + <ng-template #neverLoaded>Could not load computing units. The refresh is failing.</ng-template> +</ng-template> + +<ng-template #loadingTemplate> + <div class="loading-container"> + <nz-spin + nzTip="Loading..." + [nzSpinning]="true" + nzSize="large"></nz-spin> + </div> +</ng-template> 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 0000000000..c0fc77edba --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.scss @@ -0,0 +1,64 @@ +/** + * 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; +} + +.refresh-warning { + margin-bottom: 8px; +} + +.computing-unit-table { + display: block; +} + +.owner-cell { + display: flex; + align-items: center; + gap: 8px; +} + +// Expanded spec detail; 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 0000000000..ce692f4d88 --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts @@ -0,0 +1,435 @@ +/** + * 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 { HttpErrorResponse } from "@angular/common/http"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { of, Subject, throwError } from "rxjs"; +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, + WorkflowComputingUnit, + WorkflowComputingUnitResourceLimit, +} from "../../../../common/type/workflow-computing-unit"; +import { ComputingUnitState } from "../../../../common/type/computing-unit-connection.interface"; +import { commonTestImports, 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> = {}): 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", + ownerAvatar: "", + ownerName: "alice", + ...over, + }; +} + +// Overrides fields of the nested `computingUnit`, so a test need not respread it. +function withCu(over: Partial<WorkflowComputingUnit>): Partial<DashboardWorkflowComputingUnit> { + return { computingUnit: { ...makeUnit().computingUnit, ...over } }; +} + +// The all-placeholder resource a local unit carries. +const NAN_RESOURCE: WorkflowComputingUnitResourceLimit = { + cpuLimit: "NaN", + memoryLimit: "NaN", + gpuLimit: "NaN", + jvmMemorySize: "NaN", + shmSize: "NaN", + nodeAddresses: [], +}; + +function withResource(over: Partial<WorkflowComputingUnitResourceLimit>): Partial<DashboardWorkflowComputingUnit> { + return withCu({ resource: { ...makeUnit().computingUnit.resource, ...over } }); +} + +function localUnit(): DashboardWorkflowComputingUnit { + return makeUnit(withCu({ type: "local", resource: NAN_RESOURCE })); +} + +describe("AdminComputingUnitComponent", () => { + let component: AdminComputingUnitComponent; + let fixture: ComponentFixture<AdminComputingUnitComponent>; + let service: WorkflowComputingUnitManagingService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [{ provide: UserService, useClass: StubUserService }, ...commonTestProviders], + imports: [AdminComputingUnitComponent, ...commonTestImports], + }).compileComponents(); + + fixture = TestBed.createComponent(AdminComputingUnitComponent); + component = fixture.componentInstance; + service = TestBed.inject(WorkflowComputingUnitManagingService); + // No `detectChanges()` here, so the poll starts only in the tests that ask for it. + vi.spyOn(service, "listAllComputingUnits").mockReturnValue(of([])); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fixture.destroy(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + // Renders the template, so a pipe or directive missing from `imports` (say `date`) fails here, + // not only in the AOT build. + it("renders a row per unit", () => { + vi.mocked(service.listAllComputingUnits).mockReturnValue(of([makeUnit()])); + + fixture.detectChanges(); + + // `nz-table` adds a hidden measure row, so match on the owner cell every data row has. + const dataRows = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll("tbody tr")).filter( + row => row.querySelector("texera-user-avatar") !== null + ); + expect(dataRows.length).toBe(1); + expect(dataRows[0].textContent).toContain("alice"); + }); + + describe("loading and polling", () => { + // Fake timers go in before `ngOnInit`, so the poll's interval is fake too. + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(TestBed.inject(NzMessageService), "error").mockReturnValue({} as any); + }); + afterEach(() => vi.useRealTimers()); + + const failWith = (message: string) => throwError(() => new HttpErrorResponse({ error: { message }, status: 500 })); + const shownError = () => TestBed.inject(NzMessageService).error; + + it("loads every unit on init and clears the loading flag", () => { + const units = [makeUnit(), makeUnit(withCu({ cuid: 2 }))]; + vi.mocked(service.listAllComputingUnits).mockReturnValue(of(units)); + + component.ngOnInit(); + + expect(component.computingUnits).toEqual(units); + expect(component.isLoading).toBe(false); + }); + + it("shows the loading spinner while the first load is pending and hides it once it answers", () => { + const first = new Subject<DashboardWorkflowComputingUnit[]>(); + vi.mocked(service.listAllComputingUnits).mockReturnValue(first); + const spinner = () => fixture.nativeElement.querySelector(".loading-container"); + + fixture.detectChanges(); + vi.advanceTimersByTime(1); + fixture.detectChanges(); + expect(spinner()).not.toBeNull(); + + first.next([makeUnit()]); + first.complete(); + fixture.detectChanges(); + vi.advanceTimersByTime(1); + fixture.detectChanges(); + expect(spinner()).toBeNull(); + }); + + it("clears the loading flag and shows a message when the first load fails", () => { + vi.mocked(service.listAllComputingUnits).mockReturnValue(failWith("boom")); + + component.ngOnInit(); + + expect(component.isLoading).toBe(false); + expect(shownError()).toHaveBeenCalledWith("boom"); + }); + + it("refreshes on each tick, and a failed poll keeps the last data and does not stop the polling", () => { + const first = [makeUnit()]; + const second = [makeUnit(withCu({ cuid: 2 }))]; + vi.mocked(service.listAllComputingUnits) + .mockReturnValueOnce(of(first)) + .mockReturnValueOnce(failWith("boom")) + .mockReturnValueOnce(of(second)); + + component.ngOnInit(); + expect(component.computingUnits).toEqual(first); + + vi.advanceTimersByTime(5000); + expect(shownError()).toHaveBeenCalledWith("boom"); + expect(component.computingUnits).toEqual(first); + + vi.advanceTimersByTime(5000); + expect(component.computingUnits).toEqual(second); + }); + + it("toasts once while polling keeps failing, and again when it fails after a recovery", () => { + vi.mocked(service.listAllComputingUnits) + .mockReturnValueOnce(failWith("first")) + .mockReturnValueOnce(failWith("second")) + .mockReturnValueOnce(of([makeUnit()])) + .mockReturnValueOnce(failWith("third")); + + component.ngOnInit(); + expect(shownError()).toHaveBeenCalledTimes(1); + expect(shownError()).toHaveBeenLastCalledWith("first"); + + // Still failing: no second toast. + vi.advanceTimersByTime(5000); + expect(shownError()).toHaveBeenCalledTimes(1); + + // Recovers, then fails again: the next failure is announced afresh. + vi.advanceTimersByTime(5000); + expect(shownError()).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(5000); + expect(shownError()).toHaveBeenCalledTimes(2); + expect(shownError()).toHaveBeenLastCalledWith("third"); + }); + + describe("stale-data warning", () => { + const warning = () => fixture.nativeElement.querySelector("nz-alert") as HTMLElement | null; + const tickAndRender = () => { + vi.advanceTimersByTime(5000); + fixture.detectChanges(); + }; + + it("appears with the time of the last good load while a refresh fails, and clears on recovery", () => { + vi.setSystemTime(new Date(2026, 9, 10, 15, 4, 5)); + vi.mocked(service.listAllComputingUnits) + .mockReturnValueOnce(of([makeUnit()])) + .mockReturnValueOnce(failWith("boom")) + .mockReturnValueOnce(failWith("boom")) + .mockReturnValueOnce(of([makeUnit()])); + + fixture.detectChanges(); + expect(warning()).toBeNull(); + + // The warning stays up for the whole outage, even after the toast has gone. + tickAndRender(); + expect(warning()?.textContent).toContain("Showing data from 3:04:05"); + tickAndRender(); + expect(warning()?.textContent).toContain("Showing data from 3:04:05"); + + tickAndRender(); + expect(warning()).toBeNull(); + }); + + it("says the units could not be loaded when the first load fails, rather than showing an empty table as is", () => { + vi.mocked(service.listAllComputingUnits).mockReturnValue(failWith("boom")); + + fixture.detectChanges(); + vi.advanceTimersByTime(1); + fixture.detectChanges(); + + expect(warning()?.textContent).toContain("Could not load computing units"); + expect(warning()?.textContent).not.toContain("Showing data from"); + }); + }); + + // `switchMap` would cancel the slow response on every tick, so the table would never refresh. + it("lets a response slower than the interval finish instead of cancelling it", () => { + const requests: Subject<DashboardWorkflowComputingUnit[]>[] = []; + vi.mocked(service.listAllComputingUnits).mockImplementation(() => { + requests.push(new Subject()); + return requests[requests.length - 1]; + }); + + component.ngOnInit(); + vi.advanceTimersByTime(15000); + // Three ticks passed with the first request pending, and none added a request. + expect(requests).toHaveLength(1); + + const units = [makeUnit()]; + requests[0].next(units); + requests[0].complete(); + expect(component.computingUnits).toEqual(units); + }); + + it("stops polling once the component is destroyed", () => { + component.ngOnInit(); + fixture.destroy(); + vi.advanceTimersByTime(15000); + + expect(service.listAllComputingUnits).toHaveBeenCalledTimes(1); + }); + }); + + it("labels a unit whose owner has no name as Unknown", () => { + vi.mocked(service.listAllComputingUnits).mockReturnValue(of([makeUnit({ ownerName: "" })])); + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector(".owner-cell")?.textContent).toContain("Unknown"); + }); + + const specDetail = () => fixture.nativeElement.querySelector("dl.spec-detail") as HTMLElement | null; + const expander = () => fixture.nativeElement.querySelector("button.ant-table-row-expand-icon") as HTMLElement | null; + + it("shows every resource spec when a row is expanded, with the NaN placeholder as a dash", () => { + const unit = makeUnit(withResource({ jvmMemorySize: "NaN" })); + vi.mocked(service.listAllComputingUnits).mockReturnValue(of([unit])); + component.expandedCuids.add(unit.computingUnit.cuid); + + fixture.detectChanges(); + + const pairs = Array.from(specDetail()!.querySelectorAll("div")).map(d => [ + d.querySelector("dt")?.textContent, + d.querySelector("dd")?.textContent?.trim(), + ]); + expect(pairs).toEqual([ + ["CPU", "2"], + ["Memory", "4Gi"], + ["GPU", "0"], + ["JVM Memory", "—"], + ["Shared Memory", "64Mi"], + ]); + }); + + // Clicks the real expander, so the `nzExpandChange` wiring is covered. Local units have no limits and get no + // expander. + it("expands and collapses a Kubernetes row through its expander, and gives a local row none", () => { + vi.mocked(service.listAllComputingUnits).mockReturnValue(of([makeUnit()])); + fixture.detectChanges(); + + expect(specDetail()).toBeNull(); + expander()!.click(); + fixture.detectChanges(); + expect(specDetail()).not.toBeNull(); + expander()!.click(); + fixture.detectChanges(); + expect(specDetail()).toBeNull(); + + component.computingUnits = [localUnit()]; + fixture.detectChanges(); + expect(expander()).toBeNull(); + }); + + // A poll replaces every row object, so the expanded state is keyed by `cuid`, not held on the row. + it("keeps a row expanded when a poll replaces the rows with fresh objects", () => { + vi.mocked(service.listAllComputingUnits).mockReturnValue(of([makeUnit()])); + fixture.detectChanges(); + expander()!.click(); + fixture.detectChanges(); + + component.computingUnits = [makeUnit()]; + fixture.detectChanges(); + + expect(specDetail()).not.toBeNull(); + }); + + describe("resourceSummary", () => { + it("joins CPU, memory and GPU with a middot and labels", () => { + const unit = makeUnit(withResource({ 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("falls back to a dash when a non-local unit reports no usable spec", () => { + const blank = makeUnit(withCu({ resource: { ...NAN_RESOURCE, memoryLimit: "" } })); + expect(component.resourceSummary(blank)).toBe("—"); + }); + + 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("client-side sort and filter", () => { + it("sorts by name", () => { + const a = makeUnit(withCu({ name: "a" })); + const b = makeUnit(withCu({ 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(withCu({ creationTime: 1 })); + const newer = makeUnit(withCu({ creationTime: 2 })); + expect(component.sortByCreated(older, newer)).toBeLessThan(0); + }); + + it("sorts a missing name or owner as empty instead of throwing", () => { + const noName = makeUnit(withCu({ name: undefined as unknown as string })); + const noOwner = makeUnit({ ownerName: undefined as unknown as string }); + expect(component.sortByName(noName, makeUnit())).toBeLessThan(0); + expect(component.sortByName(makeUnit(), noName)).toBeGreaterThan(0); + expect(component.sortByOwner(noOwner, makeUnit())).toBeLessThan(0); + expect(component.sortByOwner(makeUnit(), noOwner)).toBeGreaterThan(0); + }); + + it("sorts by owner, type and status", () => { + expect(component.sortByOwner(makeUnit({ ownerName: "a" }), makeUnit({ ownerName: "b" }))).toBeLessThan(0); + expect(component.sortByType(makeUnit(), localUnit())).toBeLessThan(0); + expect(component.sortByStatus(makeUnit({ status: "Failed" }), makeUnit({ status: "Running" }))).toBeLessThan(0); + }); + + it("filters by type", () => { + expect(component.filterByType(["local"], localUnit())).toBe(true); + expect(component.filterByType(["local"], makeUnit())).toBe(false); + }); + + // Failed units are what an admin opens this page to reclaim, so every status must be selectable. + it("filters by each offered status, and does not offer the no-unit sentinel", () => { + const offered = component.statusFilters.map(f => f.value); + expect(offered).toContain("Failed"); + expect(offered).not.toContain(ComputingUnitState.NoComputingUnit); + for (const status of offered) { + const unit = makeUnit({ status: status as DashboardWorkflowComputingUnit["status"] }); + expect(component.filterByStatus([status], unit)).toBe(true); + expect(component.filterByStatus(["not-a-status"], unit)).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 0000000000..88a9f84b11 --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts @@ -0,0 +1,209 @@ +/** + * 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 { EMPTY, interval } from "rxjs"; +import { catchError, exhaustMap, finalize, startWith } from "rxjs/operators"; +import { DatePipe, NgFor, NgIf } from "@angular/common"; +import { + NzTableComponent, + NzTheadComponent, + NzTbodyComponent, + NzTrDirective, + NzTableCellDirective, + NzThMeasureDirective, + NzThAddOnComponent, + NzTdAddOnComponent, + NzTableSortFn, + NzTableFilterFn, +} from "ng-zorro-antd/table"; +import { NzAlertComponent } from "ng-zorro-antd/alert"; +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, + WorkflowComputingUnitResourceLimit, +} 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"; +import { ComputingUnitState } from "../../../../common/type/computing-unit-connection.interface"; + +// Same cadence as the admin executions page. +const COMPUTING_UNIT_REFRESH_INTERVAL_MS = 5000; + +// Local units have no limits, so their specs come back as this placeholder. +const NOT_APPLICABLE = "NaN"; + +const hasSpec = (value?: string) => !!value && value !== NOT_APPLICABLE; + +// All resource fields except `nodeAddresses`, which is a list. +type SpecKey = Exclude<keyof WorkflowComputingUnitResourceLimit, "nodeAddresses">; + +@UntilDestroy() +@Component({ + templateUrl: "./admin-computing-unit.component.html", + styleUrls: ["./admin-computing-unit.component.scss"], + imports: [ + NzAlertComponent, + NzCardComponent, + NzTableComponent, + NzTheadComponent, + NzTbodyComponent, + NzTrDirective, + NzTableCellDirective, + NzThMeasureDirective, + NzThAddOnComponent, + NzTdAddOnComponent, + NzBadgeComponent, + NzTooltipDirective, + NzSpinComponent, + UserAvatarComponent, + NgFor, + NgIf, + DatePipe, + ], +}) +export class AdminComputingUnitComponent implements OnInit { + computingUnits: ReadonlyArray<DashboardWorkflowComputingUnit> = []; + isLoading: boolean = true; + // True from the first failed poll until the next one succeeds. While it holds, the rows are the last good snapshot + // (or nothing, if the first poll failed), so the page warns instead of passing them off as current. + pollFailing = false; + lastUpdated?: Date; + readonly expandedCuids = new Set<number>(); + + readonly getBadgeColor = getComputingUnitBadgeColor; + readonly getStatusTooltip = getComputingUnitStatusTooltip; + readonly formatRelativeTime = formatRelativeTime; + + readonly specFields: { label: string; key: SpecKey }[] = [ + { label: "CPU", key: "cpuLimit" }, + { label: "Memory", key: "memoryLimit" }, + { label: "GPU", key: "gpuLimit" }, + { label: "JVM Memory", key: "jvmMemorySize" }, + { label: "Shared Memory", key: "shmSize" }, + ]; + + readonly typeFilters = [ + { text: "Kubernetes", value: "kubernetes" }, + { text: "Local", value: "local" }, + ]; + // Every reportable status, so an admin can filter to the `Failed` ones, but not the `NoComputingUnit` sentinel. + readonly statusFilters = Object.values(ComputingUnitState) + .filter(status => status !== ComputingUnitState.NoComputingUnit) + .map(status => ({ text: status, value: status })); + + readonly sortByName: NzTableSortFn<DashboardWorkflowComputingUnit> = (a, b) => + (a.computingUnit.name ?? "").localeCompare(b.computingUnit.name ?? ""); + readonly sortByOwner: NzTableSortFn<DashboardWorkflowComputingUnit> = (a, b) => + (a.ownerName ?? "").localeCompare(b.ownerName ?? ""); + readonly sortByType: NzTableSortFn<DashboardWorkflowComputingUnit> = (a, b) => + a.computingUnit.type.localeCompare(b.computingUnit.type); + readonly sortByStatus: NzTableSortFn<DashboardWorkflowComputingUnit> = (a, b) => a.status.localeCompare(b.status); + readonly sortByCreated: NzTableSortFn<DashboardWorkflowComputingUnit> = (a, b) => + a.computingUnit.creationTime - b.computingUnit.creationTime; + + readonly filterByType: NzTableFilterFn<DashboardWorkflowComputingUnit> = (selected: string[], unit) => + selected.includes(unit.computingUnit.type); + readonly filterByStatus: NzTableFilterFn<DashboardWorkflowComputingUnit> = (selected: string[], unit) => + selected.includes(unit.status); + + constructor( + private computingUnitService: WorkflowComputingUnitManagingService, + private messageService: NzMessageService + ) {} + + ngOnInit(): void { + // `startWith(0)` loads at once. `exhaustMap` rather than `switchMap` lets a response slower than the interval + // finish instead of being cancelled by the next tick, so a slow cluster still refreshes. `catchError` sits inside + // it, so a failed poll does not end the stream. It toasts only when polling starts failing, so a long outage does + // not raise a new toast every tick; the warning above the table covers the rest of it. + interval(COMPUTING_UNIT_REFRESH_INTERVAL_MS) + .pipe( + startWith(0), + exhaustMap(() => + this.computingUnitService.listAllComputingUnits().pipe( + catchError((err: unknown) => { + if (!this.pollFailing) { + this.messageService.error(extractErrorMessage(err)); + } + this.pollFailing = true; + return EMPTY; + }), + // Also runs after a failed load, so the spinner cannot spin forever. + finalize(() => (this.isLoading = false)) + ) + ), + untilDestroyed(this) + ) + .subscribe(units => { + this.pollFailing = false; + this.lastUpdated = new Date(); + this.computingUnits = units; + }); + } + + /** A poll replaces every row object, so key the rows by `cuid` to reuse their DOM. */ + trackByCuid(_index: number, unit: DashboardWorkflowComputingUnit): number { + return unit.computingUnit.cuid; + } + + onExpandChange(cuid: number, expanded: boolean): void { + if (expanded) { + this.expandedCuids.add(cuid); + } else { + this.expandedCuids.delete(cuid); + } + } + + isLocal(unit: DashboardWorkflowComputingUnit): boolean { + return unit.computingUnit.type === "local"; + } + + /** The Resources column, e.g. "2 CPU · 4Gi · 1 GPU", with GPU left out when it is 0. */ + resourceSummary(unit: DashboardWorkflowComputingUnit): string { + if (this.isLocal(unit)) { + return "Local — no limits"; + } + const { cpuLimit, memoryLimit, gpuLimit } = unit.computingUnit.resource; + const parts: string[] = []; + if (hasSpec(cpuLimit)) { + parts.push(`${cpuLimit} CPU`); + } + if (hasSpec(memoryLimit)) { + parts.push(memoryLimit); + } + if (hasSpec(gpuLimit) && gpuLimit !== "0") { + parts.push(`${gpuLimit} GPU`); + } + return parts.length > 0 ? parts.join(" · ") : "—"; + } + + /** Shows the placeholder or an empty spec as an em dash. */ + displaySpec(value: string): string { + return hasSpec(value) ? value : "—"; + } +} diff --git a/frontend/src/app/dashboard/component/dashboard.component.html b/frontend/src/app/dashboard/component/dashboard.component.html index d12a061193..bfa28ff02f 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.html +++ b/frontend/src/app/dashboard/component/dashboard.component.html @@ -186,6 +186,17 @@ nzType="setting"></span> <span>Executions</span> </li> + <li + nz-menu-item + nz-tooltip="View all computing units" + nzMatchRouter="true" + nzTooltipPlacement="right" + [routerLink]="ADMIN_COMPUTING_UNIT"> + <span + nz-icon + nzType="deployment-unit"></span> + <span>Computing Units</span> + </li> <li nz-menu-item nz-tooltip="Setup gmail" diff --git a/frontend/src/app/dashboard/component/dashboard.component.spec.ts b/frontend/src/app/dashboard/component/dashboard.component.spec.ts index d9d564ede8..b15cee14e7 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.spec.ts +++ b/frontend/src/app/dashboard/component/dashboard.component.spec.ts @@ -44,6 +44,7 @@ import { commonTestProviders } from "../../common/testing/test-utils"; import { GuiConfigService } from "../../common/service/gui-config.service"; import { ABOUT, + ADMIN_COMPUTING_UNIT, ADMIN_EXECUTION, ADMIN_GMAIL, ADMIN_SETTINGS, @@ -269,6 +270,7 @@ describe("DashboardComponent", () => { expect(USER_DISCUSSION).toBe("/user/discussion"); expect(ADMIN_USER).toBe("/admin/user"); expect(ADMIN_EXECUTION).toBe("/admin/execution"); + expect(ADMIN_COMPUTING_UNIT).toBe("/admin/compute"); expect(ADMIN_GMAIL).toBe("/admin/gmail"); expect(ADMIN_SETTINGS).toBe("/admin/settings"); expect(ADMIN_CU_IMAGE).toBe("/admin/cu-image"); @@ -296,17 +298,30 @@ describe("DashboardComponent", () => { }; fixture.detectChanges(); - // 7 "Your Work" links (incl. Python Venvs and Models) + 5 admin links (incl. CU Images) - // + 1 about link + 1 feedback link = 14 - expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(14); + // 7 "Your Work" links (incl. Python Venvs and Models) + 6 admin links (incl. Computing Units + // and CU Images) + 1 about link + 1 feedback link = 15 + expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(15); }); - describe("warehouse tab gating (#6933)", () => { - const warehouseMenuItem = () => - fixture.debugElement - .queryAll(By.css("li[nz-menu-item]")) - .find(de => (de.nativeElement.textContent || "").trim() === "Warehouses"); + const menuItemByLabel = (label: string) => + fixture.debugElement + .queryAll(By.css("li[nz-menu-item]")) + .find(de => (de.nativeElement.textContent || "").trim() === label); + + it("shows the Computing Units item to admins only", () => { + (userServiceMock.isLogin as Mock).mockReturnValue(true); + component.isLogin = true; + + component.isAdmin = false; + fixture.detectChanges(); + expect(menuItemByLabel("Computing Units")).toBeUndefined(); + component.isAdmin = true; + fixture.detectChanges(); + expect(menuItemByLabel("Computing Units")).toBeTruthy(); + }); + + describe("warehouse tab gating (#6933)", () => { beforeEach(() => { (userServiceMock.isLogin as Mock).mockReturnValue(true); component.isLogin = true; @@ -316,14 +331,16 @@ describe("DashboardComponent", () => { it("shows the Warehouses item only while the deployment's config enables the feature", () => { TestBed.inject(GuiConfigService).env.warehouseEnabled = false; fixture.detectChanges(); - expect(warehouseMenuItem()).toBeUndefined(); + expect(menuItemByLabel("Warehouses")).toBeUndefined(); TestBed.inject(GuiConfigService).env.warehouseEnabled = true; fixture.detectChanges(); - expect(warehouseMenuItem()).toBeTruthy(); + expect(menuItemByLabel("Warehouses")).toBeTruthy(); }); }); + // `componentInstance` is the `NzMenuItemComponent`; its `nzMatchRouter` input decides whether the item is + // highlighted on its active route. describe("sidebar active-route highlighting (#3490)", () => { const fullSidebarTabs = { hub_enabled: false, @@ -341,14 +358,6 @@ describe("DashboardComponent", () => { about_enabled: true, }; - // Find a rendered nz-menu-item <li> by its visible label; componentInstance is the - // NzMenuItemComponent, whose nzMatchRouter input decides whether it gets the - // .ant-menu-item-selected highlight on its active route. - const menuItemByLabel = (label: string) => - fixture.debugElement - .queryAll(By.css("li[nz-menu-item]")) - .find(de => (de.nativeElement.textContent || "").trim() === label); - beforeEach(() => { (userServiceMock.isLogin as Mock).mockReturnValue(true); component.isLogin = true; diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts b/frontend/src/app/dashboard/component/dashboard.component.ts index 33f6c37bc9..ee52ab65a7 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.ts +++ b/frontend/src/app/dashboard/component/dashboard.component.ts @@ -29,6 +29,7 @@ import { GuiConfigService } from "../../common/service/gui-config.service"; import { ABOUT, + ADMIN_COMPUTING_UNIT, ADMIN_EXECUTION, ADMIN_GMAIL, ADMIN_SETTINGS, @@ -130,6 +131,7 @@ export class DashboardComponent implements OnInit { protected readonly ADMIN_USER = ADMIN_USER; protected readonly ADMIN_GMAIL = ADMIN_GMAIL; protected readonly ADMIN_EXECUTION = ADMIN_EXECUTION; + protected readonly ADMIN_COMPUTING_UNIT = ADMIN_COMPUTING_UNIT; protected readonly ADMIN_SETTINGS = ADMIN_SETTINGS; protected readonly ADMIN_CU_IMAGE = ADMIN_CU_IMAGE; protected readonly ABOUT = ABOUT;
