Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
COMPUTING_UNIT_LIST_URL,
COMPUTING_UNIT_TYPES_URL,
} from "./workflow-computing-unit-managing.service";
import { ClusterCapacity } from "../../../type/workflow-computing-unit";

describe("WorkflowComputingUnitManagingService", () => {
let service: WorkflowComputingUnitManagingService;
Expand Down Expand Up @@ -183,6 +184,34 @@ describe("WorkflowComputingUnitManagingService", () => {
expect(status).toBe(403);
});

// Spelled out, so a typo in the constant cannot pass by matching itself.
it("getClusterCapacity() GETs the admin cluster capacity endpoint and returns the body as is", () => {
const capacity: ClusterCapacity = {
cpu: { total: 16000, allocated: 12500, available: 3500 },
memory: { total: 68719476736, allocated: 17179869184, available: 51539607552 },
gpu: { total: 0, allocated: 0, available: 0 },
};
let result: ClusterCapacity | undefined;
service.getClusterCapacity().subscribe(r => (result = r));

const req = httpMock.expectOne(`${api}/computing-unit/admin/cluster-capacity`);
expect(req.request.method).toEqual("GET");
req.flush(capacity);

expect(result).toEqual(capacity);
});

it("getClusterCapacity() surfaces the 404 a deployment without Kubernetes answers", () => {
let status: number | undefined;
service.getClusterCapacity().subscribe({ error: (e: unknown) => (status = (e as HttpErrorResponse).status) });

httpMock
.expectOne(`${api}/computing-unit/admin/cluster-capacity`)
.flush({ message: "not on Kubernetes" }, { status: 404, statusText: "Not Found" });

expect(status).toBe(404);
});

it("renameComputingUnit() PUTs to a URI-encoded rename endpoint", () => {
service.renameComputingUnit(3, "my unit/name").subscribe();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { AppSettings } from "../../../app-setting";
import {
ClusterCapacity,
DashboardWorkflowComputingUnit,
WorkflowComputingUnit,
WorkflowComputingUnitResourceLimit,
Expand All @@ -34,6 +35,7 @@ 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`;
export const COMPUTING_UNIT_ADMIN_CLUSTER_CAPACITY_URL = `${COMPUTING_UNIT_BASE_URL}/admin/cluster-capacity`;

@Injectable({
providedIn: "root",
Expand Down Expand Up @@ -200,6 +202,13 @@ export class WorkflowComputingUnitManagingService {
.pipe(map(arr => arr.map(unit => this.parseDashboardUnit(unit))));
}

/** The cluster's CPU, memory and GPU capacity. Admin only, and 404 when units do not run on Kubernetes. */
public getClusterCapacity(): Observable<ClusterCapacity> {
return this.http.get<ClusterCapacity>(
`${AppSettings.getApiEndpoint()}/${COMPUTING_UNIT_ADMIN_CLUSTER_CAPACITY_URL}`
);
}

public getComputingUnit(cuid: number): Observable<DashboardWorkflowComputingUnit> {
return this.http
.get<DashboardWorkflowComputingUnit>(`${AppSettings.getApiEndpoint()}/${COMPUTING_UNIT_BASE_URL}/${cuid}`)
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/app/common/type/workflow-computing-unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,16 @@ export interface DashboardWorkflowComputingUnit {
ownerAvatar: string;
ownerName: string;
}

// CPU in millicores, memory in bytes, GPU in devices.
export interface ResourceCapacity {
total: number;
allocated: number;
available: number;
}

export interface ClusterCapacity {
cpu: ResourceCapacity;
memory: ResourceCapacity;
gpu: ResourceCapacity;
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
</h2>
</nz-card>

<texera-admin-cluster-capacity></texera-admin-cluster-capacity>

<nz-alert
*ngIf="pollFailing"
class="refresh-warning"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import { HttpErrorResponse } from "@angular/common/http";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { of, Subject, throwError } from "rxjs";
import { NEVER, of, Subject, throwError } from "rxjs";
import { NzMessageService } from "ng-zorro-antd/message";
import { NzModalService } from "ng-zorro-antd/modal";
import { AdminComputingUnitComponent } from "./admin-computing-unit.component";
Expand Down Expand Up @@ -103,6 +103,8 @@ describe("AdminComputingUnitComponent", () => {
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([]));
// Never answers, so the capacity panel stays hidden and adds no alert here.
vi.spyOn(service, "getClusterCapacity").mockReturnValue(NEVER);
});

afterEach(() => {
Expand Down Expand Up @@ -143,6 +145,15 @@ describe("AdminComputingUnitComponent", () => {
expect(rows[0].textContent).toContain("alice");
});

it("places the cluster capacity panel above the units table", () => {
fixture.detectChanges();

const panel = fixture.nativeElement.querySelector("texera-admin-cluster-capacity");
const table = fixture.nativeElement.querySelector("nz-table");
expect(panel).not.toBeNull();
expect(panel.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});

describe("loading and polling", () => {
// Fake timers go in before `ngOnInit`, so the poll's interval is fake too.
beforeEach(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ 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";
import { AdminClusterCapacityComponent } from "./cluster-capacity/admin-cluster-capacity.component";

// Same cadence as the admin executions page.
const COMPUTING_UNIT_REFRESH_INTERVAL_MS = 5000;
Expand Down Expand Up @@ -90,6 +91,7 @@ type SpecKey = Exclude<keyof WorkflowComputingUnitResourceLimit, "nodeAddresses"
NzTooltipDirective,
NzSpinComponent,
UserAvatarComponent,
AdminClusterCapacityComponent,
NgFor,
NgIf,
DatePipe,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<!--
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
*ngIf="isVisible"
nzSize="small"
nzTitle="Cluster capacity"
[nzExtra]="headerExtra"
class="cluster-capacity">
<nz-alert
*ngIf="loadError !== undefined && !rows"
nzType="error"
nzShowIcon
nzMessage="Could not load cluster capacity: {{ loadError }}"></nz-alert>
<nz-alert
*ngIf="loadError !== undefined && rows"
class="capacity-alert"
nzType="warning"
nzShowIcon
nzMessage="Showing capacity from {{ lastUpdated | date: 'mediumTime' }}. The refresh is failing: {{ loadError }}"></nz-alert>

<ng-container *ngIf="rows">
<table
class="capacity-ledger"
aria-label="Cluster capacity">
<thead>
<tr>
<th scope="col">Resource</th>
<th
scope="col"
class="usage">
Share allocated
</th>
<th scope="col">Allocated</th>
<th scope="col">Available</th>
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let row of rows; trackBy: trackByResource"
[class]="row.status?.className">
<th scope="row">{{ row.resource }}</th>
<td class="usage">
<div
*ngIf="row.percentUsed !== undefined; else noMeter"
class="meter">
<div
class="meter-track"
aria-hidden="true">
<div
class="meter-fill"
[style.width.%]="row.fillPercent"></div>
</div>
<span class="percent-used">{{ row.percentUsed }}%</span>
<span class="status-slot">
<!-- `nz-icon` sets its own aria-label, so the label goes on a wrapper. -->
<span
*ngIf="row.status"
role="img"
class="status-icon"
[nz-tooltip]="row.status.label"
[attr.aria-label]="row.status.label">
<span
nz-icon
nzTheme="fill"
aria-hidden="true"
[nzType]="row.status.icon"></span>
</span>
</span>
</div>
<ng-template #noMeter>
<span class="meter-note">{{ row.note }}</span>
</ng-template>
</td>
<td
class="allocated"
data-label="Allocated">
{{ row.allocated }}
</td>
<td
class="available"
data-label="Available">
{{ row.available }}
</td>
<td
class="total"
data-label="Total">
{{ row.total }}
</td>
</tr>
</tbody>
</table>
<p class="capacity-note">
Allocated counts what running pods request, not what they use. Available is summed across nodes, so a unit too big
for any one node's free space still waits in Pending.
</p>
</ng-container>
</nz-card>

<ng-template #headerExtra>
<span class="capacity-extra">
<span
*ngIf="lastUpdated"
class="capacity-updated">
Updated {{ lastUpdated | date: "mediumTime" }}
</span>
<button
nz-button
nzType="text"
nzSize="small"
class="refresh-button"
nz-tooltip="Refresh"
aria-label="Refresh cluster capacity"
(click)="refresh()">
<span
nz-icon
nzType="reload"
[nzSpin]="isFetching"></span>
</button>
</span>
</ng-template>
Loading
Loading