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
15 changes: 7 additions & 8 deletions app/controllers/vm_common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -298,15 +298,14 @@ def snap_pressed
@active = @snap_selected.current? if @snap_selected
@center_toolbar = 'x_vm_snapshot'
@explorer = true
render :update do |page|
page << javascript_prologue
page << javascript_reload_toolbars

page.replace("flash_msg_div", :partial => "layouts/flash_msg")
page << "miqScrollTop();" if @flash_array.present?
page.replace("desc_content", :partial => "/vm_common/snapshots_desc",
:locals => {:selected => params[:id]})
formatted_time = format_timezone(@snap_selected[:create_time].to_time, Time.zone, "view")
Copy link
Member

Choose a reason for hiding this comment

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

Should this be in the users time zone?

Copy link
Member

Choose a reason for hiding this comment

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

Or should this be in UTC and we convert on the client side?

Copy link
Member Author

Choose a reason for hiding this comment

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

This line converts it to the user's timezone in the MIQ settings, converting timezones is messy on JS and would be difficult to match the original formatting which is why I kept it on the ruby side

number_to_human_size(@snap_selected[:total_size], :precision => 2)
Copy link
Member

Choose a reason for hiding this comment

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

This line isn't doing anything on its own. Should this be used below in the formatted bytes section?

Copy link
Member Author

@GilbertCherrie GilbertCherrie Sep 30, 2025

Choose a reason for hiding this comment

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

I just copied the original code for this part. Is the original code wrong here?

= number_to_human_size(selected_id[:total_size], :precision => 2)
= _("(%{number} bytes)") % {:number => number_with_delimiter(selected_id[:total_size], :delimiter => ",", :separator => ".")}

if @snap_selected[:total_size] == nil || @snap_selected[:total_size] == 0
formatted_size = ''
else
formatted_size = _("%{number} bytes") % {:number => number_with_delimiter(@snap_selected[:total_size], :delimiter => ",", :separator => ".")}
end
render :json => {:data => {:data => @snap_selected, :size => formatted_size, :time => formatted_time}}, :status => 200
end

def disks
Expand Down
70 changes: 70 additions & 0 deletions app/javascript/components/vm-snapshot-tree-select/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './styles.css';
import SnapshotTree from './snapshot-tree';

const VMSnapshotTreeSelect = ({
tree, selected, size, time,
}) => {
const [snapshot, setSnapshot] = useState({ ...selected, size, time });

// eslint-disable-next-line react/prop-types
return (
<div>
<div className="snapshot-details-div">
<div className="snapshot-details">
<div className="snapshot-detail-title">
<p>
<b>
{__('Description')}
</b>
</p>
</div>
<div className="snapshot-detail-value">
{snapshot.data ? snapshot.data.description : snapshot.description || ''}
</div>
</div>
<div className="snapshot-details">
<div className="snapshot-detail-title" id="size-title">
<p>
<b>
{__('Size')}
</b>
</p>
</div>
<div className="snapshot-detail-value">
{snapshot.size || ''}
</div>
</div>
<div className="snapshot-details">
<div className="snapshot-detail-title" id="created-title">
<p>
<b>
{__('Created')}
</b>
</p>
</div>
<div className="snapshot-detail-value">
{snapshot.time || ''}
</div>
</div>
</div>
<SnapshotTree nodes={tree.tree_nodes} setSnapshot={setSnapshot} />
</div>
);
};

VMSnapshotTreeSelect.propTypes = {
tree: PropTypes.objectOf(PropTypes.any).isRequired,
selected: PropTypes.objectOf(PropTypes.any),
size: PropTypes.string,
time: PropTypes.string,
};

VMSnapshotTreeSelect.defaultProps = {
selected: {},
size: '',
time: '',
};

export default VMSnapshotTreeSelect;
174 changes: 174 additions & 0 deletions app/javascript/components/vm-snapshot-tree-select/snapshot-tree.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import React, { useEffect, useState } from 'react';
import {
Camera16, ChevronRight16, ChevronDown16, VirtualMachine16,
} from '@carbon/icons-react';
import TreeView, { flattenTree } from 'react-accessible-treeview';
import './styles.css';
import PropTypes from 'prop-types';

const allNodeData = [];

const convertData = (node) => {
allNodeData.push(
{
name: node.text,
id: node.key,
selectable: node.selectable,
tooltip: node.tooltip,
icon: node.icon,
}
);
const treeData = {
name: node.text, // Use the `text` property as the `name`
id: node.key, // Use the `key` property as the `id`
children: node.nodes ? node.nodes.map(convertData) : [], // Recursively process children
};

return treeData;
};

const SnapshotTree = ({ nodes, setSnapshot }) => {
const [selectedNode, setSelectedNode] = useState('');

const data = {
name: '',
children: nodes.map(convertData),
};
const treeData = flattenTree(data);
const expandedIds = [];
treeData.forEach((node) => {
expandedIds.push(node.id);

allNodeData.forEach((nodeData) => {
if (nodeData.id === node.id) {
const metadata = {
selectable: nodeData.selectable || false,
tooltip: nodeData.tooltip || nodeData.name,
icon: nodeData.icon || 'fa fa-camera',
};
node.metadata = metadata;
}
});
});

const nodeClick = (e, node) => {
if (node.metadata.selectable === false) {
// If the clicked node is already selected or root is selected, do nothing
return;
}

const ids = node.id.split('-');
const shortId = ids[ids.length - 1];
miqSparkleOn();
http.post(`/${ManageIQ.controller}/snap_pressed/${encodeURIComponent(shortId)}`).then((response) => {
if (response.data) {
const tempData = response.data;
tempData.size = response.data.size;
tempData.time = response.data.time;
setSnapshot(tempData);
}
miqSparkleOff();
});

e.stopPropagation();
setSelectedNode(e.target.id);
};

const addSelectedClassName = () => {
// Remove 'selected' class from all elements
const selectedElements = document.querySelectorAll('.selected-snapshot');
selectedElements.forEach((el) => {
el.classList.remove('selected-snapshot');
});

const selectedElement = document.getElementById(selectedNode);
if (selectedElement) {
selectedElement.parentNode.classList.add('selected-snapshot');
}
};

useEffect(() => {
addSelectedClassName();
}, [selectedNode]);

useEffect(() => {
treeData.forEach((node) => {
if (node.name.includes(__('(Active)'))) {
setSelectedNode(node.id);
}
});
}, []);

const ArrowIcon = (isOpen) => {
let icon = <ChevronRight16 />;
if (isOpen && isOpen.isOpen) {
icon = <ChevronDown16 />;
}
return <div className="arrow-div">{icon}</div>;
};

const NodeIcon = (icon) => {
if (icon === 'pficon pficon-virtual-machine') {
return <VirtualMachine16 />;
}
return <Camera16 />;
};

// First pull in node data and go through flattened tree to add metadata like icons and selectable
// Then add icons, tooltip and special handling

return (
<div>
<div className="checkbox">
<TreeView
data={treeData}
aria-label="Single select"
multiSelect={false}
onExpand={addSelectedClassName}
defaultExpandedIds={expandedIds}
propagateSelectUpwards
togglableSelect
nodeAction="check"
nodeRenderer={({
element,
isBranch,
isExpanded,
getNodeProps,
level,
handleExpand,
}) => (
<div
{...getNodeProps({ onClick: handleExpand })}
style={{ paddingLeft: 40 * (level - 1) }}
>
{isBranch && <ArrowIcon isOpen={isExpanded} />}
{element.metadata && element.metadata.icon && (
<div className="node-icon-div">
<NodeIcon icon={element.metadata.icon} />
</div>
)}
<span
key={element.id}
id={element.id}
onClick={(e) => nodeClick(e, element)}
onKeyDown={(e) => e.key === 'Enter' && nodeClick(e)}
role="button"
tabIndex={0}
className="name"
>
{element.name}
</span>
</div>
)}
/>
</div>
</div>
);
};

SnapshotTree.propTypes = {
nodes: PropTypes.arrayOf(PropTypes.any).isRequired,
setSnapshot: PropTypes.func.isRequired,
};

export default SnapshotTree;
100 changes: 100 additions & 0 deletions app/javascript/components/vm-snapshot-tree-select/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
.snapshot-details-div {
.snapshot-details {
margin-left: 30px;
margin-bottom: 20px;
}

.snapshot-detail-title {
width: 80px;
justify-content: right;
display: inline-flex;
margin-right: 30px;
}

.snapshot-detail-value {
display: inline-flex;
}
}

.checkbox {
font-size: 16px;
user-select: none;
min-height: 320px;
padding: 20px;
box-sizing: content-box;
}

.selected-snapshot {
background-color: #0f62fe;
color: white;
width: 100%;
margin-left: 0px;
pointer-events: none;

.arrow-div {
pointer-events: all;
}

span {
pointer-events: all;
}
}

.arrow-div {
display: inline-flex;
margin-right: 5px;
}

.node-icon-div {
margin-right: 5px;
display: inline-flex;
}

.checkbox .tree,
.checkbox .tree-node,
.checkbox .tree-node-group {
list-style: none;
margin: 0;
padding: 0;
}

.checkbox .tree-branch-wrapper,
.checkbox .tree-node__leaf {
outline: none;
}

.checkbox .tree-node {
cursor: pointer;
}

.checkbox .tree-node .name:hover {
background: rgba(0, 0, 0, 0.1);
}

.checkbox .tree-node--focused .name {
background: rgba(0, 0, 0, 0.2);
}

.checkbox .tree-node {
display: inline-block;
}

.checkbox .checkbox-icon {
margin: 0 5px;
vertical-align: middle;
}

.checkbox button {
border: none;
background: transparent;
cursor: pointer;
}

.checkbox .arrow {
margin-left: 5px;
vertical-align: middle;
}

.checkbox .arrow--open {
transform: rotate(90deg);
}
2 changes: 2 additions & 0 deletions app/javascript/packs/component-definitions-common.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ import VmFloatingIPsForm from '../components/vm-floating-ips/vm-floating-ips-for
import VmResizeForm from '../components/vm-resize-form/vm-resize-form';
import VmServerRelationshipForm from '../components/vm-server-relationship-form';
import VmSnapshotForm from '../components/vm-snapshot-form/vm-snapshot-form';
import VmSnapshotTreeSelect from '../components/vm-snapshot-tree-select';
import VolumeMappingForm from '../components/volume-mapping-form';
import WidgetChart from '../components/dashboard-widgets/widget-chart';
import WidgetError from '../components/dashboard-widgets/widget-error';
Expand Down Expand Up @@ -352,6 +353,7 @@ ManageIQ.component.addReact('VmFloatingIPsForm', VmFloatingIPsForm);
ManageIQ.component.addReact('VmResizeForm', VmResizeForm);
ManageIQ.component.addReact('VmServerRelationshipForm', VmServerRelationshipForm);
ManageIQ.component.addReact('VmSnapshotForm', VmSnapshotForm);
ManageIQ.component.addReact('VmSnapshotTreeSelect', VmSnapshotTreeSelect);
ManageIQ.component.addReact('VolumeMappingForm', VolumeMappingForm);
ManageIQ.component.addReact('WidgetChart', WidgetChart);
ManageIQ.component.addReact('WidgetError', WidgetError);
Expand Down
Loading