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;

Reply via email to