[ui] Create/Delete volumes (#7029)

* [api/devstate] Add volumes to Devfile content

* Add Volume related endpoints to API

* Create/Delete volumes from the Volumes Tab

* Update UI static files

* API Devstate returns VolumeMounts

* Display volume mounts in containers

* [api] Add VolumeMounts to containers

* [ui] Define container's volume mounts

* [ui] e2e  tests

* Update UI static files

* [ui] create volumes from container / exec command creation

* Update UI static files

* Update container display

* Update UI static files

* Regenerate UI static files
This commit is contained in:
Philippe Martin
2023-08-21 18:02:55 +02:00
committed by GitHub
parent fcc1cd880d
commit edf0bf38d4
54 changed files with 1596 additions and 152 deletions

View File

@@ -1,4 +1,4 @@
import {TAB_YAML, TAB_COMMANDS, TAB_CONTAINERS, TAB_IMAGES, TAB_METADATA, TAB_RESOURCES, TAB_EVENTS} from './consts';
import {TAB_YAML, TAB_COMMANDS, TAB_CONTAINERS, TAB_IMAGES, TAB_METADATA, TAB_RESOURCES, TAB_EVENTS, TAB_VOLUMES} from './consts';
describe('devfile editor spec', () => {
@@ -42,14 +42,39 @@ describe('devfile editor spec', () => {
it('displays a created container', () => {
cy.init();
cy.selectTab(TAB_VOLUMES);
cy.getByDataCy('volume-name').type('volume1');
cy.getByDataCy('volume-size').type('512Mi');
cy.getByDataCy('volume-ephemeral').click();
cy.getByDataCy('volume-create').click();
cy.selectTab(TAB_CONTAINERS);
cy.getByDataCy('container-name').type('created-container');
cy.getByDataCy('container-image').type('an-image');
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-0').type("/mnt/vol1");
cy.getByDataCy('volume-mount-name-0').click().get('mat-option').contains('volume1').click();
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-1').type("/mnt/vol2");
cy.getByDataCy('volume-mount-name-1').click().get('mat-option').contains('(New Volume)').click();
cy.getByDataCy('volume-name').type('volume2');
cy.getByDataCy('volume-create').click();
cy.getByDataCy('container-create').click();
cy.getByDataCy('container-info').first()
.should('contain.text', 'created-container')
.should('contain.text', 'an-image');
.should('contain.text', 'an-image')
.should('contain.text', 'volume1')
.should('contain.text', '/mnt/vol1')
.should('contain.text', 'volume2')
.should('contain.text', '/mnt/vol2');
cy.selectTab(TAB_VOLUMES);
cy.getByDataCy('volume-info').eq(1)
.should('contain.text', 'volume2');
});
it('displays a created image', () => {
@@ -97,9 +122,30 @@ describe('devfile editor spec', () => {
.should('contain.text', '/my/manifest.yaml');
});
it('displays a created volume', () => {
cy.init();
cy.selectTab(TAB_VOLUMES);
cy.getByDataCy('volume-name').type('created-volume');
cy.getByDataCy('volume-size').type('512Mi');
cy.getByDataCy('volume-ephemeral').click();
cy.getByDataCy('volume-create').click();
cy.getByDataCy('volume-info').first()
.should('contain.text', 'created-volume')
.should('contain.text', '512Mi')
.should('contain.text', 'Yes')
});
it('creates an exec command with a new container', () => {
cy.init();
cy.selectTab(TAB_VOLUMES);
cy.getByDataCy('volume-name').type('volume1');
cy.getByDataCy('volume-size').type('512Mi');
cy.getByDataCy('volume-ephemeral').click();
cy.getByDataCy('volume-create').click();
cy.selectTab(TAB_COMMANDS);
cy.getByDataCy('add').click();
cy.getByDataCy('new-command-exec').click();
@@ -110,6 +156,17 @@ describe('devfile editor spec', () => {
cy.getByDataCy('select-container').click().get('mat-option').contains('(New Container)').click();
cy.getByDataCy('container-name').type('a-created-container');
cy.getByDataCy('container-image').type('an-image');
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-0').type("/mnt/vol1");
cy.getByDataCy('volume-mount-name-0').click().get('mat-option').contains('volume1').click();
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-1').type("/mnt/vol2");
cy.getByDataCy('volume-mount-name-1').click().get('mat-option').contains('(New Volume)').click();
cy.getByDataCy('volume-name').type('volume2');
cy.getByDataCy('volume-create').click();
cy.getByDataCy('container-create').click();
cy.getByDataCy('select-container').should('contain', 'a-created-container');
@@ -124,7 +181,15 @@ describe('devfile editor spec', () => {
cy.selectTab(TAB_CONTAINERS);
cy.getByDataCy('container-info').first()
.should('contain.text', 'a-created-container')
.should('contain.text', 'an-image');
.should('contain.text', 'an-image')
.should('contain.text', 'volume1')
.should('contain.text', '/mnt/vol1')
.should('contain.text', 'volume2')
.should('contain.text', '/mnt/vol2');
cy.selectTab(TAB_VOLUMES);
cy.getByDataCy('volume-info').eq(1)
.should('contain.text', 'volume2');
});
it('creates an apply image command with a new image', () => {

View File

@@ -29,6 +29,7 @@ model/devstateExecCommandPostRequest.ts
model/devstateImagePostRequest.ts
model/devstateQuantityValidPostRequest.ts
model/devstateResourcePostRequest.ts
model/devstateVolumePostRequest.ts
model/events.ts
model/execCommand.ts
model/generalError.ts
@@ -41,5 +42,7 @@ model/metadataRequest.ts
model/models.ts
model/resource.ts
model/telemetryResponse.ts
model/volume.ts
model/volumeMount.ts
param.ts
variables.ts

View File

@@ -45,6 +45,8 @@ import { DevstateQuantityValidPostRequest } from '../model/devstateQuantityValid
// @ts-ignore
import { DevstateResourcePostRequest } from '../model/devstateResourcePostRequest';
// @ts-ignore
import { DevstateVolumePostRequest } from '../model/devstateVolumePostRequest';
// @ts-ignore
import { GeneralError } from '../model/generalError';
// @ts-ignore
import { GeneralSuccess } from '../model/generalSuccess';
@@ -1361,4 +1363,127 @@ export class DevstateService {
);
}
/**
* Add a new Volume to the Devfile
* @param devstateVolumePostRequest
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
* @param reportProgress flag to report request and response progress.
*/
public devstateVolumePost(devstateVolumePostRequest?: DevstateVolumePostRequest, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable<DevfileContent>;
public devstateVolumePost(devstateVolumePostRequest?: DevstateVolumePostRequest, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable<HttpResponse<DevfileContent>>;
public devstateVolumePost(devstateVolumePostRequest?: DevstateVolumePostRequest, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable<HttpEvent<DevfileContent>>;
public devstateVolumePost(devstateVolumePostRequest?: DevstateVolumePostRequest, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable<any> {
let localVarHeaders = this.defaultHeaders;
let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept;
if (localVarHttpHeaderAcceptSelected === undefined) {
// to determine the Accept header
const httpHeaderAccepts: string[] = [
'application/json'
];
localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts);
}
if (localVarHttpHeaderAcceptSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected);
}
let localVarHttpContext: HttpContext | undefined = options && options.context;
if (localVarHttpContext === undefined) {
localVarHttpContext = new HttpContext();
}
// to determine the Content-Type header
const consumes: string[] = [
'application/json'
];
const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes);
if (httpContentTypeSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected);
}
let responseType_: 'text' | 'json' | 'blob' = 'json';
if (localVarHttpHeaderAcceptSelected) {
if (localVarHttpHeaderAcceptSelected.startsWith('text')) {
responseType_ = 'text';
} else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) {
responseType_ = 'json';
} else {
responseType_ = 'blob';
}
}
let localVarPath = `/devstate/volume`;
return this.httpClient.request<DevfileContent>('post', `${this.configuration.basePath}${localVarPath}`,
{
context: localVarHttpContext,
body: devstateVolumePostRequest,
responseType: <any>responseType_,
withCredentials: this.configuration.withCredentials,
headers: localVarHeaders,
observe: observe,
reportProgress: reportProgress
}
);
}
/**
* Delete a volume from the Devfile
* @param volumeName Volume name to delete
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
* @param reportProgress flag to report request and response progress.
*/
public devstateVolumeVolumeNameDelete(volumeName: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable<GeneralSuccess>;
public devstateVolumeVolumeNameDelete(volumeName: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable<HttpResponse<GeneralSuccess>>;
public devstateVolumeVolumeNameDelete(volumeName: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable<HttpEvent<GeneralSuccess>>;
public devstateVolumeVolumeNameDelete(volumeName: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable<any> {
if (volumeName === null || volumeName === undefined) {
throw new Error('Required parameter volumeName was null or undefined when calling devstateVolumeVolumeNameDelete.');
}
let localVarHeaders = this.defaultHeaders;
let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept;
if (localVarHttpHeaderAcceptSelected === undefined) {
// to determine the Accept header
const httpHeaderAccepts: string[] = [
'application/json'
];
localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts);
}
if (localVarHttpHeaderAcceptSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected);
}
let localVarHttpContext: HttpContext | undefined = options && options.context;
if (localVarHttpContext === undefined) {
localVarHttpContext = new HttpContext();
}
let responseType_: 'text' | 'json' | 'blob' = 'json';
if (localVarHttpHeaderAcceptSelected) {
if (localVarHttpHeaderAcceptSelected.startsWith('text')) {
responseType_ = 'text';
} else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) {
responseType_ = 'json';
} else {
responseType_ = 'blob';
}
}
let localVarPath = `/devstate/volume/${this.configuration.encodeParam({name: "volumeName", value: volumeName, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}`;
return this.httpClient.request<GeneralSuccess>('delete', `${this.configuration.basePath}${localVarPath}`,
{
context: localVarHttpContext,
responseType: <any>responseType_,
withCredentials: this.configuration.withCredentials,
headers: localVarHeaders,
observe: observe,
reportProgress: reportProgress
}
);
}
}

View File

@@ -9,6 +9,7 @@
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { VolumeMount } from './volumeMount';
export interface Container {
@@ -20,5 +21,6 @@ export interface Container {
memoryLimit: string;
cpuRequest: string;
cpuLimit: string;
volumeMounts: Array<VolumeMount>;
}

View File

@@ -12,6 +12,7 @@
import { Container } from './container';
import { Command } from './command';
import { Events } from './events';
import { Volume } from './volume';
import { Metadata } from './metadata';
import { Resource } from './resource';
import { Image } from './image';
@@ -23,6 +24,7 @@ export interface DevfileContent {
containers: Array<Container>;
images: Array<Image>;
resources: Array<Resource>;
volumes: Array<Volume>;
events: Events;
metadata: Metadata;
}

View File

@@ -9,6 +9,7 @@
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { VolumeMount } from './volumeMount';
export interface DevstateContainerPostRequest {
@@ -44,5 +45,9 @@ export interface DevstateContainerPostRequest {
* CPU limit for the deployed container
*/
cpuLimit?: string;
/**
* Volume to mount into the container filesystem
*/
volumeMounts?: Array<VolumeMount>;
}

View File

@@ -0,0 +1,28 @@
/**
* odo dev
* API interface for \'odo dev\'
*
* The version of the OpenAPI document: 0.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface DevstateVolumePostRequest {
/**
* Name of the volume
*/
name?: string;
/**
* Minimal size of the volume
*/
size?: string;
/**
* True if the Volume is Ephemeral
*/
ephemeral?: boolean;
}

View File

@@ -19,6 +19,7 @@ export * from './devstateExecCommandPostRequest';
export * from './devstateImagePostRequest';
export * from './devstateQuantityValidPostRequest';
export * from './devstateResourcePostRequest';
export * from './devstateVolumePostRequest';
export * from './events';
export * from './execCommand';
export * from './generalError';
@@ -30,3 +31,5 @@ export * from './metadata';
export * from './metadataRequest';
export * from './resource';
export * from './telemetryResponse';
export * from './volume';
export * from './volumeMount';

19
ui/src/app/api-gen/model/volume.ts generated Normal file
View File

@@ -0,0 +1,19 @@
/**
* odo dev
* API interface for \'odo dev\'
*
* The version of the OpenAPI document: 0.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface Volume {
name: string;
ephemeral?: boolean;
size?: string;
}

18
ui/src/app/api-gen/model/volumeMount.ts generated Normal file
View File

@@ -0,0 +1,18 @@
/**
* odo dev
* API interface for \'odo dev\'
*
* The version of the OpenAPI document: 0.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface VolumeMount {
name: string;
path: string;
}

View File

@@ -80,6 +80,7 @@
<mat-icon class="tab-icon material-icons-outlined">storage</mat-icon>
{{tabNames[8]}}
</ng-template>
<app-volumes></app-volumes>
</mat-tab>
</mat-tab-group>

View File

@@ -45,6 +45,9 @@ import { MultiCommandComponent } from './controls/multi-command/multi-command.co
import { EventsComponent } from './tabs/events/events.component';
import { ChipsEventsComponent } from './controls/chips-events/chips-events.component';
import { ConfirmComponent } from './components/confirm/confirm.component';
import { VolumesComponent } from './tabs/volumes/volumes.component';
import { VolumeComponent } from './forms/volume/volume.component';
import { VolumeMountsComponent } from './controls/volume-mounts/volume-mounts.component';
@NgModule({
declarations: [
@@ -68,6 +71,9 @@ import { ConfirmComponent } from './components/confirm/confirm.component';
EventsComponent,
ChipsEventsComponent,
ConfirmComponent,
VolumesComponent,
VolumeComponent,
VolumeMountsComponent,
],
imports: [
BrowserModule,

View File

@@ -1,4 +1,4 @@
<h3>{{title}}</h3>
<h3 *ngIf="title">{{title}}</h3>
<div class="group">
<span *ngFor="let text of texts; let i=index">
<mat-form-field class="inline" appearance="outline">

View File

@@ -0,0 +1,2 @@
h3 { margin-bottom: 0; }
div.group { margin-bottom: 16px; }

View File

@@ -0,0 +1,26 @@
<h3>Volume Mounts</h3>
<div class="group">
<div *ngFor="let vm of volumeMounts; let i=index">
<mat-form-field class="inline" appearance="outline">
<mat-label><span>Volume</span></mat-label>
<mat-select [attr.data-cy]="'volume-mount-name-'+i" [value]="vm.name" (selectionChange)="onNameChange(i, $event.value)">
<mat-option *ngFor="let volume of volumes" [value]="volume">{{volume}}</mat-option>
<mat-option value="!">(New Volume)</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="inline" appearance="outline">
<mat-label><span>Mount Path</span></mat-label>
<input (input)="onPathChange(i, $event)" [attr.data-cy]="'volume-mount-path-'+i" matInput [value]="vm.path" (change)="onPathChange(i, $event)">
</mat-form-field>
<app-volume
*ngIf="showNewVolume[i]"
(created)="onNewVolumeCreated(i, $event)"
></app-volume>
</div>
<button data-cy="volume-mount-add" *ngIf="volumeMounts.length > 0" mat-icon-button (click)="add()">
<mat-icon class="tab-icon material-icons-outlined">add</mat-icon>
</button>
<button data-cy="volume-mount-add" *ngIf="volumeMounts.length == 0" mat-flat-button (click)="add()">Add Volume Mount</button>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VolumeMountsComponent } from './volume-mounts.component';
describe('VolumeMountsComponent', () => {
let component: VolumeMountsComponent;
let fixture: ComponentFixture<VolumeMountsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ VolumeMountsComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(VolumeMountsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,86 @@
import { Component, EventEmitter, Input, Output, forwardRef } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator } from '@angular/forms';
import { Volume, VolumeMount } from 'src/app/api-gen';
@Component({
selector: 'app-volume-mounts',
templateUrl: './volume-mounts.component.html',
styleUrls: ['./volume-mounts.component.css'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: VolumeMountsComponent
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => VolumeMountsComponent),
multi: true,
},
]
})
export class VolumeMountsComponent implements Validator {
@Input() volumes: string[] = [];
@Output() createNewVolume = new EventEmitter<Volume>();
volumeMounts: VolumeMount[] = [];
showNewVolume: boolean[] = [];
onChange = (_: VolumeMount[]) => {};
onValidatorChange = () => {};
writeValue(value: any) {
this.volumeMounts = value;
}
registerOnChange(onChange: any) {
this.onChange = onChange;
}
registerOnTouched(_: any) {}
add() {
this.volumeMounts.push({name: "", path: ""});
this.onChange(this.volumeMounts);
}
onPathChange(i: number, e: Event) {
const target = e.target as HTMLInputElement;
this.volumeMounts[i].path = target.value;
this.onChange(this.volumeMounts);
}
onNameChange(i: number, name: string) {
if (name != "!") {
this.volumeMounts[i].name = name;
this.onChange(this.volumeMounts);
}
this.showNewVolume[i] = name == "!";
}
onNewVolumeCreated(i: number, v: Volume) {
this.volumes.push(v.name);
this.volumeMounts[i].name = v.name;
this.createNewVolume.next(v);
this.showNewVolume[i] = false;
this.onValidatorChange();
}
/* Validator implementation */
validate(control: AbstractControl): ValidationErrors | null {
for (let i=0; i<this.volumeMounts.length; i++) {
const vm = this.volumeMounts[i];
if (vm.name == "" || vm.path == "") {
return {'internal': true};
}
}
return null;
}
registerOnValidatorChange?(onValidatorChange: () => void): void {
this.onValidatorChange = onValidatorChange;
}
}

View File

@@ -28,6 +28,7 @@
<app-container
*ngIf="showNewContainer"
[volumeNames]="volumeNames ?? []"
(created)="onNewContainerCreated($event)"
></app-container>

View File

@@ -3,8 +3,9 @@ import { FormControl, FormGroup, Validators } from '@angular/forms';
import { StateService } from 'src/app/services/state.service';
import { DevstateService } from 'src/app/services/devstate.service';
import { PATTERN_COMMAND_ID } from '../patterns';
import { Container } from 'src/app/api-gen';
import { Container, Volume } from 'src/app/api-gen';
import { TelemetryService } from 'src/app/services/telemetry.service';
import { ToCreate } from '../container/container.component';
@Component({
selector: 'app-command-exec',
@@ -18,6 +19,8 @@ export class CommandExecComponent {
containerList: string[] = [];
showNewContainer: boolean = false;
containerToCreate: Container | null = null;
volumesToCreate: Volume[] = [];
volumeNames: string[] | undefined = [];
constructor(
private devstate: DevstateService,
@@ -33,6 +36,7 @@ export class CommandExecComponent {
});
this.state.state.subscribe(async newContent => {
this.volumeNames = newContent?.volumes.map((v: Volume) => v.name);
const containers = newContent?.containers;
if (containers == null) {
return
@@ -41,6 +45,21 @@ export class CommandExecComponent {
});
}
createVolumes(volumes: Volume[], i: number, next: () => any) {
if (volumes.length == i) {
next();
return;
}
const res = this.devstate.addVolume(volumes[i]);
res.subscribe({
next: value => {
this.createVolumes(volumes, i+1, next);
},
error: error => {
alert(error.error.message);
}
});
}
create() {
this.telemetry.track("[ui] create exec command");
@@ -56,7 +75,8 @@ export class CommandExecComponent {
});
}
if (this.containerToCreate != null &&
this.createVolumes(this.volumesToCreate, 0, () => {
if (this.containerToCreate != null &&
this.containerToCreate?.name == this.form.controls["component"].value) {
const res = this.devstate.addContainer(this.containerToCreate);
res.subscribe({
@@ -67,9 +87,10 @@ export class CommandExecComponent {
alert(error.error.message);
}
});
} else {
subcreate();
}
} else {
subcreate();
}
});
}
cancel() {
@@ -84,10 +105,12 @@ export class CommandExecComponent {
this.showNewContainer = v;
}
onNewContainerCreated(container: Container) {
onNewContainerCreated(toCreate: ToCreate) {
const container = toCreate.container;
this.containerList.push(container.name);
this.form.controls["component"].setValue(container.name);
this.showNewContainer = false;
this.containerToCreate = container;
this.volumesToCreate.push(...toCreate.volumes);
}
}

View File

@@ -11,8 +11,15 @@
<mat-label><span>Image</span></mat-label>
<input placeholder="Image to start the container" data-cy="container-image" matInput formControlName="image">
</mat-form-field>
<app-multi-text formControlName="command" title="Command" label="Command" addLabel="Add command"></app-multi-text>
<app-multi-text formControlName="args" title="Arguments to command" label="Arg" addLabel="Add arg"></app-multi-text>
<h3>Command and Arguments</h3>
<app-multi-text formControlName="command" label="Command" addLabel="Add command"></app-multi-text>
<app-multi-text formControlName="args" label="Arg" addLabel="Add arg"></app-multi-text>
<app-volume-mounts
[volumes]="volumeNames"
formControlName="volumeMounts"
(createNewVolume)="onCreateNewVolume($event)"></app-volume-mounts>
<h3>Resource Usage</h3>
<mat-form-field appearance="outline" class="mid-width">
<mat-label><span>Memory Request</span></mat-label>
<mat-error>{{quantityErrMsgMemory}}</mat-error>

View File

@@ -1,26 +1,33 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, FormControl, FormGroup, ValidationErrors, ValidatorFn, Validators } from '@angular/forms';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { PATTERN_COMPONENT_ID } from '../patterns';
import { DevstateService } from 'src/app/services/devstate.service';
import { Observable, of, map, catchError } from 'rxjs';
import { Container } from 'src/app/api-gen';
import { Container, Volume } from 'src/app/api-gen';
import { TelemetryService } from 'src/app/services/telemetry.service';
export interface ToCreate {
container: Container;
volumes: Volume[];
}
@Component({
selector: 'app-container',
templateUrl: './container.component.html',
styleUrls: ['./container.component.css']
})
export class ContainerComponent {
@Input() volumeNames: string[] = [];
@Input() cancelable: boolean = false;
@Output() canceled = new EventEmitter<void>();
@Output() created = new EventEmitter<Container>();
@Output() created = new EventEmitter<ToCreate>();
form: FormGroup;
quantityErrMsgMemory = 'Numeric value, with optional unit Ki, Mi, Gi, Ti, Pi, Ei';
quantityErrMsgCPU = 'Numeric value, with optional unit m, k, M, G, T, P, E';
volumesToCreate: Volume[] = [];
constructor(
private devstate: DevstateService,
private telemetry: TelemetryService
@@ -30,33 +37,27 @@ export class ContainerComponent {
image: new FormControl("", [Validators.required]),
command: new FormControl([]),
args: new FormControl([]),
memoryRequest: new FormControl("", null, [this.isQuantity()]),
memoryLimit: new FormControl("", null, [this.isQuantity()]),
cpuRequest: new FormControl("", null, [this.isQuantity()]),
cpuLimit: new FormControl("", null, [this.isQuantity()]),
memoryRequest: new FormControl("", null, [this.devstate.isQuantity()]),
memoryLimit: new FormControl("", null, [this.devstate.isQuantity()]),
cpuRequest: new FormControl("", null, [this.devstate.isQuantity()]),
cpuLimit: new FormControl("", null, [this.devstate.isQuantity()]),
volumeMounts: new FormControl([]),
})
}
create() {
this.telemetry.track("[ui] create container");
this.created.emit(this.form.value);
this.created.emit({
container: this.form.value,
volumes: this.volumesToCreate,
});
}
cancel() {
this.canceled.emit();
}
isQuantity(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const val = control.value;
if (val == '') {
return of(null);
}
const valid = this.devstate.isQuantityValid(val);
return valid.pipe(
map(() => null),
catchError(() => of({"isQuantity": false}))
);
};
}
onCreateNewVolume(v: Volume) {
this.volumesToCreate.push(v);
}
}

View File

@@ -0,0 +1,3 @@
.main { padding: 16px; }
mat-form-field.full-width { width: 100%; }
mat-form-field.mid-width { width: 50%; }

View File

@@ -0,0 +1,20 @@
<div class="main">
<h2>Add a new volume</h2>
<div class="description">A volume can be mounted and shared by several containers.</div>
<form [formGroup]="form">
<mat-form-field appearance="outline" class="mid-width">
<mat-label><span>Name</span></mat-label>
<mat-error>Lowercase words separated by dashes. Ex: my-volume</mat-error>
<input placeholder="unique name to identify the volume" data-cy="volume-name" matInput formControlName="name">
</mat-form-field>
<mat-form-field appearance="outline" class="mid-width">
<mat-label><span>Size</span></mat-label>
<input placeholder="Minimal size of the volume" data-cy="volume-size" matInput formControlName="size">
</mat-form-field>
<mat-checkbox data-cy="volume-ephemeral" formControlName="ephemeral">Volume is Ephemeral</mat-checkbox>
</form>
<button data-cy="volume-create" [disabled]="form.invalid" mat-flat-button color="primary" matTooltip="create new volume" (click)="create()">Create</button>
<button *ngIf="cancelable" mat-flat-button (click)="cancel()">Cancel</button>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VolumeComponent } from './volume.component';
describe('VolumeComponent', () => {
let component: VolumeComponent;
let fixture: ComponentFixture<VolumeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ VolumeComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(VolumeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,39 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { Volume } from 'src/app/api-gen';
import { TelemetryService } from 'src/app/services/telemetry.service';
import { PATTERN_COMPONENT_ID } from '../patterns';
import { DevstateService } from 'src/app/services/devstate.service';
@Component({
selector: 'app-volume',
templateUrl: './volume.component.html',
styleUrls: ['./volume.component.css']
})
export class VolumeComponent {
@Input() cancelable: boolean = false;
@Output() canceled = new EventEmitter<void>();
@Output() created = new EventEmitter<Volume>();
form: FormGroup;
constructor(
private devstate: DevstateService,
private telemetry: TelemetryService
) {
this.form = new FormGroup({
name: new FormControl("", [Validators.required, Validators.pattern(PATTERN_COMPONENT_ID)]),
size: new FormControl("", null, [this.devstate.isQuantity()]),
ephemeral: new FormControl(false),
})
}
create() {
this.telemetry.track("[ui] create volume");
this.created.emit(this.form.value);
}
cancel() {
this.canceled.emit();
}
}

View File

@@ -1,7 +1,8 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApplyCommand, CompositeCommand, Container, DevfileContent, DevstateChartGet200Response, ExecCommand, Image, Metadata, Resource } from '../api-gen';
import { Observable, catchError, map, of } from 'rxjs';
import { ApplyCommand, CompositeCommand, Container, DevfileContent, DevstateChartGet200Response, ExecCommand, Image, Metadata, Resource, Volume } from '../api-gen';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
@Injectable({
providedIn: 'root'
@@ -22,6 +23,7 @@ export class DevstateService {
memLimit: container.memoryLimit,
cpuReq: container.cpuRequest,
cpuLimit: container.cpuLimit,
volumeMounts: container.volumeMounts,
});
}
@@ -44,6 +46,14 @@ export class DevstateService {
});
}
addVolume(volume: Volume): Observable<DevfileContent> {
return this.http.post<DevfileContent>(this.base+"/volume", {
name: volume.name,
ephemeral: volume.ephemeral,
size: volume.size,
});
}
addExecCommand(name: string, cmd: ExecCommand): Observable<DevfileContent> {
return this.http.post<DevfileContent>(this.base+"/execCommand", {
name: name,
@@ -145,6 +155,10 @@ export class DevstateService {
return this.http.delete<DevfileContent>(this.base+"/resource/"+resource);
}
deleteVolume(volume: string): Observable<DevfileContent> {
return this.http.delete<DevfileContent>(this.base+"/volume/"+volume);
}
updateEvents(event: "preStart"|"postStart"|"preStop"|"postStop", commands: string[]): Observable<DevfileContent> {
return this.http.put<DevfileContent>(this.base+"/events", {
eventName: event,
@@ -157,4 +171,18 @@ export class DevstateService {
quantity: quantity
});
}
isQuantity(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const val = control.value;
if (val == '') {
return of(null);
}
const valid = this.isQuantityValid(val);
return valid.pipe(
map(() => null),
catchError(() => of({"isQuantity": false}))
);
};
}
}

View File

@@ -1,3 +1,16 @@
.main { padding: 16px; }
mat-card { margin-bottom: 16px; }
mat-card-content { padding: 16px; }
.volume-mount {
margin-top: 4px;
}
.volume-mount > mat-chip {
top: -11px;
}
.volume-mount > span.path {
position: relative;
top: -14px;
}
table.aligned > tr > td {
vertical-align: top;
}

View File

@@ -18,6 +18,16 @@
<td>Args:</td>
<td><code>{{container.args.join(" ")}}</code></td>
</tr>
<tr *ngIf="container.volumeMounts.length > 0">
<td>Volume Mounts:</td>
<td>
<div class="volume-mount" *ngFor="let vm of container.volumeMounts">
<mat-chip disableRipple>
<mat-icon matChipAvatar class="material-icons-outlined">storage</mat-icon>
{{vm.name}}
</mat-chip><span class="path"> in <code>{{vm.path}}</code></span></div>
</td>
</tr>
<tr *ngIf="container.memoryRequest != null && container.memoryRequest.length > 0">
<td>Memory Request:</td>
<td><code>{{container.memoryRequest}}</code></td>
@@ -46,6 +56,7 @@
<app-container
*ngIf="forceDisplayAdd || containers == undefined || containers.length == 0"
[volumeNames]="volumeNames ?? []"
[cancelable]="forceDisplayAdd"
(canceled)="undisplayAddForm()"
(created)="onCreated($event)"

View File

@@ -1,7 +1,8 @@
import { Component, OnInit } from '@angular/core';
import { StateService } from 'src/app/services/state.service';
import { DevstateService } from 'src/app/services/devstate.service';
import { Container } from 'src/app/api-gen';
import { Container, Volume } from 'src/app/api-gen';
import { ToCreate } from 'src/app/forms/container/container.component';
@Component({
selector: 'app-containers',
@@ -12,6 +13,7 @@ export class ContainersComponent implements OnInit {
forceDisplayAdd: boolean = false;
containers: Container[] | undefined = [];
volumeNames: string[] | undefined = [];
constructor(
private state: StateService,
@@ -21,6 +23,7 @@ export class ContainersComponent implements OnInit {
ngOnInit() {
const that = this;
this.state.state.subscribe(async newContent => {
this.volumeNames = newContent?.volumes.map((v: Volume) => v.name);
that.containers = newContent?.containers;
if (this.containers == null) {
return
@@ -54,16 +57,36 @@ export class ContainersComponent implements OnInit {
}
}
onCreated(container: Container) {
const result = this.devstate.addContainer(container);
result.subscribe({
next: value => {
this.state.changeDevfileYaml(value);
},
error: error => {
alert(error.error.message);
}
});
createVolumes(volumes: Volume[], i: number, next: () => any) {
if (volumes.length == i) {
next();
return;
}
const res = this.devstate.addVolume(volumes[i]);
res.subscribe({
next: value => {
this.createVolumes(volumes, i+1, next);
},
error: error => {
alert(error.error.message);
}
});
}
onCreated(toCreate: ToCreate) {
const container = toCreate.container;
this.createVolumes(toCreate.volumes, 0, () => {
const result = this.devstate.addContainer(container);
result.subscribe({
next: value => {
this.state.changeDevfileYaml(value);
},
error: error => {
alert(error.error.message);
}
});
});
}
scrollToBottom() {

View File

@@ -0,0 +1,3 @@
.main { padding: 16px; }
mat-card { margin-bottom: 16px; }
mat-card-content { padding: 16px; }

View File

@@ -0,0 +1,38 @@
<div class="main">
<mat-card data-cy="volume-info" *ngFor="let volume of volumes">
<mat-card-header class="colored-title">
<mat-card-title>{{volume.name}}</mat-card-title>
<mat-card-subtitle>Volume</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<table class="aligned">
<tr *ngIf="volume.size">
<td>Size:</td>
<td><code>{{volume.size}}</code></td>
</tr>
<tr>
<td>Volume is Ephemeral:</td>
<td><code>{{volume.ephemeral ? "Yes" : "No"}}</code></td>
</tr>
</table>
</mat-card-content>
<mat-card-actions>
<button mat-button color="warn" (click)="delete(volume.name)">Delete</button>
</mat-card-actions>
</mat-card>
<app-volume
*ngIf="forceDisplayAdd || volumes == undefined || volumes.length == 0"
[cancelable]="forceDisplayAdd"
(canceled)="undisplayAddForm()"
(created)="onCreated($event)"
></app-volume>
</div>
<ng-container *ngIf="!forceDisplayAdd && volumes != undefined && volumes.length > 0">
<button class="fab" mat-fab color="primary" (click)="displayAddForm()">
<mat-icon class="material-icons-outlined">add</mat-icon>
</button>
</ng-container>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VolumesComponent } from './volumes.component';
describe('VolumesComponent', () => {
let component: VolumesComponent;
let fixture: ComponentFixture<VolumesComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ VolumesComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(VolumesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,72 @@
import { Component } from '@angular/core';
import { Volume } from 'src/app/api-gen';
import { DevstateService } from 'src/app/services/devstate.service';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-volumes',
templateUrl: './volumes.component.html',
styleUrls: ['./volumes.component.css']
})
export class VolumesComponent {
forceDisplayAdd: boolean = false;
volumes: Volume[] | undefined = [];
constructor(
private state: StateService,
private devstate: DevstateService,
) {}
ngOnInit() {
const that = this;
this.state.state.subscribe(async newContent => {
that.volumes = newContent?.volumes;
if (this.volumes == null) {
return
}
that.forceDisplayAdd = false;
});
}
displayAddForm() {
this.forceDisplayAdd = true;
setTimeout(() => {
this.scrollToBottom();
}, 0);
}
undisplayAddForm() {
this.forceDisplayAdd = false;
}
delete(name: string) {
if(confirm('You will delete the volume "'+name+'". Continue?')) {
const result = this.devstate.deleteVolume(name);
result.subscribe({
next: (value) => {
this.state.changeDevfileYaml(value);
},
error: (error) => {
alert(error.error.message);
}
});
}
}
onCreated(volume: Volume) {
const result = this.devstate.addVolume(volume);
result.subscribe({
next: value => {
this.state.changeDevfileYaml(value);
},
error: error => {
alert(error.error.message);
}
});
}
scrollToBottom() {
window.scrollTo(0,document.body.scrollHeight);
}
}