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..b4d1d695298 100644 --- a/app/controllers/vm_infra_controller.rb +++ b/app/controllers/vm_infra_controller.rb @@ -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 + render :json => { + :resources => storage_class + } + 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..cb9c3307cdc --- /dev/null +++ b/app/javascript/components/vm-infra/add-volume.jsx @@ -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 : ( + + + + ); +}; + +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..50f9a4c286a --- /dev/null +++ b/app/javascript/components/vm-infra/add-volume.schema.js @@ -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; 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..900c502a078 --- /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/attached_volumes/${recordId}`); + 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 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..2e1bcb0f605 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3048,6 +3048,9 @@ retire show tagging_edit + persistentvolumeclaims + attached_volumes + storage_class_list ] + compare_get, :post => %w[ @@ -3097,6 +3100,8 @@ wait_for_task win32_services ownership_update + add_volume + remove_volume ] + adv_search_post + compare_post +