This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 53114a7594 feat(frontend): add admin computing units dashboard (#8888)
53114a7594 is described below
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;