diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts index f5a01300394..54b1181825c 100644 --- a/frontend/src/app/app-routing.constant.ts +++ b/frontend/src/app/app-routing.constant.ts @@ -44,6 +44,7 @@ export const ADMIN = "/admin"; export const ADMIN_USER = `${ADMIN}/user`; export const ADMIN_GMAIL = `${ADMIN}/gmail`; export const ADMIN_EXECUTION = `${ADMIN}/execution`; +export const ADMIN_COMPUTING_UNIT = `${ADMIN}/compute`; export const ADMIN_SETTINGS = `${ADMIN}/settings`; export const SEARCH = "/search"; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 58f90143006..f35ffc1800f 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -31,6 +31,7 @@ import { AboutComponent } from "./hub/component/about/about.component"; import { AuthGuardService } from "./common/service/user/auth-guard.service"; import { AdminUserComponent } from "./dashboard/component/admin/user/admin-user.component"; import { AdminExecutionComponent } from "./dashboard/component/admin/execution/admin-execution.component"; +import { AdminComputingUnitComponent } from "./dashboard/component/admin/computing-unit/admin-computing-unit.component"; import { AdminGuardService } from "./dashboard/service/admin/guard/admin-guard.service"; import { SearchComponent } from "./dashboard/component/user/search/search.component"; import { FlarumComponent } from "./dashboard/component/user/flarum/flarum.component"; @@ -164,6 +165,10 @@ routes.push({ path: "execution", component: AdminExecutionComponent, }, + { + path: "compute", + component: AdminComputingUnitComponent, + }, { path: "settings", component: AdminSettingsComponent, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index fef2fd5aa9a..027891bc856 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -99,6 +99,7 @@ import { NzModalCommentBoxComponent } from "./workspace/component/workflow-edito import { NzCommentModule } from "ng-zorro-antd/comment"; import { AdminUserComponent } from "./dashboard/component/admin/user/admin-user.component"; import { AdminExecutionComponent } from "./dashboard/component/admin/execution/admin-execution.component"; +import { AdminComputingUnitComponent } from "./dashboard/component/admin/computing-unit/admin-computing-unit.component"; import { NzPopconfirmModule } from "ng-zorro-antd/popconfirm"; import { AdminGuardService } from "./dashboard/service/admin/guard/admin-guard.service"; import { ContextMenuComponent } from "./workspace/component/workflow-editor/context-menu/context-menu/context-menu.component"; @@ -289,6 +290,7 @@ registerLocaleData(en); DashboardComponent, AdminUserComponent, AdminExecutionComponent, + AdminComputingUnitComponent, UserIconComponent, UserAvatarComponent, LocalLoginComponent, diff --git a/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.spec.ts b/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.spec.ts index 53983e0e885..646f3227880 100644 --- a/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.spec.ts +++ b/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.spec.ts @@ -22,6 +22,7 @@ import { TestBed } from "@angular/core/testing"; import { AppSettings } from "../../../app-setting"; import { WorkflowComputingUnitManagingService, + COMPUTING_UNIT_ADMIN_LIST_URL, COMPUTING_UNIT_BASE_URL, COMPUTING_UNIT_CREATE_URL, COMPUTING_UNIT_LIST_URL, @@ -154,6 +155,17 @@ describe("WorkflowComputingUnitManagingService", () => { expect(result.map(u => u.computingUnit.resource)).toEqual([{ cpuLimit: "1" }, { cpuLimit: "2" }]); }); + it("listAllComputingUnits() GETs the admin list endpoint and parses every unit's resource", () => { + let result: any[] = []; + service.listAllComputingUnits().subscribe(r => (result = r)); + + const req = httpMock.expectOne(`${api}/${COMPUTING_UNIT_ADMIN_LIST_URL}`); + expect(req.request.method).toEqual("GET"); + req.flush([unitWithResource('{"cpuLimit":"1"}'), unitWithResource('{"cpuLimit":"2"}')]); + + expect(result.map(u => u.computingUnit.resource)).toEqual([{ cpuLimit: "1" }, { cpuLimit: "2" }]); + }); + it("renameComputingUnit() PUTs to a URI-encoded rename endpoint", () => { service.renameComputingUnit(3, "my unit/name").subscribe(); diff --git a/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.ts b/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.ts index 58768bb346d..33c6d105d3f 100644 --- a/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.ts +++ b/frontend/src/app/common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service.ts @@ -33,6 +33,7 @@ export const COMPUTING_UNIT_BASE_URL = "computing-unit"; export const COMPUTING_UNIT_CREATE_URL = `${COMPUTING_UNIT_BASE_URL}/create`; export const COMPUTING_UNIT_LIST_URL = `${COMPUTING_UNIT_BASE_URL}`; export const COMPUTING_UNIT_TYPES_URL = `${COMPUTING_UNIT_BASE_URL}/types`; +export const COMPUTING_UNIT_ADMIN_LIST_URL = `${COMPUTING_UNIT_BASE_URL}/admin/list`; @Injectable({ providedIn: "root", @@ -173,6 +174,16 @@ export class WorkflowComputingUnitManagingService { .pipe(map(arr => arr.map(unit => this.parseDashboardUnit(unit)))); } + /** + * List every active computing unit across all users. ADMIN only. + * @returns An Observable of a list of DashboardWorkflowComputingUnit. + */ + public listAllComputingUnits(): Observable { + return this.http + .get(`${AppSettings.getApiEndpoint()}/${COMPUTING_UNIT_ADMIN_LIST_URL}`) + .pipe(map(arr => arr.map(unit => this.parseDashboardUnit(unit)))); + } + public getComputingUnit(cuid: number): Observable { return this.http .get(`${AppSettings.getApiEndpoint()}/${COMPUTING_UNIT_BASE_URL}/${cuid}`) diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html new file mode 100644 index 00000000000..ebc7a71b7e0 --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.html @@ -0,0 +1,141 @@ + + + +

+ Computing Units +

+
+ + + + + + + Name + + + Owner + + + Type + + + Status + + + Created + + Resources + + + + + + + {{ unit.computingUnit.name }} + + + + {{ unit.ownerName || "Unknown" }} + + + {{ unit.computingUnit.type }} + + + + + {{ formatRelativeTime(unit.computingUnit.creationTime) }} + + {{ resourceSummary(unit) }} + + + + +
+
+
CPU
+
{{ displaySpec(unit.computingUnit.resource.cpuLimit) }}
+
+
+
Memory
+
{{ displaySpec(unit.computingUnit.resource.memoryLimit) }}
+
+
+
GPU
+
{{ displaySpec(unit.computingUnit.resource.gpuLimit) }}
+
+
+
JVM Memory
+
{{ displaySpec(unit.computingUnit.resource.jvmMemorySize) }}
+
+
+
Shared Memory
+
{{ displaySpec(unit.computingUnit.resource.shmSize) }}
+
+
+ + +
+ +
+ + +
+ +
+
diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.scss b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.scss new file mode 100644 index 00000000000..4f28778d044 --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.scss @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.loading-container { + display: flex; + justify-content: center; + flex-direction: column; + height: 300px; +} + +.computing-unit-table { + display: block; +} + +.owner-cell { + display: flex; + align-items: center; + gap: 8px; +} + +// The expanded spec detail: a compact label/value grid that wraps on narrow viewports. +.spec-detail { + display: flex; + flex-wrap: wrap; + gap: 8px 32px; + margin: 0; + padding: 4px 0; + + div { + display: flex; + flex-direction: column; + } + + dt { + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + font-weight: 400; + } + + dd { + margin: 0; + font-variant-numeric: tabular-nums; + } +} diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts new file mode 100644 index 00000000000..da5cafbf5c6 --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.spec.ts @@ -0,0 +1,217 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { of, 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 } from "../../../../common/type/workflow-computing-unit"; +import { commonTestProviders } from "../../../../common/testing/test-utils"; +import { UserService } from "../../../../common/service/user/user.service"; +import { StubUserService } from "../../../../common/service/user/stub-user.service"; + +function makeUnit(over: Partial = {}): DashboardWorkflowComputingUnit { + return { + computingUnit: { + cuid: 1, + uid: 100, + name: "cu", + creationTime: 1_700_000_000_000, + terminateTime: undefined, + type: "kubernetes", + uri: "uri", + resource: { + cpuLimit: "2", + memoryLimit: "4Gi", + gpuLimit: "0", + jvmMemorySize: "2G", + shmSize: "64Mi", + nodeAddresses: [], + }, + }, + status: "Running", + metrics: { cpuUsage: "NaN", memoryUsage: "NaN" }, + isOwner: false, + accessPrivilege: "WRITE", + ownerGoogleAvatar: "", + ownerName: "alice", + ...over, + }; +} + +function localUnit(): DashboardWorkflowComputingUnit { + return makeUnit({ + computingUnit: { + ...makeUnit().computingUnit, + type: "local", + resource: { + cpuLimit: "NaN", + memoryLimit: "NaN", + gpuLimit: "NaN", + jvmMemorySize: "NaN", + shmSize: "NaN", + nodeAddresses: [], + }, + }, + }); +} + +describe("AdminComputingUnitComponent", () => { + let component: AdminComputingUnitComponent; + let fixture: ComponentFixture; + let service: WorkflowComputingUnitManagingService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + WorkflowComputingUnitManagingService, + { provide: UserService, useClass: StubUserService }, + ...commonTestProviders, + ], + imports: [AdminComputingUnitComponent, HttpClientTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(AdminComputingUnitComponent); + component = fixture.componentInstance; + service = TestBed.inject(WorkflowComputingUnitManagingService); + // Keep the fetch inert/synchronous; deliberately no detectChanges() so ngOnInit's poll never starts. + vi.spyOn(service, "listAllComputingUnits").mockReturnValue(of([])); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fixture.destroy(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + // The only test that renders the template, so a pipe/directive missing from the component's + // `imports` (e.g. the `date` pipe) fails here instead of only in the AOT app build. + it("renders a row per unit", () => { + vi.mocked(service.listAllComputingUnits).mockReturnValue(of([makeUnit()])); + + fixture.detectChanges(); + + // nz-table adds a hidden measure row to tbody, so match on the owner cell every data row has. + const dataRows = Array.from(fixture.nativeElement.querySelectorAll("tbody tr")).filter( + row => row.querySelector("texera-user-avatar") !== null + ); + expect(dataRows.length).toBe(1); + expect(dataRows[0].textContent).toContain("alice"); + }); + + it("fetchData loads all units and clears the loading flag", () => { + const units = [makeUnit(), makeUnit({ computingUnit: { ...makeUnit().computingUnit, cuid: 2 } })]; + vi.mocked(service.listAllComputingUnits).mockReturnValue(of(units)); + + component.fetchData(); + + expect(component.computingUnits).toEqual(units); + expect(component.isLoading).toBe(false); + }); + + it("fetchData clears the loading flag and shows a message when the fetch fails", () => { + const errorSpy = vi.spyOn(TestBed.inject(NzMessageService), "error").mockReturnValue({} as any); + vi.mocked(service.listAllComputingUnits).mockReturnValue(throwError(() => ({ error: { message: "boom" } }))); + + component.fetchData(); + + // On error the spinner must stop rather than spin forever, and the failure is surfaced. + expect(component.isLoading).toBe(false); + expect(errorSpy).toHaveBeenCalledWith("boom"); + }); + + describe("resourceSummary", () => { + it("joins CPU, memory and GPU with a middot and labels", () => { + const unit = makeUnit({ + computingUnit: { + ...makeUnit().computingUnit, + resource: { ...makeUnit().computingUnit.resource, gpuLimit: "1" }, + }, + }); + expect(component.resourceSummary(unit)).toBe("2 CPU · 4Gi · 1 GPU"); + }); + + it("omits GPU when there is none", () => { + expect(component.resourceSummary(makeUnit())).toBe("2 CPU · 4Gi"); + }); + + it("shows a no-limits message for local units", () => { + expect(component.resourceSummary(localUnit())).toBe("Local — no limits"); + }); + }); + + describe("displaySpec", () => { + it("renders a real value unchanged", () => { + expect(component.displaySpec("2Gi")).toBe("2Gi"); + }); + + it("renders NaN and empty as an em dash", () => { + expect(component.displaySpec("NaN")).toBe("—"); + expect(component.displaySpec("")).toBe("—"); + }); + }); + + describe("isLocal", () => { + it("is true only for local units", () => { + expect(component.isLocal(localUnit())).toBe(true); + expect(component.isLocal(makeUnit())).toBe(false); + }); + }); + + describe("onExpandChange", () => { + it("adds and removes a cuid from the expanded set", () => { + component.onExpandChange(7, true); + expect(component.expandedCuids.has(7)).toBe(true); + + component.onExpandChange(7, false); + expect(component.expandedCuids.has(7)).toBe(false); + }); + }); + + describe("client-side sort and filter", () => { + it("sorts by name", () => { + const a = makeUnit({ computingUnit: { ...makeUnit().computingUnit, name: "a" } }); + const b = makeUnit({ computingUnit: { ...makeUnit().computingUnit, name: "b" } }); + expect(component.sortByName(a, b)).toBeLessThan(0); + expect(component.sortByName(b, a)).toBeGreaterThan(0); + }); + + it("sorts by creation time numerically", () => { + const older = makeUnit({ computingUnit: { ...makeUnit().computingUnit, creationTime: 1 } }); + const newer = makeUnit({ computingUnit: { ...makeUnit().computingUnit, creationTime: 2 } }); + expect(component.sortByCreated(older, newer)).toBeLessThan(0); + }); + + it("filters by type", () => { + expect(component.filterByType(["local"], localUnit())).toBe(true); + expect(component.filterByType(["local"], makeUnit())).toBe(false); + }); + + it("filters by status", () => { + const pending = makeUnit({ status: "Pending" }); + expect(component.filterByStatus(["Pending"], pending)).toBe(true); + expect(component.filterByStatus(["Pending"], makeUnit())).toBe(false); + }); + }); +}); diff --git a/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts new file mode 100644 index 00000000000..721b290f30b --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/computing-unit/admin-computing-unit.component.ts @@ -0,0 +1,211 @@ +/** + * 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, switchMap } 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 { NzCardComponent } from "ng-zorro-antd/card"; +import { NzBadgeComponent } from "ng-zorro-antd/badge"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; +import { NzSpinComponent } from "ng-zorro-antd/spin"; +import { NzMessageService } from "ng-zorro-antd/message"; +import { WorkflowComputingUnitManagingService } from "../../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service"; +import { DashboardWorkflowComputingUnit } from "../../../../common/type/workflow-computing-unit"; +import { getComputingUnitBadgeColor, getComputingUnitStatusTooltip } from "../../../../common/util/computing-unit.util"; +import { formatRelativeTime } from "../../../../common/util/format.util"; +import { extractErrorMessage } from "../../../../common/util/error"; +import { UserAvatarComponent } from "../../user/user-avatar/user-avatar.component"; + +// Poll cadence for live status, matching the admin executions page. +const COMPUTING_UNIT_REFRESH_INTERVAL_MS = 5000; + +// Local units have no limits; their specs come back as this placeholder. +const NOT_APPLICABLE = "NaN"; + +@UntilDestroy() +@Component({ + templateUrl: "./admin-computing-unit.component.html", + styleUrls: ["./admin-computing-unit.component.scss"], + imports: [ + NzCardComponent, + NzTableComponent, + NzTheadComponent, + NzTbodyComponent, + NzTrDirective, + NzTableCellDirective, + NzThMeasureDirective, + NzThAddOnComponent, + NzTdAddOnComponent, + NzBadgeComponent, + NzTooltipDirective, + NzSpinComponent, + UserAvatarComponent, + NgFor, + NgIf, + DatePipe, + ], +}) +export class AdminComputingUnitComponent implements OnInit { + computingUnits: ReadonlyArray = []; + isLoading: boolean = true; + // cuids of rows whose spec detail is expanded. + readonly expandedCuids = new Set(); + + // Expose the shared formatters to the template. + readonly getBadgeColor = getComputingUnitBadgeColor; + readonly getStatusTooltip = getComputingUnitStatusTooltip; + readonly formatRelativeTime = formatRelativeTime; + + readonly typeFilters = [ + { text: "Kubernetes", value: "kubernetes" }, + { text: "Local", value: "local" }, + ]; + readonly statusFilters = [ + { text: "Running", value: "Running" }, + { text: "Pending", value: "Pending" }, + ]; + + readonly sortByName: NzTableSortFn = (a, b) => + (a.computingUnit.name ?? "").localeCompare(b.computingUnit.name ?? ""); + readonly sortByOwner: NzTableSortFn = (a, b) => + (a.ownerName ?? "").localeCompare(b.ownerName ?? ""); + readonly sortByType: NzTableSortFn = (a, b) => + a.computingUnit.type.localeCompare(b.computingUnit.type); + readonly sortByStatus: NzTableSortFn = (a, b) => a.status.localeCompare(b.status); + readonly sortByCreated: NzTableSortFn = (a, b) => + a.computingUnit.creationTime - b.computingUnit.creationTime; + + readonly filterByType: NzTableFilterFn = (selected: string[], unit) => + selected.includes(unit.computingUnit.type); + readonly filterByStatus: NzTableFilterFn = (selected: string[], unit) => + selected.includes(unit.status); + + constructor( + private computingUnitService: WorkflowComputingUnitManagingService, + private messageService: NzMessageService + ) {} + + ngOnInit(): void { + this.fetchData(); + + // switchMap drops a stale in-flight request; catchError is inside it so one failed poll + // shows a message but does not terminate the interval. + interval(COMPUTING_UNIT_REFRESH_INTERVAL_MS) + .pipe( + switchMap(() => + this.computingUnitService.listAllComputingUnits().pipe( + catchError((err: unknown) => { + this.messageService.error(extractErrorMessage(err)); + return EMPTY; + }) + ) + ), + untilDestroyed(this) + ) + .subscribe(units => (this.computingUnits = units)); + } + + /** + * Initial load, with the spinner. Only init calls this, so the poll never re-flashes it. + */ + fetchData(): void { + this.isLoading = true; + this.computingUnitService + .listAllComputingUnits() + .pipe(untilDestroyed(this)) + .subscribe({ + next: units => { + this.computingUnits = units; + this.isLoading = false; + }, + error: (err: unknown) => { + // Clear the spinner so a failed load doesn't spin forever. + this.isLoading = false; + this.messageService.error(extractErrorMessage(err)); + }, + }); + } + + /** + * Track by cuid so the 5s poll (which replaces every row object) reuses row DOM. + */ + trackByCuid(_index: number, unit: DashboardWorkflowComputingUnit): number { + return unit.computingUnit.cuid; + } + + /** + * Toggle whether a row's full spec detail is shown. + */ + onExpandChange(cuid: number, expanded: boolean): void { + if (expanded) { + this.expandedCuids.add(cuid); + } else { + this.expandedCuids.delete(cuid); + } + } + + /** + * A local unit has no resource limits (every spec is the "NaN" placeholder). + */ + isLocal(unit: DashboardWorkflowComputingUnit): boolean { + return unit.computingUnit.type === "local"; + } + + /** + * The Resources-column summary, e.g. "2 CPU · 4Gi · 1 GPU" (GPU omitted when none). + */ + resourceSummary(unit: DashboardWorkflowComputingUnit): string { + if (this.isLocal(unit)) { + return "Local — no limits"; + } + const { cpuLimit, memoryLimit, gpuLimit } = unit.computingUnit.resource; + const parts: string[] = []; + if (cpuLimit && cpuLimit !== NOT_APPLICABLE) { + parts.push(`${cpuLimit} CPU`); + } + if (memoryLimit && memoryLimit !== NOT_APPLICABLE) { + parts.push(memoryLimit); + } + if (gpuLimit && gpuLimit !== NOT_APPLICABLE && gpuLimit !== "0") { + parts.push(`${gpuLimit} GPU`); + } + return parts.length > 0 ? parts.join(" · ") : "—"; + } + + /** + * Present a spec value, rendering the "NaN" placeholder as an em dash. + */ + displaySpec(value: string): string { + return !value || value === NOT_APPLICABLE ? "—" : value; + } +} diff --git a/frontend/src/app/dashboard/component/dashboard.component.html b/frontend/src/app/dashboard/component/dashboard.component.html index 6f074d692e5..2267ba3f442 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.html +++ b/frontend/src/app/dashboard/component/dashboard.component.html @@ -175,6 +175,17 @@ nzType="setting"> Executions +
  • + + Computing Units +