diff --git a/frontend/.prettierrc b/frontend/.prettierrc index ae751f30..6112aa18 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -1,11 +1,5 @@ { - printWidth: 80, - useTabs: false, - tabWidth: 2, - semi: true, - singleQuote: true, - trailingComma: 'all', - bracketSpacing: true, - arrowParens: 'avoid', - proseWrap: 'preserve', + "arrowParens": "avoid", + "bracketSpacing": false, + "trailingComma": "none" } diff --git a/frontend/src/app/resource-form/form-data-volumes/form-data-volumes.component.html b/frontend/src/app/resource-form/form-data-volumes/form-data-volumes.component.html index c4af5501..f30b6da0 100644 --- a/frontend/src/app/resource-form/form-data-volumes/form-data-volumes.component.html +++ b/frontend/src/app/resource-form/form-data-volumes/form-data-volumes.component.html @@ -25,6 +25,7 @@

[pvcs]="pvcs" [ephemeral]="false" [defaultStorageClass]="defaultStorageClass" + [sizes]="['4Gi', '8Gi', '16Gi', '32Gi', '64Gi', '128Gi', '256Gi', '512Gi']" > diff --git a/frontend/src/app/resource-form/form-name/form-name.component.html b/frontend/src/app/resource-form/form-name/form-name.component.html index 05e0d06c..0c98596c 100644 --- a/frontend/src/app/resource-form/form-name/form-name.component.html +++ b/frontend/src/app/resource-form/form-name/form-name.component.html @@ -16,7 +16,9 @@

formControlName="name" #name /> - {{ showNameError() }} + + {{ showNameError() }} + diff --git a/frontend/src/app/resource-form/form-name/form-name.component.ts b/frontend/src/app/resource-form/form-name/form-name.component.ts index cb2eb9d2..d98ca759 100644 --- a/frontend/src/app/resource-form/form-name/form-name.component.ts +++ b/frontend/src/app/resource-form/form-name/form-name.component.ts @@ -22,11 +22,11 @@ export class FormNameComponent implements OnInit, OnDestroy { constructor(private k8s: KubernetesService, private ns: NamespaceService) {} ngOnInit() { - // Add the ExistingName validator to the list if it doesn't already exist + // Add validator for notebook name (existing name, length, lowercase alphanumeric and '-') this.parentForm .get("name") - .setValidators([Validators.required, this.existingName()]); - + .setValidators([Validators.required, this.existingNameValidator(), Validators.pattern(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/), Validators.maxLength(52)]); + // Keep track of the existing Notebooks in the selected Namespace // Use these names to check if the input name exists const nsSub = this.ns.getSelectedNamespace().subscribe(ns => { @@ -45,18 +45,26 @@ export class FormNameComponent implements OnInit, OnDestroy { showNameError() { const nameCtrl = this.parentForm.get("name"); - + + if (nameCtrl.value.length==0) { + return `The Notebook Server's name can't be empty`; + } if (nameCtrl.hasError("existingName")) { return `Notebook Server "${nameCtrl.value}" already exists`; - } else { - return "The Notebook Server's name can't be empty"; + } + if (nameCtrl.hasError("pattern")) { + return `The Notebook Server's name can only contain lowercase alphanumeric characters or '-' and must start and end with an alphanumeric character`; + } + if (nameCtrl.hasError("maxlength")) { + return `The Notebook Server's name can't be more than 52 characters`; } } - private existingName(): ValidatorFn { + private existingNameValidator(): ValidatorFn { return (control: AbstractControl): { [key: string]: any } => { const exists = this.notebooks.has(control.value); return exists ? { existingName: true } : null; }; } + } diff --git a/frontend/src/app/resource-form/form-specs/form-specs.component.html b/frontend/src/app/resource-form/form-specs/form-specs.component.html index 332a05f0..7c2a2e22 100644 --- a/frontend/src/app/resource-form/form-specs/form-specs.component.html +++ b/frontend/src/app/resource-form/form-specs/form-specs.component.html @@ -12,14 +12,24 @@

CPU - - Please provide the CPU requirements + + {{ cpuErrorMessage() }} Memory - - Please provide the RAM requirements + + {{ memoryErrorMessage() }}
diff --git a/frontend/src/app/resource-form/form-specs/form-specs.component.ts b/frontend/src/app/resource-form/form-specs/form-specs.component.ts index 24559216..6a1c78ed 100644 --- a/frontend/src/app/resource-form/form-specs/form-specs.component.ts +++ b/frontend/src/app/resource-form/form-specs/form-specs.component.ts @@ -1,5 +1,41 @@ -import { Component, OnInit, Input } from "@angular/core"; -import { FormGroup } from "@angular/forms"; +import {Component, OnInit, Input} from "@angular/core"; +import { + FormGroup, + AbstractControl, + Validators, + ValidatorFn, + ValidationErrors, + FormControl, + FormGroupDirective, + NgForm +} from "@angular/forms"; + +const MAX_FOR_GPU: ReadonlyMap = new Map([ + [0, {cpu: 15, ram: 96}], + [1, {cpu: 5, ram: 48}] +]); + +type MaxResourceSpec = {cpu: number; ram: number}; + +function resourcesValidator(): ValidatorFn { + return function (control: AbstractControl): ValidationErrors | null { + const gpuNumValue = control.get("gpus").get("num").value; + const gpu = gpuNumValue === "none" ? 0 : parseInt(gpuNumValue, 10) || 0; + const cpu = parseFloat(control.get("cpu").value); + const ram = parseFloat(control.get("memory").value); + const errors = {}; + + const max = MAX_FOR_GPU.get(gpu); + if (cpu > max.cpu) { + errors["maxCpu"] = {max: max.cpu, gpu}; + } + if (ram > max.ram) { + errors["maxRam"] = {max: max.ram, gpu}; + } + + return Object.entries(errors).length > 0 ? errors : null; + }; +} @Component({ selector: "app-form-specs", @@ -11,7 +47,70 @@ export class FormSpecsComponent implements OnInit { @Input() readonlyCPU: boolean; @Input() readonlyMemory: boolean; - constructor() {} + ngOnInit() { + this.parentForm + .get("cpu") + .setValidators([ + Validators.required, + Validators.pattern(/^[0-9]+([.][0-9]+)?$/), + Validators.min(0.5) + ]); + this.parentForm + .get("memory") + .setValidators([ + Validators.required, + Validators.pattern(/^[0-9]+([.][0-9]+)?(Gi)$/), + Validators.min(1) + ]); + this.parentForm.setValidators(resourcesValidator()); + } + + parentErrorKeysErrorStateMatcher(keys: string | string[]) { + const arrKeys = ([] as string[]).concat(keys); + return { + isErrorState( + control: FormControl, + form: FormGroupDirective | NgForm + ): boolean { + return ( + (control.dirty && control.invalid) || + (form.dirty && arrKeys.some(key => form.hasError(key))) + ); + } + }; + } + + cpuErrorMessage(): string { + let e: any; + const errs = this.parentForm.get("cpu").errors || {}; + + if (errs.required) return "Specify number of CPUs"; + if (errs.pattern) return "Must be a number"; + if ((e = errs.min)) return `Specify at least ${e.min} CPUs`; + + if (this.parentForm.hasError("maxCpu")) { + e = this.parentForm.errors.maxCpu; + return ( + `Can't exceed ${e.max} CPUs` + + (e.gpu > 0 ? ` with ${e.gpu} GPU(s) selected` : "") + ); + } + } + + memoryErrorMessage(): string { + let e: any; + const errs = this.parentForm.get("memory").errors || {}; + + if (errs.required || errs.pattern) + return "Specify amount of memory (e.g. 2Gi)"; + if ((e = errs.min)) return `Specify at least ${e.min}Gi of memory`; - ngOnInit() {} + if (this.parentForm.hasError("maxRam")) { + e = this.parentForm.errors.maxRam; + return ( + `Can't exceed ${e.max}Gi of memory` + + (e.gpu > 0 ? ` with ${e.gpu} GPU(s) selected` : "") + ); + } + } } diff --git a/frontend/src/app/resource-form/form-workspace-volume/form-workspace-volume.component.html b/frontend/src/app/resource-form/form-workspace-volume/form-workspace-volume.component.html index 4d7c879a..fe39634e 100644 --- a/frontend/src/app/resource-form/form-workspace-volume/form-workspace-volume.component.html +++ b/frontend/src/app/resource-form/form-workspace-volume/form-workspace-volume.component.html @@ -18,6 +18,7 @@

[ephemeral]="parentForm.value.noWorkspace" [namespace]="parentForm.value.namespace" [defaultStorageClass]="defaultStorageClass" + [sizes]="['4Gi', '8Gi', '16Gi', '32Gi']" > diff --git a/frontend/src/app/resource-form/volume/volume.component.html b/frontend/src/app/resource-form/volume/volume.component.html index bfc2efec..d6c6b671 100644 --- a/frontend/src/app/resource-form/volume/volume.component.html +++ b/frontend/src/app/resource-form/volume/volume.component.html @@ -22,13 +22,20 @@ }} + + {{ showNameError() }} + Size - - + + {{ + sizeOptions + }} + + diff --git a/frontend/src/app/resource-form/volume/volume.component.ts b/frontend/src/app/resource-form/volume/volume.component.ts index be8bdbcc..aa0a399b 100644 --- a/frontend/src/app/resource-form/volume/volume.component.ts +++ b/frontend/src/app/resource-form/volume/volume.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit, Input, OnDestroy } from "@angular/core"; -import { FormGroup } from "@angular/forms"; +import { FormGroup, Validators } from "@angular/forms"; import { Volume } from "src/app/utils/types"; import { Subscription } from "rxjs"; @@ -14,13 +14,14 @@ export class VolumeComponent implements OnInit, OnDestroy { currentPVC: Volume; existingPVCs: Set = new Set(); - + subscriptions = new Subscription(); // ----- @Input Parameters ----- @Input() volume: FormGroup; @Input() namespace: string; - + @Input() sizes: Set; + @Input() get notebookName() { return this._notebookName; @@ -102,8 +103,11 @@ export class VolumeComponent implements OnInit, OnDestroy { constructor() {} ngOnInit() { - // type - this.subscriptions.add( + this.volume + .get("name") + .setValidators([Validators.required, Validators.pattern(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/)]); + + this.subscriptions.add( this.volume.get("type").valueChanges.subscribe((type: string) => { this.setVolumeType(type); }) @@ -158,4 +162,17 @@ export class VolumeComponent implements OnInit, OnDestroy { this.volume.controls.name.setValue(this.currentVolName); } } + + showNameError() { + const volumeName = this.volume.get("name"); + + if (volumeName.hasError("required")) { + return `The volume name can't be empty` + } + if (volumeName.hasError("pattern")) { + return `The volume name can only contain lowercase alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character`; + } + } + + } diff --git a/frontend/src/app/utils/common.ts b/frontend/src/app/utils/common.ts index 9f611fc3..fe2e6bf2 100644 --- a/frontend/src/app/utils/common.ts +++ b/frontend/src/app/utils/common.ts @@ -86,7 +86,7 @@ export function addDataVolume( value: '{notebook-name}-vol-' + (l + 1), }, size: { - value: '10Gi', + value: '16Gi', }, mountPath: { value: '/home/jovyan/data-vol-' + (l + 1), diff --git a/samples/spawner_ui_config.yaml b/samples/spawner_ui_config.yaml index ccd033e3..668484ec 100644 --- a/samples/spawner_ui_config.yaml +++ b/samples/spawner_ui_config.yaml @@ -59,7 +59,7 @@ spawnerFormDefaults: value: 'workspace-{notebook-name}' size: # The Size of the Workspace Volume (in Gi) - value: '10Gi' + value: '4Gi' mountPath: # The Path that the Workspace Volume will be mounted value: /home/jovyan @@ -87,7 +87,7 @@ spawnerFormDefaults: # name: # value: '{notebook-name}-vol-1' # size: - # value: '10Gi' + # value: '16Gi' # class: # value: standard # mountPath: @@ -102,7 +102,7 @@ spawnerFormDefaults: # name: # value: '{notebook-name}-vol-2' # size: - # value: '10Gi' + # value: '16Gi' # mountPath: # value: /home/jovyan/vol-2 # accessModes: