[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

@@ -543,6 +543,11 @@ paths:
cpuLimit:
description: CPU limit for the deployed container
type: string
volumeMounts:
description: Volume to mount into the container filesystem
type: array
items:
$ref: '#/components/schemas/VolumeMount'
responses:
'200':
description: container was successfully added to the devfile
@@ -813,6 +818,101 @@ paths:
example:
message: "Error deleting the resource"
/devstate/volume:
post:
tags:
- devstate
description: Add a new Volume to the Devfile
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
description: Name of the volume
type: string
size:
description: Minimal size of the volume
type: string
ephemeral:
description: True if the Volume is Ephemeral
type: boolean
responses:
'200':
description: volume was successfully added to the devfile
content:
application/json:
schema:
$ref: '#/components/schemas/DevfileContent'
example:
{
"content": "schemaVersion: 2.2.0\n",
"commands": [],
"containers": [],
"images": [],
"resources": [],
"events": {
"preStart": null,
"postStart": null,
"preStop": null,
"postStop": null
},
"metadata": {
"name": "",
"version": "",
"displayName": "",
description": "",
"tags": "",
"architectures": "",
"icon": "",
"globalMemoryLimit": "",
"projectType": "",
"language": "",
"website": "",
"provider": "",
"supportUrl": ""
}
}
'500':
description: Error adding the volume
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
example:
message: "Error adding the volume"
/devstate/volume/{volumeName}:
delete:
tags:
- devstate
description: "Delete a volume from the Devfile"
parameters:
- name: volumeName
in: path
description: Volume name to delete
required: true
schema:
type: string
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralSuccess'
example:
message: "Volume has been deleted"
description: "Volume has been deleted"
'500':
description: Error deleting the volume
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
example:
message: "Error deleting the volume"
/devstate/applyCommand:
post:
tags:
@@ -1315,6 +1415,7 @@ components:
- containers
- images
- resources
- volumes
- events
- metadata
properties:
@@ -1336,6 +1437,10 @@ components:
type: array
items:
$ref: '#/components/schemas/Resource'
volumes:
type: array
items:
$ref: '#/components/schemas/Volume'
events:
$ref: '#/components/schemas/Events'
metadata:
@@ -1416,6 +1521,7 @@ components:
- memoryLimit
- cpuRequest
- cpuLimit
- volumeMounts
properties:
name:
type: string
@@ -1437,6 +1543,20 @@ components:
type: string
cpuLimit:
type: string
volumeMounts:
type: array
items:
$ref: '#/components/schemas/VolumeMount'
VolumeMount:
type: object
required:
- name
- path
properties:
name:
type: string
path:
type: string
Image:
type: object
required:
@@ -1472,6 +1592,17 @@ components:
type: string
uri:
type: string
Volume:
type: object
required:
- name
properties:
name:
type: string
ephemeral:
type: boolean
size:
type: string
Events:
type: object
properties:

View File

@@ -20,6 +20,7 @@ go/model__devstate_exec_command_post_request.go
go/model__devstate_image_post_request.go
go/model__devstate_quantity_valid_post_request.go
go/model__devstate_resource_post_request.go
go/model__devstate_volume_post_request.go
go/model__instance_get_200_response.go
go/model_apply_command.go
go/model_command.go
@@ -38,3 +39,5 @@ go/model_metadata.go
go/model_metadata_request.go
go/model_resource.go
go/model_telemetry_response.go
go/model_volume.go
go/model_volume_mount.go

View File

@@ -51,6 +51,8 @@ type DevstateApiRouter interface {
DevstateQuantityValidPost(http.ResponseWriter, *http.Request)
DevstateResourcePost(http.ResponseWriter, *http.Request)
DevstateResourceResourceNameDelete(http.ResponseWriter, *http.Request)
DevstateVolumePost(http.ResponseWriter, *http.Request)
DevstateVolumeVolumeNameDelete(http.ResponseWriter, *http.Request)
}
// DefaultApiServicer defines the api actions for the DefaultApi service
@@ -92,4 +94,6 @@ type DevstateApiServicer interface {
DevstateQuantityValidPost(context.Context, DevstateQuantityValidPostRequest) (ImplResponse, error)
DevstateResourcePost(context.Context, DevstateResourcePostRequest) (ImplResponse, error)
DevstateResourceResourceNameDelete(context.Context, string) (ImplResponse, error)
DevstateVolumePost(context.Context, DevstateVolumePostRequest) (ImplResponse, error)
DevstateVolumeVolumeNameDelete(context.Context, string) (ImplResponse, error)
}

View File

@@ -170,6 +170,18 @@ func (c *DevstateApiController) Routes() Routes {
"/api/v1/devstate/resource/{resourceName}",
c.DevstateResourceResourceNameDelete,
},
{
"DevstateVolumePost",
strings.ToUpper("Post"),
"/api/v1/devstate/volume",
c.DevstateVolumePost,
},
{
"DevstateVolumeVolumeNameDelete",
strings.ToUpper("Delete"),
"/api/v1/devstate/volume/{volumeName}",
c.DevstateVolumeVolumeNameDelete,
},
}
}
@@ -578,3 +590,42 @@ func (c *DevstateApiController) DevstateResourceResourceNameDelete(w http.Respon
EncodeJSONResponse(result.Body, &result.Code, w)
}
// DevstateVolumePost -
func (c *DevstateApiController) DevstateVolumePost(w http.ResponseWriter, r *http.Request) {
devstateVolumePostRequestParam := DevstateVolumePostRequest{}
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if err := d.Decode(&devstateVolumePostRequestParam); err != nil {
c.errorHandler(w, r, &ParsingError{Err: err}, nil)
return
}
if err := AssertDevstateVolumePostRequestRequired(devstateVolumePostRequestParam); err != nil {
c.errorHandler(w, r, err, nil)
return
}
result, err := c.service.DevstateVolumePost(r.Context(), devstateVolumePostRequestParam)
// If an error occurred, encode the error with the status code
if err != nil {
c.errorHandler(w, r, err, &result)
return
}
// If no error, encode the body and the result code
EncodeJSONResponse(result.Body, &result.Code, w)
}
// DevstateVolumeVolumeNameDelete -
func (c *DevstateApiController) DevstateVolumeVolumeNameDelete(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
volumeNameParam := params["volumeName"]
result, err := c.service.DevstateVolumeVolumeNameDelete(r.Context(), volumeNameParam)
// If an error occurred, encode the error with the status code
if err != nil {
c.errorHandler(w, r, err, &result)
return
}
// If no error, encode the body and the result code
EncodeJSONResponse(result.Body, &result.Code, w)
}

View File

@@ -34,10 +34,18 @@ type DevstateContainerPostRequest struct {
// CPU limit for the deployed container
CpuLimit string `json:"cpuLimit,omitempty"`
// Volume to mount into the container filesystem
VolumeMounts []VolumeMount `json:"volumeMounts,omitempty"`
}
// AssertDevstateContainerPostRequestRequired checks if the required fields are not zero-ed
func AssertDevstateContainerPostRequestRequired(obj DevstateContainerPostRequest) error {
for _, el := range obj.VolumeMounts {
if err := AssertVolumeMountRequired(el); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,39 @@
/*
* odo dev
*
* API interface for 'odo dev'
*
* API version: 0.1
* Generated by: OpenAPI Generator (https://openapi-generator.tech)
*/
package openapi
type DevstateVolumePostRequest struct {
// Name of the volume
Name string `json:"name,omitempty"`
// Minimal size of the volume
Size string `json:"size,omitempty"`
// True if the Volume is Ephemeral
Ephemeral bool `json:"ephemeral,omitempty"`
}
// AssertDevstateVolumePostRequestRequired checks if the required fields are not zero-ed
func AssertDevstateVolumePostRequestRequired(obj DevstateVolumePostRequest) error {
return nil
}
// AssertRecurseDevstateVolumePostRequestRequired recursively checks if required fields are not zero-ed in a nested slice.
// Accepts only nested slice of DevstateVolumePostRequest (e.g. [][]DevstateVolumePostRequest), otherwise ErrTypeAssertionError is thrown.
func AssertRecurseDevstateVolumePostRequestRequired(objSlice interface{}) error {
return AssertRecurseInterfaceRequired(objSlice, func(obj interface{}) error {
aDevstateVolumePostRequest, ok := obj.(DevstateVolumePostRequest)
if !ok {
return ErrTypeAssertionError
}
return AssertDevstateVolumePostRequestRequired(aDevstateVolumePostRequest)
})
}

View File

@@ -25,6 +25,8 @@ type Container struct {
CpuRequest string `json:"cpuRequest"`
CpuLimit string `json:"cpuLimit"`
VolumeMounts []VolumeMount `json:"volumeMounts"`
}
// AssertContainerRequired checks if the required fields are not zero-ed
@@ -38,6 +40,7 @@ func AssertContainerRequired(obj Container) error {
"memoryLimit": obj.MemoryLimit,
"cpuRequest": obj.CpuRequest,
"cpuLimit": obj.CpuLimit,
"volumeMounts": obj.VolumeMounts,
}
for name, el := range elements {
if isZero := IsZeroValue(el); isZero {
@@ -45,6 +48,11 @@ func AssertContainerRequired(obj Container) error {
}
}
for _, el := range obj.VolumeMounts {
if err := AssertVolumeMountRequired(el); err != nil {
return err
}
}
return nil
}

View File

@@ -20,6 +20,8 @@ type DevfileContent struct {
Resources []Resource `json:"resources"`
Volumes []Volume `json:"volumes"`
Events Events `json:"events"`
Metadata Metadata `json:"metadata"`
@@ -33,6 +35,7 @@ func AssertDevfileContentRequired(obj DevfileContent) error {
"containers": obj.Containers,
"images": obj.Images,
"resources": obj.Resources,
"volumes": obj.Volumes,
"events": obj.Events,
"metadata": obj.Metadata,
}
@@ -62,6 +65,11 @@ func AssertDevfileContentRequired(obj DevfileContent) error {
return err
}
}
for _, el := range obj.Volumes {
if err := AssertVolumeRequired(el); err != nil {
return err
}
}
if err := AssertEventsRequired(obj.Events); err != nil {
return err
}

44
pkg/apiserver-gen/go/model_volume.go generated Normal file
View File

@@ -0,0 +1,44 @@
/*
* odo dev
*
* API interface for 'odo dev'
*
* API version: 0.1
* Generated by: OpenAPI Generator (https://openapi-generator.tech)
*/
package openapi
type Volume struct {
Name string `json:"name"`
Ephemeral bool `json:"ephemeral,omitempty"`
Size string `json:"size,omitempty"`
}
// AssertVolumeRequired checks if the required fields are not zero-ed
func AssertVolumeRequired(obj Volume) error {
elements := map[string]interface{}{
"name": obj.Name,
}
for name, el := range elements {
if isZero := IsZeroValue(el); isZero {
return &RequiredError{Field: name}
}
}
return nil
}
// AssertRecurseVolumeRequired recursively checks if required fields are not zero-ed in a nested slice.
// Accepts only nested slice of Volume (e.g. [][]Volume), otherwise ErrTypeAssertionError is thrown.
func AssertRecurseVolumeRequired(objSlice interface{}) error {
return AssertRecurseInterfaceRequired(objSlice, func(obj interface{}) error {
aVolume, ok := obj.(Volume)
if !ok {
return ErrTypeAssertionError
}
return AssertVolumeRequired(aVolume)
})
}

View File

@@ -0,0 +1,43 @@
/*
* odo dev
*
* API interface for 'odo dev'
*
* API version: 0.1
* Generated by: OpenAPI Generator (https://openapi-generator.tech)
*/
package openapi
type VolumeMount struct {
Name string `json:"name"`
Path string `json:"path"`
}
// AssertVolumeMountRequired checks if the required fields are not zero-ed
func AssertVolumeMountRequired(obj VolumeMount) error {
elements := map[string]interface{}{
"name": obj.Name,
"path": obj.Path,
}
for name, el := range elements {
if isZero := IsZeroValue(el); isZero {
return &RequiredError{Field: name}
}
}
return nil
}
// AssertRecurseVolumeMountRequired recursively checks if required fields are not zero-ed in a nested slice.
// Accepts only nested slice of VolumeMount (e.g. [][]VolumeMount), otherwise ErrTypeAssertionError is thrown.
func AssertRecurseVolumeMountRequired(objSlice interface{}) error {
return AssertRecurseInterfaceRequired(objSlice, func(obj interface{}) error {
aVolumeMount, ok := obj.(VolumeMount)
if !ok {
return ErrTypeAssertionError
}
return AssertVolumeMountRequired(aVolumeMount)
})
}

View File

@@ -19,6 +19,7 @@ func (s *DevstateApiService) DevstateContainerPost(ctx context.Context, containe
container.MemLimit,
container.CpuReq,
container.CpuLimit,
container.VolumeMounts,
)
if err != nil {
return openapi.Response(http.StatusInternalServerError, openapi.GeneralError{
@@ -90,6 +91,30 @@ func (s *DevstateApiService) DevstateResourceResourceNameDelete(ctx context.Cont
return openapi.Response(http.StatusOK, newContent), nil
}
func (s *DevstateApiService) DevstateVolumePost(ctx context.Context, volume openapi.DevstateVolumePostRequest) (openapi.ImplResponse, error) {
newContent, err := s.devfileState.AddVolume(
volume.Name,
volume.Ephemeral,
volume.Size,
)
if err != nil {
return openapi.Response(http.StatusInternalServerError, openapi.GeneralError{
Message: fmt.Sprintf("Error adding the volume: %s", err),
}), nil
}
return openapi.Response(http.StatusOK, newContent), nil
}
func (s *DevstateApiService) DevstateVolumeVolumeNameDelete(ctx context.Context, volumeName string) (openapi.ImplResponse, error) {
newContent, err := s.devfileState.DeleteVolume(volumeName)
if err != nil {
return openapi.Response(http.StatusInternalServerError, openapi.GeneralError{
Message: fmt.Sprintf("Error deleting the volume: %s", err),
}), nil
}
return openapi.Response(http.StatusOK, newContent), nil
}
func (s *DevstateApiService) DevstateApplyCommandPost(ctx context.Context, command openapi.DevstateApplyCommandPostRequest) (openapi.ImplResponse, error) {
newContent, err := s.devfileState.AddApplyCommand(
command.Name,

View File

@@ -6,6 +6,7 @@ import (
"github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2"
"github.com/google/go-cmp/cmp"
. "github.com/redhat-developer/odo/pkg/apiserver-gen/go"
openapi "github.com/redhat-developer/odo/pkg/apiserver-gen/go"
)
func TestDevfileState_AddExecCommand(t *testing.T) {
@@ -36,6 +37,7 @@ func TestDevfileState_AddExecCommand(t *testing.T) {
"2Gi",
"100m",
"200m",
nil,
)
if err != nil {
t.Fatal(err)
@@ -96,10 +98,12 @@ schemaVersion: 2.2.0
MemoryLimit: "2Gi",
CpuRequest: "100m",
CpuLimit: "200m",
VolumeMounts: []openapi.VolumeMount{},
},
},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{},
},
},
@@ -186,6 +190,7 @@ schemaVersion: 2.2.0
},
},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{},
},
},
@@ -235,6 +240,7 @@ func TestDevfileState_AddCompositeCommand(t *testing.T) {
"2Gi",
"100m",
"200m",
nil,
)
if err != nil {
t.Fatal(err)
@@ -313,10 +319,12 @@ schemaVersion: 2.2.0
MemoryLimit: "2Gi",
CpuRequest: "100m",
CpuLimit: "200m",
VolumeMounts: []openapi.VolumeMount{},
},
},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{},
},
},
@@ -364,6 +372,7 @@ func TestDevfileState_DeleteCommand(t *testing.T) {
"2Gi",
"100m",
"200m",
nil,
)
if err != nil {
t.Fatal(err)
@@ -412,10 +421,12 @@ schemaVersion: 2.2.0
MemoryLimit: "2Gi",
CpuRequest: "100m",
CpuLimit: "200m",
VolumeMounts: []openapi.VolumeMount{},
},
},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{},
},
},
@@ -627,6 +638,7 @@ schemaVersion: 2.2.0
Containers: []Container{},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
},
},
// TODO: Add test cases.
@@ -713,6 +725,7 @@ schemaVersion: 2.2.0
Containers: []Container{},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
},
},
// TODO: Add test cases.
@@ -801,6 +814,7 @@ schemaVersion: 2.2.0
Containers: []Container{},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
},
},
// TODO: Add test cases.

View File

@@ -9,7 +9,24 @@ import (
. "github.com/redhat-developer/odo/pkg/apiserver-gen/go"
)
func (o *DevfileState) AddContainer(name string, image string, command []string, args []string, memRequest string, memLimit string, cpuRequest string, cpuLimit string) (DevfileContent, error) {
func (o *DevfileState) AddContainer(
name string,
image string,
command []string,
args []string,
memRequest string,
memLimit string,
cpuRequest string,
cpuLimit string,
volumeMounts []VolumeMount,
) (DevfileContent, error) {
v1alpha2VolumeMounts := make([]v1alpha2.VolumeMount, 0, len(volumeMounts))
for _, vm := range volumeMounts {
v1alpha2VolumeMounts = append(v1alpha2VolumeMounts, v1alpha2.VolumeMount{
Name: vm.Name,
Path: vm.Path,
})
}
container := v1alpha2.Component{
Name: name,
ComponentUnion: v1alpha2.ComponentUnion{
@@ -22,6 +39,7 @@ func (o *DevfileState) AddContainer(name string, image string, command []string,
MemoryLimit: memLimit,
CpuRequest: cpuRequest,
CpuLimit: cpuLimit,
VolumeMounts: v1alpha2VolumeMounts,
},
},
},
@@ -184,3 +202,56 @@ func (o *DevfileState) checkResourceUsed(name string) error {
}
return nil
}
func (o *DevfileState) AddVolume(name string, ephemeral bool, size string) (DevfileContent, error) {
volume := v1alpha2.Component{
Name: name,
ComponentUnion: v1alpha2.ComponentUnion{
Volume: &v1alpha2.VolumeComponent{
Volume: v1alpha2.Volume{
Ephemeral: &ephemeral,
Size: size,
},
},
},
}
err := o.Devfile.Data.AddComponents([]v1alpha2.Component{volume})
if err != nil {
return DevfileContent{}, err
}
return o.GetContent()
}
func (o *DevfileState) DeleteVolume(name string) (DevfileContent, error) {
err := o.checkVolumeUsed(name)
if err != nil {
return DevfileContent{}, fmt.Errorf("error deleting volume %q: %w", name, err)
}
// TODO check if it is a Volume, not another component
err = o.Devfile.Data.DeleteComponent(name)
if err != nil {
return DevfileContent{}, err
}
return o.GetContent()
}
func (o *DevfileState) checkVolumeUsed(name string) error {
containers, err := o.Devfile.Data.GetComponents(common.DevfileOptions{
ComponentOptions: common.ComponentOptions{
ComponentType: v1alpha2.ContainerComponentType,
},
})
if err != nil {
return err
}
for _, container := range containers {
for _, mount := range container.Container.VolumeMounts {
if mount.Name == name {
return fmt.Errorf("volume %q is mounted by Container %q", name, container.Name)
}
}
}
return nil
}

View File

@@ -5,18 +5,20 @@ import (
"github.com/google/go-cmp/cmp"
. "github.com/redhat-developer/odo/pkg/apiserver-gen/go"
openapi "github.com/redhat-developer/odo/pkg/apiserver-gen/go"
)
func TestDevfileState_AddContainer(t *testing.T) {
type args struct {
name string
image string
command []string
args []string
memRequest string
memLimit string
cpuRequest string
cpuLimit string
name string
image string
command []string
args []string
memRequest string
memLimit string
cpuRequest string
cpuLimit string
volumeMounts []openapi.VolumeMount
}
tests := []struct {
name string
@@ -39,6 +41,12 @@ func TestDevfileState_AddContainer(t *testing.T) {
memLimit: "2Gi",
cpuRequest: "100m",
cpuLimit: "200m",
volumeMounts: []openapi.VolumeMount{
{
Name: "vol1",
Path: "/mnt/volume1",
},
},
},
want: DevfileContent{
Content: `components:
@@ -54,6 +62,9 @@ func TestDevfileState_AddContainer(t *testing.T) {
image: an-image
memoryLimit: 2Gi
memoryRequest: 1Gi
volumeMounts:
- name: vol1
path: /mnt/volume1
name: a-name
metadata: {}
schemaVersion: 2.2.0
@@ -69,10 +80,17 @@ schemaVersion: 2.2.0
MemoryLimit: "2Gi",
CpuRequest: "100m",
CpuLimit: "200m",
VolumeMounts: []openapi.VolumeMount{
{
Name: "vol1",
Path: "/mnt/volume1",
},
},
},
},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{},
},
},
@@ -81,7 +99,7 @@ schemaVersion: 2.2.0
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := tt.state()
got, err := o.AddContainer(tt.args.name, tt.args.image, tt.args.command, tt.args.args, tt.args.memRequest, tt.args.memLimit, tt.args.cpuRequest, tt.args.cpuLimit)
got, err := o.AddContainer(tt.args.name, tt.args.image, tt.args.command, tt.args.args, tt.args.memRequest, tt.args.memLimit, tt.args.cpuRequest, tt.args.cpuLimit, tt.args.volumeMounts)
if (err != nil) != tt.wantErr {
t.Errorf("DevfileState.AddContainer() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -120,6 +138,7 @@ func TestDevfileState_DeleteContainer(t *testing.T) {
"2Gi",
"100m",
"200m",
nil,
)
if err != nil {
t.Fatal(err)
@@ -137,6 +156,7 @@ schemaVersion: 2.2.0
Containers: []Container{},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{},
},
},
@@ -153,6 +173,7 @@ schemaVersion: 2.2.0
"2Gi",
"100m",
"200m",
nil,
)
if err != nil {
t.Fatal(err)
@@ -242,6 +263,7 @@ schemaVersion: 2.2.0
},
},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{},
},
},
@@ -304,6 +326,7 @@ schemaVersion: 2.2.0
Containers: []Container{},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{},
},
},
@@ -389,7 +412,8 @@ schemaVersion: 2.2.0
Uri: "an-uri",
},
},
Events: Events{},
Volumes: []Volume{},
Events: Events{},
},
},
{
@@ -418,7 +442,8 @@ schemaVersion: 2.2.0
Inlined: "inline resource...",
},
},
Events: Events{},
Volumes: []Volume{},
Events: Events{},
},
},
// TODO: Add test cases.
@@ -477,6 +502,7 @@ schemaVersion: 2.2.0
Containers: []Container{},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{},
},
},
@@ -519,3 +545,148 @@ schemaVersion: 2.2.0
})
}
}
func TestDevfileState_AddVolume(t *testing.T) {
type args struct {
name string
size string
ephemeral bool
}
tests := []struct {
name string
state func() DevfileState
args args
want DevfileContent
wantErr bool
}{
{
name: "Add a volume",
state: func() DevfileState {
return NewDevfileState()
},
args: args{
name: "a-name",
size: "1Gi",
ephemeral: true,
},
want: DevfileContent{
Content: `components:
- name: a-name
volume:
ephemeral: true
size: 1Gi
metadata: {}
schemaVersion: 2.2.0
`,
Commands: []Command{},
Containers: []Container{},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{
{
Name: "a-name",
Size: "1Gi",
Ephemeral: true,
},
},
Events: Events{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := tt.state()
got, err := o.AddVolume(tt.args.name, tt.args.ephemeral, tt.args.size)
if (err != nil) != tt.wantErr {
t.Errorf("DevfileState.AddVolume() error = %v, wantErr %v", err, tt.wantErr)
return
}
if diff := cmp.Diff(tt.want.Content, got.Content); diff != "" {
t.Errorf("DevfileState.AddVolume() mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("DevfileState.AddVolume() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestDevfileState_DeleteVolume(t *testing.T) {
type args struct {
name string
}
tests := []struct {
name string
state func(t *testing.T) DevfileState
args args
want DevfileContent
wantErr bool
}{
{
name: "Delete an existing volume",
state: func(t *testing.T) DevfileState {
state := NewDevfileState()
_, err := state.AddVolume(
"a-name",
true,
"1Gi",
)
if err != nil {
t.Fatal(err)
}
return state
},
args: args{
name: "a-name",
},
want: DevfileContent{
Content: `metadata: {}
schemaVersion: 2.2.0
`,
Commands: []Command{},
Containers: []Container{},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{},
},
},
{
name: "Delete a non existing resource",
state: func(t *testing.T) DevfileState {
state := NewDevfileState()
_, err := state.AddVolume(
"a-name",
true,
"1Gi",
)
if err != nil {
t.Fatal(err)
}
return state
},
args: args{
name: "another-name",
},
want: DevfileContent{},
wantErr: true,
},
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := tt.state(t)
got, err := o.DeleteVolume(tt.args.name)
if (err != nil) != tt.wantErr {
t.Errorf("DevfileState.DeleteVolume() error = %v, wantErr %v", err, tt.wantErr)
return
}
if diff := cmp.Diff(tt.want.Content, got.Content); diff != "" {
t.Errorf("DevfileState.DeleteVolume() mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("DevfileState.DeleteVolume() mismatch (-want +got):\n%s", diff)
}
})
}
}

View File

@@ -16,97 +16,6 @@ const (
SEPARATOR = ","
)
/*
type DevfileContent struct {
Content string `json:"content"`
Commands []Command `json:"commands"`
Containers []Container `json:"containers"`
Images []Image `json:"images"`
Resources []Resource `json:"resources"`
Events Events `json:"events"`
Metadata Metadata `json:"metadata"`
}
type Metadata struct {
Name string `json:"name"`
Version string `json:"version"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Tags string `json:"tags"`
Architectures string `json:"architectures"`
Icon string `json:"icon"`
GlobalMemoryLimit string `json:"globalMemoryLimit"`
ProjectType string `json:"projectType"`
Language string `json:"language"`
Website string `json:"website"`
Provider string `json:"provider"`
SupportUrl string `json:"supportUrl"`
}
type Command struct {
Name string `json:"name"`
Group string `json:"group"`
Default bool `json:"default"`
Type string `json:"type"`
Exec *ExecCommand `json:"exec"`
Apply *ApplyCommand `json:"apply"`
Image *ImageCommand `json:"image"`
Composite *CompositeCommand `json:"composite"`
}
type ExecCommand struct {
Component string `json:"component"`
CommandLine string `json:"commandLine"`
WorkingDir string `json:"workingDir"`
HotReloadCapable bool `json:"hotReloadCapable"`
}
type ApplyCommand struct {
Component string `json:"component"`
}
type ImageCommand struct {
Component string `json:"component"`
}
type CompositeCommand struct {
Commands []string `json:"commands"`
Parallel bool `json:"parallel"`
}
type Container struct {
Name string `json:"name"`
Image string `json:"image"`
Command []string `json:"command"`
Args []string `json:"args"`
MemoryRequest string `json:"memoryRequest"`
MemoryLimit string `json:"memoryLimit"`
CpuRequest string `json:"cpuRequest"`
CpuLimit string `json:"cpuLimit"`
}
type Image struct {
Name string `json:"name"`
ImageName string `json:"imageName"`
Args []string `json:"args"`
BuildContext string `json:"buildContext"`
RootRequired bool `json:"rootRequired"`
URI string `json:"uri"`
}
type Resource struct {
Name string `json:"name"`
Inlined string `json:"inlined"`
URI string `json:"uri"`
}
type Events struct {
PreStart []string `json:"preStart"`
PostStart []string `json:"postStart"`
PreStop []string `json:"preStop"`
PostStop []string `json:"postStop"`
}
*/
// getContent returns the YAML content of the global devfile as string
func (o *DevfileState) GetContent() (DevfileContent, error) {
err := o.Devfile.WriteYamlDevfile()
@@ -137,12 +46,18 @@ func (o *DevfileState) GetContent() (DevfileContent, error) {
return DevfileContent{}, errors.New("error getting Kubernetes resources")
}
volumes, err := o.getVolumes()
if err != nil {
return DevfileContent{}, errors.New("error getting volumes")
}
return DevfileContent{
Content: string(result),
Commands: commands,
Containers: containers,
Images: images,
Resources: resources,
Volumes: volumes,
Events: o.getEvents(),
Metadata: o.getMetadata(),
}, nil
@@ -255,11 +170,23 @@ func (o *DevfileState) getContainers() ([]Container, error) {
MemoryLimit: container.ComponentUnion.Container.MemoryLimit,
CpuRequest: container.ComponentUnion.Container.CpuRequest,
CpuLimit: container.ComponentUnion.Container.CpuLimit,
VolumeMounts: o.getVolumeMounts(container.Container.Container),
})
}
return result, nil
}
func (o *DevfileState) getVolumeMounts(container v1alpha2.Container) []VolumeMount {
result := make([]VolumeMount, 0, len(container.VolumeMounts))
for _, vm := range container.VolumeMounts {
result = append(result, VolumeMount{
Name: vm.Name,
Path: vm.Path,
})
}
return result
}
func (o *DevfileState) getImages() ([]Image, error) {
images, err := o.Devfile.Data.GetComponents(common.DevfileOptions{
ComponentOptions: common.ComponentOptions{
@@ -303,6 +230,26 @@ func (o *DevfileState) getResources() ([]Resource, error) {
return result, nil
}
func (o *DevfileState) getVolumes() ([]Volume, error) {
volumes, err := o.Devfile.Data.GetComponents(common.DevfileOptions{
ComponentOptions: common.ComponentOptions{
ComponentType: v1alpha2.VolumeComponentType,
},
})
if err != nil {
return nil, err
}
result := make([]Volume, 0, len(volumes))
for _, volume := range volumes {
result = append(result, Volume{
Name: volume.Name,
Ephemeral: *volume.Volume.Ephemeral,
Size: volume.Volume.Size,
})
}
return result, nil
}
func (o *DevfileState) getEvents() Events {
events := o.Devfile.Data.GetEvents()
return Events{

View File

@@ -24,6 +24,7 @@ func TestDevfileState_GetContent(t *testing.T) {
Containers: []Container{},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{},
},
},

View File

@@ -39,6 +39,7 @@ schemaVersion: 2.2.0
Containers: []Container{},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{
PreStart: []string{"command1"},
},
@@ -70,6 +71,7 @@ schemaVersion: 2.2.0
Containers: []Container{},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
Events: Events{
PreStart: []string{"command1"},
PostStart: []string{"command2"},

View File

@@ -75,6 +75,7 @@ schemaVersion: 2.2.0
Containers: []Container{},
Images: []Image{},
Resources: []Resource{},
Volumes: []Volume{},
Metadata: Metadata{
Name: "a-name",
Version: "v1.1.1",

View File

@@ -543,6 +543,11 @@ paths:
cpuLimit:
description: CPU limit for the deployed container
type: string
volumeMounts:
description: Volume to mount into the container filesystem
type: array
items:
$ref: '#/components/schemas/VolumeMount'
responses:
'200':
description: container was successfully added to the devfile
@@ -813,6 +818,101 @@ paths:
example:
message: "Error deleting the resource"
/devstate/volume:
post:
tags:
- devstate
description: Add a new Volume to the Devfile
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
description: Name of the volume
type: string
size:
description: Minimal size of the volume
type: string
ephemeral:
description: True if the Volume is Ephemeral
type: boolean
responses:
'200':
description: volume was successfully added to the devfile
content:
application/json:
schema:
$ref: '#/components/schemas/DevfileContent'
example:
{
"content": "schemaVersion: 2.2.0\n",
"commands": [],
"containers": [],
"images": [],
"resources": [],
"events": {
"preStart": null,
"postStart": null,
"preStop": null,
"postStop": null
},
"metadata": {
"name": "",
"version": "",
"displayName": "",
description": "",
"tags": "",
"architectures": "",
"icon": "",
"globalMemoryLimit": "",
"projectType": "",
"language": "",
"website": "",
"provider": "",
"supportUrl": ""
}
}
'500':
description: Error adding the volume
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
example:
message: "Error adding the volume"
/devstate/volume/{volumeName}:
delete:
tags:
- devstate
description: "Delete a volume from the Devfile"
parameters:
- name: volumeName
in: path
description: Volume name to delete
required: true
schema:
type: string
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralSuccess'
example:
message: "Volume has been deleted"
description: "Volume has been deleted"
'500':
description: Error deleting the volume
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
example:
message: "Error deleting the volume"
/devstate/applyCommand:
post:
tags:
@@ -1315,6 +1415,7 @@ components:
- containers
- images
- resources
- volumes
- events
- metadata
properties:
@@ -1336,6 +1437,10 @@ components:
type: array
items:
$ref: '#/components/schemas/Resource'
volumes:
type: array
items:
$ref: '#/components/schemas/Volume'
events:
$ref: '#/components/schemas/Events'
metadata:
@@ -1416,6 +1521,7 @@ components:
- memoryLimit
- cpuRequest
- cpuLimit
- volumeMounts
properties:
name:
type: string
@@ -1437,6 +1543,20 @@ components:
type: string
cpuLimit:
type: string
volumeMounts:
type: array
items:
$ref: '#/components/schemas/VolumeMount'
VolumeMount:
type: object
required:
- name
- path
properties:
name:
type: string
path:
type: string
Image:
type: object
required:
@@ -1472,6 +1592,17 @@ components:
type: string
uri:
type: string
Volume:
type: object
required:
- name
properties:
name:
type: string
ephemeral:
type: boolean
size:
type: string
Events:
type: object
properties:

View File

@@ -11,6 +11,6 @@
<body class="mat-typography">
<div id="loading">Loading, please wait...</div>
<app-root></app-root>
<script src="runtime.1289ea0acffcdc5e.js" type="module"></script><script src="polyfills.8b3b37cedaf377c3.js" type="module"></script><script src="main.ae49ed4fe0fa0670.js" type="module"></script>
<script src="runtime.1289ea0acffcdc5e.js" type="module"></script><script src="polyfills.8b3b37cedaf377c3.js" type="module"></script><script src="main.1046d99cec4375b1.js" type="module"></script>
</body></html>

File diff suppressed because one or more lines are too long

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);
}
}