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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/controllers/application_controller/explorer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ def x_history
'remove_security_group' => :s2,
'rename' => :s2,

'add_volume' => :s2,
'remove_volume' => :s2,

# specials
'perf' => :show,
'download_pdf' => :show,
Expand Down
38 changes: 38 additions & 0 deletions app/controllers/vm_common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,32 @@ def textual_summary_flash_list
helper_method :disable_check?
end

def add_volume
assert_privileges("vm_common_add_volume")
@record = find_record_with_rbac(VmOrTemplate, params[:id])
@edit ||= {}
@in_a_form = true

if @explorer
@refresh_partial = "vm_common/add_volume"
@edit[:explorer] = true
end
end
alias_method :instance_add_volume, :add_volume

def remove_volume
assert_privileges("vm_common_remove_volume")
@record = find_record_with_rbac(VmOrTemplate, params[:id])
@edit ||= {}
@in_a_form = true

if @explorer
@refresh_partial = "vm_common/remove_volume"
@edit[:explorer] = true
end
end
alias_method :instance_remove_volume, :remove_volume

# handle buttons pressed on the button bar
def button
@edit = session[:edit] # Restore @edit for adv search box
Expand Down Expand Up @@ -1297,6 +1323,18 @@ def set_right_cell_vars(options = {})
partial = "layouts/tl_show"
header = _("Timelines for %{virtual_machine} \"%{name}\"") % {:virtual_machine => ui_lookup(:table => table), :name => name}
action = nil
when "add_volume", "instance_add_volume"
partial = "vm_common/add_volume"
header = _("Add Volume to %{vm_or_template} \"%{name}\"") % {
:vm_or_template => ui_lookup(:table => table),
:name => name
}
when "remove_volume", "instance_remove_volume"
partial = "vm_common/remove_volume"
header = _("Remove Volume %{vm_or_template} \"%{name}\"") % {
:vm_or_template => ui_lookup(:table => table),
:name => name
}
else
# now take care of links on summary screen
partial = if @showtype == "details"
Expand Down
36 changes: 36 additions & 0 deletions app/controllers/vm_infra_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,42 @@ def index
redirect_to(:action => 'explorer')
end

def persistentvolumeclaims
@record = find_record_with_rbac(VmOrTemplate, params[:id])
pvcs = @record.persistentvolumeclaims(@record)

render :json => {
:resources => pvcs,
:vm_name => @record.name,
:vm_namespace => @record.location
}
rescue => e
render :json => {:error => e.message}, :status => 500
end

def attached_volumes
@record = find_record_with_rbac(VmOrTemplate, params[:id])
attached = @record.attached_volumes(@record)

render :json => {
:resources => attached,
:vm_name => @record.name,
:vm_namespace => @record.location
}

rescue => e
render :json => {:error => e.message}, :status => 500
end

def storage_class_list
vm = find_record_with_rbac(VmOrTemplate, params[:id])
ems = vm.ext_management_system
storage_class = vm.storage_classes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE storage_classes isn't something that we currently have. We would need to collect them in inventory refresh as part of the provider and persist them in the database for this to work. We do currently have a StorageProfile model which we could use to store these without having to create a new table/model.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @agrare. Just to clarify, storage_classes here is a function I added that fetches the list directly from the OpenShift/KubeVirt API at runtime, it is not a table/model. Would it make sense to use the existing StorageProfile model to persist storage classes list in OpenShift/KubeVirt as you suggested?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's not going to work for a couple of reasons, first this is generic VM code so we can't have provider specific methods in here, also since this is running from the UI role we can't run anything that would hit the live provider since that can only be guaranteed to be accessed via ems_operations role in the proper zone.

StorageProfiles sound like they could be a good match, what data do you need for them other than uid/name?

render :json => {
:resources => storage_class
}
end

private

def features
Expand Down
6 changes: 6 additions & 0 deletions app/helpers/application_helper/button/vm_attach_volume.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class ApplicationHelper::Button::VmAttachVolume < ApplicationHelper::Button::Basic
needs :@record
def visible?
@record.kind_of?(ManageIQ::Providers::Kubevirt::InfraManager::Vm)
end
end
6 changes: 6 additions & 0 deletions app/helpers/application_helper/button/vm_detach_volume.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class ApplicationHelper::Button::VmDetachVolume < ApplicationHelper::Button::Basic
needs :@record
def visible?
@record.kind_of?(ManageIQ::Providers::Kubevirt::InfraManager::Vm)
end
end
14 changes: 14 additions & 0 deletions app/helpers/application_helper/toolbar/x_vm_center.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ class ApplicationHelper::Toolbar::XVmCenter < ApplicationHelper::Toolbar::Basic
t,
:klass => ApplicationHelper::Button::VmSnapshotAdd
),
button(
:instance_add_volume,
'fa fa-hdd-o fa-lg',
t = N_('Attach Volume'),
t,
:klass => ApplicationHelper::Button::VmAttachVolume
),
button(
:instance_remove_volume,
'fa fa-trash-o fa-lg',
t = N_('Detach Volume'),
t,
:klass => ApplicationHelper::Button::VmDetachVolume
),
]
),
])
Expand Down
137 changes: 137 additions & 0 deletions app/javascript/components/vm-infra/add-volume.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import React, { useState, useEffect, useMemo } from "react";
import PropTypes from "prop-types";
import { Grid } from "carbon-components-react";
import MiqFormRenderer from "../../forms/data-driven-form";
import { API } from "../../http_api";
import createSchema from "./add-volume.schema";
import miqRedirectBack from "../../helpers/miq-redirect-back";

const AddVolumeForm = ({ recordId, redirect }) => {
const [state, setState] = useState({
isLoading: true,
volumes: [],
storageClasses: [],
});

const [isSubmitDisabled, setSubmitDisabled] = useState(true);

useEffect(() => {
const fetchPersistentVolumeClaims = async () => {
try {
setState(prev => ({ ...prev, isLoading: true, error: null }));

// Fetch PVCs
const pvcResponse = await fetch(`/vm_infra/persistentvolumeclaims/${recordId}`);
const pvcData = await pvcResponse.json();
if (!pvcResponse.ok) throw new Error((pvcData.error && pvcData.error.message) || "Failed to fetch PVCs");

// Fetch Storage Classes
const scResponse = await fetch(`/vm_infra/storage_class_list/${recordId}`);
const scData = await scResponse.json();
if (!scResponse.ok) throw new Error((scData.error && scData.error.message) || "Failed to fetch Storage Classes");

setState(prev => ({
...prev,
isLoading: false,
volumes: pvcData.resources || [],
storageClasses: scData.resources || [],
vmInfo: {
name: pvcData.vm_name,
namespace: pvcData.vm_namespace
}
}));
} catch (error) {
console.error('Error fetching PVCs:', error);
setState(prev => ({
...prev,
isLoading: false,
error: error.message,
volumes: [],
storageClasses: [],
}));
}
};

fetchPersistentVolumeClaims();
}, [recordId]);

const schema = useMemo(() => createSchema(state.volumes, state.storageClasses), [state.volumes, state.storageClasses]);

const onFormChange = (values) => {
if (values.volumeSourceType === "existing") {
setSubmitDisabled(!values.pvcName);
} else if (values.volumeSourceType === "new") {
setSubmitDisabled(!values.newVolumeName || !values.newVolumeSize);
} else {
setSubmitDisabled(true);
}
};

const onSubmit = (values) => {
const { volumeSourceType } = values;

let payload;

if (volumeSourceType === "existing") {
const volumeNameFinal =
values.volumeName && values.volumeName.trim()
? values.volumeName.trim()
: values.pvcName;

payload = {
action: "attach",
resource: {
pvc_name: values.pvcName,
volume_name: volumeNameFinal,
vm_id: recordId
}
};
} else {
payload = {
action: "create_and_attach_volume",
resource: {
volume_name: values.newVolumeName.trim(),
volume_size: values.newVolumeSize.trim(),
storage_class: values.storage_class,
access_mode: values.access_mode,
vm_id: recordId,
device: values.device_mountpoint ? values.device_mountpoint : ''
},
};
}

const request = API.post(`/api/container_volumes/${recordId}`, payload);

request.then(() => {
const message = sprintf(
__('Attechment of Volume has been successfully queued.')
);
miqRedirectBack(message, 'success', redirect);
}).catch((error) => {
miqRedirectBack(error.message || __("Failed to attach volume"), "error", redirect);
}).finally(miqSparkleOff);

};

const onCancel = () =>
miqRedirectBack(__("Add Volume was cancelled by the user"), "warning", redirect);

return state.isLoading ? null : (
<Grid>
<MiqFormRenderer
schema={schema}
onSubmit={onSubmit}
onCancel={onCancel}
canSubmit={!isSubmitDisabled}
onStateUpdate={onFormChange}
/>
</Grid>
);
};

AddVolumeForm.propTypes = {
recordId: PropTypes.string.isRequired,
redirect: PropTypes.string.isRequired,
};

export default AddVolumeForm;
110 changes: 110 additions & 0 deletions app/javascript/components/vm-infra/add-volume.schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { componentTypes } from "@@ddf";

const createSchema = (volumes = [], storageClasses = []) => ({
fields: [
{
component: componentTypes.RADIO,
name: "volumeSourceType",
label: __("Volume Source Type"),
isRequired: true,
options: [
{ label: __("Select Existing PVC"), value: "existing" },
{ label: __("Create New PVC"), value: "new" },
],
initialValue: "existing",
},
// Existing PVC selection
{
component: componentTypes.SELECT,
name: "pvcName",
id: "pvcName",
label: __("Select Persistent Volume Claim"),
placeholder: volumes.length > 0 ? __("Select PVC") : __("No PVCs available"),
options: volumes.length > 0
? [
{ label: __("Select PVC"), value: null, isDisabled: true },
...volumes.map(({ metadata }) => ({
label: metadata.name,
value: metadata.name,
})),
]
: [{ label: __("No PVCs available"), value: "", isDisabled: true }],
condition: {
when: "volumeSourceType",
is: "existing",
},
isRequired: true,
validate: [{ type: "required", message: __("PVC selection is required") }],
},


// New volume name
{
component: componentTypes.TEXT_FIELD,
name: "newVolumeName",
id: "newVolumeName",
label: __("New Volume Name"),
isRequired: true,
condition: {
when: "volumeSourceType",
is: "new",
},
validate: [{ type: "required", message: __("Volume name is required") }],
},
// New volume size
{
component: componentTypes.TEXT_FIELD,
name: "newVolumeSize",
id: "newVolumeSize",
label: __("New Volume Size (e.g., 3Gi)"),
isRequired: true,
condition: {
when: "volumeSourceType",
is: "new",
},
validate: [
{ type: "required", message: __("Volume size is required") },
{
type: "pattern",
pattern: "^[0-9]+Gi$",
message: __("Size must be in Gi format (e.g., 3Gi)"),
},
],
},
{
component: componentTypes.SELECT,
id: "storage_class",
name: "storage_class",
label: __("Storage Class"),
isRequired: true,
condition: { when: "volumeSourceType", is: "new" },
includeEmpty: true,
options:
storageClasses.length > 0
? [
{ label: __("Select Storage Class"), value: "", isDisabled: true },
...storageClasses.map((sc) => ({
label: sc.name || sc,
value: sc.name || sc,
})),
]
: [{ label: __("No Storage Classes Available"), value: "", isDisabled: true }],
},
{
component: componentTypes.SELECT,
id: "access_mode",
name: "access_mode",
label: __("Access Mode"),
isRequired: true,
condition: { when: "volumeSourceType", is: "new" },
includeEmpty: true,
options: [
{ label: "Single Use (RWO)", value: "ReadWriteOnce" },
{ label: "Shared Access (RWX)", value: "ReadWriteMany" },
{ label: "Read Write Once Pod (RWOP)", value: "ReadWriteOncePod" },
],
},
],
});

export default createSchema;
Loading
Loading