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 +