diff --git a/app/controllers/application_controller/explorer.rb b/app/controllers/application_controller/explorer.rb
index 51502c17cad..c34ed80d815 100644
--- a/app/controllers/application_controller/explorer.rb
+++ b/app/controllers/application_controller/explorer.rb
@@ -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,
diff --git a/app/controllers/vm_common.rb b/app/controllers/vm_common.rb
index 1fa5c734741..3d0718049c0 100644
--- a/app/controllers/vm_common.rb
+++ b/app/controllers/vm_common.rb
@@ -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
@@ -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"
diff --git a/app/controllers/vm_infra_controller.rb b/app/controllers/vm_infra_controller.rb
index aab70635974..0e4482d0996 100644
--- a/app/controllers/vm_infra_controller.rb
+++ b/app/controllers/vm_infra_controller.rb
@@ -19,6 +19,33 @@ 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
+
private
def features
diff --git a/app/helpers/application_helper/button/vm_attach_volume.rb b/app/helpers/application_helper/button/vm_attach_volume.rb
new file mode 100644
index 00000000000..06dff7c69f6
--- /dev/null
+++ b/app/helpers/application_helper/button/vm_attach_volume.rb
@@ -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
\ No newline at end of file
diff --git a/app/helpers/application_helper/button/vm_detach_volume.rb b/app/helpers/application_helper/button/vm_detach_volume.rb
new file mode 100644
index 00000000000..479d5e492d5
--- /dev/null
+++ b/app/helpers/application_helper/button/vm_detach_volume.rb
@@ -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
diff --git a/app/helpers/application_helper/toolbar/x_vm_center.rb b/app/helpers/application_helper/toolbar/x_vm_center.rb
index a5ffa8b54ec..02876451a17 100644
--- a/app/helpers/application_helper/toolbar/x_vm_center.rb
+++ b/app/helpers/application_helper/toolbar/x_vm_center.rb
@@ -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
+ ),
]
),
])
diff --git a/app/javascript/components/vm-infra/add-volume.jsx b/app/javascript/components/vm-infra/add-volume.jsx
new file mode 100644
index 00000000000..94b88c197c5
--- /dev/null
+++ b/app/javascript/components/vm-infra/add-volume.jsx
@@ -0,0 +1,130 @@
+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: [],
+ });
+
+ const [isSubmitDisabled, setSubmitDisabled] = useState(true);
+
+ useEffect(() => {
+ const fetchPersistentVolumeClaims = async () => {
+ try {
+ setState(prev => ({ ...prev, isLoading: true, error: null }));
+
+ // Use the correct route for your Rails controller
+ const response = await fetch(`/vm_infra/${recordId}/persistentvolumeclaims`);
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error((data.error && data.error.message) || 'Failed to fetch persistent volume claims');
+ }
+
+ setState(prev => ({
+ ...prev,
+ isLoading: false,
+ volumes: data.resources || [],
+ vmInfo: {
+ name: data.vm_name,
+ namespace: data.vm_namespace
+ }
+ }));
+ } catch (error) {
+ console.error('Error fetching PVCs:', error);
+ setState(prev => ({
+ ...prev,
+ isLoading: false,
+ error: error.message,
+ volumes: []
+ }));
+ }
+ };
+
+ fetchPersistentVolumeClaims();
+}, [recordId]);
+
+ const schema = useMemo(() => createSchema(state.volumes), [state.volumes]);
+
+ 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(),
+ vm_id: recordId,
+ device: values.device_mountpoint ? values.device_mountpoint : ''
+ },
+ };
+ }
+
+ const request = API.post(`/api/container_volumes/${recordId}`, payload);
+
+ request.then(() => {
+ const message = sprintf(
+ __('Volume processed successfully.')
+ );
+ 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 : (
+
+
+
+ );
+};
+
+AddVolumeForm.propTypes = {
+ recordId: PropTypes.string.isRequired,
+ redirect: PropTypes.string.isRequired,
+};
+
+export default AddVolumeForm;
\ No newline at end of file
diff --git a/app/javascript/components/vm-infra/add-volume.schema.js b/app/javascript/components/vm-infra/add-volume.schema.js
new file mode 100644
index 00000000000..ca71fa84dd4
--- /dev/null
+++ b/app/javascript/components/vm-infra/add-volume.schema.js
@@ -0,0 +1,77 @@
+import { componentTypes } from "@@ddf";
+
+const createSchema = (volumes = []) => ({
+ 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)"),
+ },
+ ],
+ },
+ ],
+});
+
+export default createSchema;
diff --git a/app/javascript/components/vm-infra/remove-volume.jsx b/app/javascript/components/vm-infra/remove-volume.jsx
new file mode 100644
index 00000000000..bbd2c70ea89
--- /dev/null
+++ b/app/javascript/components/vm-infra/remove-volume.jsx
@@ -0,0 +1,103 @@
+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 miqRedirectBack from "../../helpers/miq-redirect-back";
+import createDetachSchema from "./remove-volume.schema";
+
+const DetachVolumeForm = ({ recordId, redirect }) => {
+ const [state, setState] = useState({
+ isLoading: true,
+ volumes: [],
+ });
+
+ const [isSubmitDisabled, setSubmitDisabled] = useState(true);
+
+ useEffect(() => {
+ const fetchVolumes = async () => {
+ try {
+ setState(prev => ({ ...prev, isLoading: true, error: null }));
+
+ const response = await fetch(`/vm_infra/${recordId}/attached_volumes`);
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error((data.error && data.error.message) || 'Failed to fetch attached volumes');
+ }
+
+ setState(prev => ({
+ ...prev,
+ isLoading: false,
+ volumes: data.resources || [],
+ }));
+ } catch (error) {
+ console.error('Error fetching volumes:', error);
+ setState(prev => ({
+ ...prev,
+ isLoading: false,
+ error: error.message,
+ volumes: []
+ }));
+ }
+ };
+
+ fetchVolumes();
+ }, [recordId]);
+
+ const schema = useMemo(() => createDetachSchema(state.volumes), [state.volumes]);
+
+ const onFormChange = (values) => {
+ setSubmitDisabled(!values.volumeName);
+ };
+
+ const onSubmit = (values) => {
+ miqSparkleOn();
+
+ let payload = {
+ action: "detach",
+ resource: {
+ volume_name: values.volumeName.trim(),
+ vm_id: recordId,
+ device: values.device_mountpoint ? values.device_mountpoint : ''
+ },
+ };
+
+ const request = API.post(`/api/container_volumes/${recordId}`, payload)
+ request.then(() => {
+ const message = sprintf(
+ __('Detachment of Container Volume has been successfully queued.')
+ );
+ miqRedirectBack(message, 'success', redirect);
+ }).catch((error) => {
+ miqRedirectBack(error.message || __("Failed to detach volume"), "error", redirect);
+ }).finally(miqSparkleOff);
+
+ };
+
+ const onCancel = () => {
+ miqSparkleOn();
+ const message = sprintf(__('Detach Volume was cancelled by the user.'));
+ miqRedirectBack(message, 'warning', redirect);
+ };
+
+ return state.isLoading ? null : (
+
+
+
+ );
+};
+
+DetachVolumeForm.propTypes = {
+ recordId: PropTypes.string.isRequired,
+ redirect: PropTypes.string.isRequired,
+};
+
+export default DetachVolumeForm;
diff --git a/app/javascript/components/vm-infra/remove-volume.schema.js b/app/javascript/components/vm-infra/remove-volume.schema.js
new file mode 100644
index 00000000000..cd2ea2cca3c
--- /dev/null
+++ b/app/javascript/components/vm-infra/remove-volume.schema.js
@@ -0,0 +1,26 @@
+import { componentTypes } from "@@ddf";
+
+const createDetachSchema = (volumes = []) => ({
+ fields: [
+ {
+ component: componentTypes.SELECT,
+ name: "volumeName",
+ id: "volumeName",
+ label: __("Select Volume to Detach"),
+ placeholder: volumes.length > 0 ? __("Select Volume") : __("No Volumes Available"),
+ options: volumes.length > 0
+ ? [
+ { label: __("Select Volume"), value: "", isDisabled: true },
+ ...volumes.map(({ metadata }) => ({
+ label: metadata.name,
+ value: metadata.name,
+ })),
+ ]
+ : [{ label: __("No Volumes Available"), value: "", isDisabled: true }],
+ isRequired: true,
+ validate: [{ type: "required", message: __("Volume selection is required") }],
+ },
+ ],
+});
+
+export default createDetachSchema;
diff --git a/app/javascript/packs/component-definitions-common.js b/app/javascript/packs/component-definitions-common.js
index 164edaa3905..05fdf01f74d 100644
--- a/app/javascript/packs/component-definitions-common.js
+++ b/app/javascript/packs/component-definitions-common.js
@@ -11,6 +11,7 @@ import { Toolbar } from '../components/toolbar';
import ActionForm from '../components/action-form';
import AddRemoveHostAggregateForm from '../components/host-aggregate-form/add-remove-host-aggregate-form';
import AddRemoveSecurityGroupForm from '../components/vm-cloud-add-remove-security-group-form';
+import AddVolumeForm from '../components/vm-infra/add-volume';
import AeInlineMethod from '../components/AeInlineMethod';
import AggregateStatusCard from '../components/aggregate_status_card';
import AnsibleCredentialsForm from '../components/ansible-credentials-form';
@@ -116,6 +117,7 @@ import RefreshDataNotification from '../components/refresh-data-notification';
import RegionForm from '../components/region-form';
import RemoveCatalogItemModal from '../components/remove-catalog-item-modal';
import RemoveGenericItemModal from '../components/remove-generic-item-modal';
+import RemoveVolumeForm from '../components/vm-infra/remove-volume';
import ReportChartWidget from '../components/create-report-chart-form';
import ReportDataTable from '../components/data-tables/report-data-table/report-data-table';
import ReportList from '../components/data-tables/reports/ReportList';
@@ -190,6 +192,7 @@ import MiqAeClass from '../components/miq-ae-class';
ManageIQ.component.addReact('ActionForm', ActionForm);
ManageIQ.component.addReact('AddRemoveHostAggregateForm', AddRemoveHostAggregateForm);
ManageIQ.component.addReact('AddRemoveSecurityGroupForm', AddRemoveSecurityGroupForm);
+ManageIQ.component.addReact('AddVolumeForm', AddVolumeForm);
ManageIQ.component.addReact('AggregateStatusCard', AggregateStatusCard);
ManageIQ.component.addReact('AeInlineMethod', AeInlineMethod);
ManageIQ.component.addReact('AnsibleCredentialsForm', AnsibleCredentialsForm);
@@ -300,6 +303,7 @@ ManageIQ.component.addReact('RefreshDataNotification', RefreshDataNotification);
ManageIQ.component.addReact('RegionForm', RegionForm);
ManageIQ.component.addReact('RemoveCatalogItemModal', RemoveCatalogItemModal);
ManageIQ.component.addReact('RemoveGenericItemModal', RemoveGenericItemModal);
+ManageIQ.component.addReact('RemoveVolumeForm', RemoveVolumeForm);
ManageIQ.component.addReact('ReportChartWidget', ReportChartWidget);
ManageIQ.component.addReact('ReportDataTable', ReportDataTable);
ManageIQ.component.addReact('ReportList', ReportList);
diff --git a/app/views/vm_common/_add_volume.html.haml b/app/views/vm_common/_add_volume.html.haml
new file mode 100644
index 00000000000..c314ea83d72
--- /dev/null
+++ b/app/views/vm_common/_add_volume.html.haml
@@ -0,0 +1,10 @@
+#tab-div
+ = render :partial => "layouts/flash_msg"
+ %h3
+ = _('Add Volume')
+ .col-md-12
+ = react(
+ 'AddVolumeForm',
+ :recordId => @record.id.to_s,
+ :redirect => url_for(:action => :show, :id => @record.id)
+ )
diff --git a/app/views/vm_common/_remove_volume.html.haml b/app/views/vm_common/_remove_volume.html.haml
new file mode 100644
index 00000000000..62ea6121a28
--- /dev/null
+++ b/app/views/vm_common/_remove_volume.html.haml
@@ -0,0 +1,9 @@
+#tab-div
+ = render :partial => "layouts/flash_msg"
+ %h3= _('Detach Volume')
+ .col-md-12
+ = react(
+ 'RemoveVolumeForm',
+ :recordId => @record.id.to_s,
+ :redirect => url_for(:action => :show, :id => @record.id)
+ )
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index ce1fd14bf9a..b62395107a1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -3048,6 +3048,8 @@
retire
show
tagging_edit
+ persistentvolumeclaims
+ attached_volumes
] +
compare_get,
:post => %w[
@@ -3097,6 +3099,8 @@
wait_for_task
win32_services
ownership_update
+ add_volume
+ remove_volume
] +
adv_search_post +
compare_post +