[PATCH] Adding ability to switch graphics devices on a cold vm, adds serial console option as well

From: Brent Baude <bbaude@redhat.com> This patch allows you to change the graphics type for a cold guest. It will also allow a serial choice for ppc64 machines where a user can then run virsh console to deal with low-bandwidth issues. --- src/kimchi/API.json | 5 +- src/kimchi/model/config.py | 2 + src/kimchi/model/vms.py | 177 +++++++++++++++++++++++++++------ src/kimchi/vmtemplate.py | 9 ++ ui/css/theme-default/guest-edit.css | 6 ++ ui/css/theme-default/storage.css | 58 ----------- ui/js/src/kimchi.guest_edit_main.js | 30 +++++- ui/js/src/kimchi.guest_main.js | 23 ++++- ui/js/src/kimchi.template_edit_main.js | 4 + ui/pages/guest-edit.html.tmpl | 21 ++++ ui/pages/guest.html.tmpl | 5 +- ui/pages/i18n.json.tmpl | 1 + ui/pages/tabs/storage.html.tmpl | 4 +- 13 files changed, 246 insertions(+), 99 deletions(-) diff --git a/src/kimchi/API.json b/src/kimchi/API.json index 8a95804..335f1fd 100644 --- a/src/kimchi/API.json +++ b/src/kimchi/API.json @@ -9,7 +9,7 @@ "type": "object", "properties": { "type": { - "enum": ["spice", "vnc"], + "enum": ["spice", "vnc", "serial"], "error": "KCHVM0014E" }, "listen": { @@ -274,7 +274,8 @@ "type": "integer", "minimum": 512, "error": "KCHTMPL0013E" - } + }, + "graphics": { "$ref": "#/kimchitype/graphics" } } }, "networks_create": { diff --git a/src/kimchi/model/config.py b/src/kimchi/model/config.py index 1c00cfe..0b7ecc3 100644 --- a/src/kimchi/model/config.py +++ b/src/kimchi/model/config.py @@ -20,6 +20,7 @@ from multiprocessing.pool import ThreadPool import cherrypy +import platform from kimchi.basemodel import Singleton from kimchi.config import config as kconfig @@ -106,6 +107,7 @@ class CapabilitiesModel(object): return {'libvirt_stream_protocols': self.libvirt_stream_protocols, 'qemu_spice': self._qemu_support_spice(), 'qemu_stream': self.qemu_stream, + 'hostarch': platform.machine(), 'screenshot': VMScreenshot.get_stream_test_result(), 'system_report_tool': bool(report_tool), 'update_tool': update_tool, diff --git a/src/kimchi/model/vms.py b/src/kimchi/model/vms.py index 58686cd..8f69cef 100644 --- a/src/kimchi/model/vms.py +++ b/src/kimchi/model/vms.py @@ -20,10 +20,12 @@ from lxml.builder import E import lxml.etree as ET from lxml import etree, objectify +import platform import os import random import string import time +from datetime import datetime,timedelta import uuid from xml.etree import ElementTree @@ -247,8 +249,13 @@ class VMModel(object): self.groups = import_class('kimchi.model.host.GroupsModel')(**kargs) def update(self, name, params): + if 'ticket' in params: + password = params['ticket'].get("passwd") + password = password if password is not None else "".join( + random.sample(string.ascii_letters + string.digits, 8)) + params['ticket']['passwd'] = password dom = self.get_vm(name, self.conn) - dom = self._static_vm_update(dom, params) + dom = self._static_vm_update(dom, params, name) self._live_vm_update(dom, params) return dom.name().decode('utf-8') @@ -305,41 +312,107 @@ class VMModel(object): os_elem = E.os({"distro": distro, "version": version}) set_metadata_node(dom, os_elem) - def _update_graphics(self, dom, xml, params): - root = objectify.fromstring(xml) - graphics = root.devices.find("graphics") - if graphics is None: + def _update_graphics(self, dom, xml, params, name): +## root = objectify.fromstring(xml) +## graphics = root.devices.find("graphics") + + curgraphics = self._vm_get_graphics(name) + graphics_type, graphics_listen, graphics_port, graphics_password, graphics_validto = curgraphics + + if graphics_type is None : return xml + + if graphics_password != None : params['graphics']['passwd'] = graphics_password + if graphics_validto != None : + params['graphics']['passwdValidTo'] = graphics_validto - password = params['graphics'].get("passwd") - if password is not None and len(password.strip()) == 0: - password = "".join(random.sample(string.ascii_letters + + try: + ## Determine if a change in graphics type has been submitted + if ('graphics' in params) and (params['graphics']['type'] != None): + hasGraphicTypeChange = True + except: + hasGraphicTypeChange = False + + if hasGraphicTypeChange: + newGraphicsType=params['graphics']['type'] + + ## Remove current graphics implementations by node + newxml=xml + root = etree.fromstring(newxml) + devices = root.find("devices") + if graphics_type == 'vnc': + nodeDelete=[devices.find("graphics"),devices.find("video")] + elif graphics_type == 'spice': + nodeDelete=[devices.find("graphics"),devices.find("channel"), devices.find("video")] + elif graphics_type == 'serial': + nodeDelete=[devices.find("serial"),devices.find("console")] + else: + nodeDelete='' + if nodeDelete: + for i in nodeDelete: + devices.remove(i) + ## Add new graphics information back in by node + if newGraphicsType == 'spice': + newchannel=etree.SubElement(devices,'channel',type='spicevmc') + newtarget=etree.SubElement(newchannel,'target',type='virtio',name='com.redhat.spice.0') + newgraphics=etree.SubElement(devices,'graphics',type='spice', listen='127.0.0.1', autoport='yes') + if 'passwd' in params['graphics']: newgraphics.attrib['passwd'] = graphics_password + if 'passwdValidTo' in params['graphics']: newgraphics.attrib['passwdValidTo'] = self._convert_datetime(str(graphics_validto)) + newlisten=etree.SubElement(newgraphics,'listen',type='address',address='127.0.0.1') + elif newGraphicsType == 'vnc': + newgraphics=etree.SubElement(devices,'graphics',type='vnc', listen='127.0.0.1', autoport='yes', port='-1') + newlisten=etree.SubElement(newgraphics,'listen',type='address',address='127.0.0.1') + if 'passwd' in params['graphics']: newgraphics.attrib['passwd'] = graphics_password + if 'passwdValidTo' in params['graphics']: newgraphics.attrib['passwdValidTo'] = self._convert_datetime(str(graphics_validto)) + + elif newGraphicsType == 'serial': + newconsole=etree.SubElement(devices,'console',type='pty') + newtarget=etree.SubElement(newconsole,'target',type='serial', port='0') + newaddress=etree.SubElement(newconsole,'address',type='spapr-vio', reg='0x30000000') + xml = etree.tostring(root) + if ('passwd' in params['graphics']) or ('passwdValidTo' in params['graphics']): + root = objectify.fromstring(xml) + graphics = root.devices.find("graphics") + + password = params['graphics'].get("passwd") + if password is not None and len(password.strip()) == 0: + password = "".join(random.sample(string.ascii_letters + string.digits, 8)) - if password is not None: - graphics.attrib['passwd'] = password + if password is not None: + graphics.attrib['passwd'] = password - expire = params['graphics'].get("passwdValidTo") - to = graphics.attrib.get('passwdValidTo') - if to is not None: - if (time.mktime(time.strptime(to, '%Y-%m-%dT%H:%M:%S')) - - time.time() <= 0): - expire = expire if expire is not None else 30 + expire = params['graphics'].get("passwdValidTo") + to = graphics.attrib.get('passwdValidTo') + if to is not None: + if (time.mktime(time.strptime(to, '%Y-%m-%dT%H:%M:%S')) + - time.time() <= 0): + expire = expire if expire is not None else 30 - if expire is not None: - expire_time = time.gmtime(time.time() + float(expire)) - valid_to = time.strftime('%Y-%m-%dT%H:%M:%S', expire_time) - graphics.attrib['passwdValidTo'] = valid_to + if expire is not None: + expire_time = time.gmtime(time.time() + float(expire)) + valid_to = time.strftime('%Y-%m-%dT%H:%M:%S', expire_time) + graphics.attrib['passwdValidTo'] = valid_to - if not dom.isActive(): - return ET.tostring(root, encoding="utf-8") + if not dom.isActive(): + return ET.tostring(root, encoding="utf-8") - xml = dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE) - dom.updateDeviceFlags(etree.tostring(graphics), + xml = dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE) + dom.updateDeviceFlags(etree.tostring(graphics), libvirt.VIR_DOMAIN_AFFECT_LIVE) return xml - def _static_vm_update(self, dom, params): + def _convert_datetime(self,deltaseconds): + deltaseconds = int(deltaseconds) + # takes the delta (in seconds) between the current time + # and the expiration time for the vnc password + mycur = datetime.strptime((time.strftime('%Y-%m-%dT%H:%M:%S',time.gmtime())),'%Y-%m-%dT%H:%M:%S') + expiredate = time.strptime((str(mycur + timedelta(seconds=deltaseconds))),'%Y-%m-%d %H:%M:%S') + + return time.strftime('%Y-%m-%dT%H:%M:%S',expiredate) + + + def _static_vm_update(self, dom, params, name): state = DOM_STATE_MAP[dom.info()[0]] old_xml = new_xml = dom.XMLDesc(0) @@ -355,7 +428,7 @@ class VMModel(object): new_xml = xmlutils.xml_item_update(new_xml, xpath, val) if 'graphics' in params: - new_xml = self._update_graphics(dom, new_xml, params) + new_xml = self._update_graphics(dom, new_xml, params, name) conn = self.conn.get() try: @@ -383,15 +456,35 @@ class VMModel(object): dom = ElementTree.fromstring(dom.XMLDesc(0)) return dom.find('devices/video') is not None + def _get_ticket(self, dom): + xml = dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE) + root = objectify.fromstring(xml) + graphic = root.devices.find("graphics") + if graphic is None: + return {"passwd": None, "expire": None} + passwd = graphic.attrib.get('passwd') + ticket = {"passwd": passwd} + valid_to = graphic.attrib.get('passwdValidTo') + ticket['expire'] = None + if valid_to is not None: + to = time.mktime(time.strptime(valid_to, '%Y-%m-%dT%H:%M:%S')) + ticket['expire'] = to - time.mktime(time.gmtime()) + + return ticket + def lookup(self, name): dom = self.get_vm(name, self.conn) info = dom.info() state = DOM_STATE_MAP[info[0]] screenshot = None - # (type, listen, port, passwd, passwdValidTo) graphics = self._vm_get_graphics(name) + graphics_type = graphics[0] graphics_port = graphics[2] graphics_port = graphics_port if state == 'running' else None + if graphics_type is None and platform.machine() == "ppc64": + ## Checking for ppc64 serial console + graphics_type = "serial" + try: if state == 'running' and self._has_video(dom): screenshot = self.vmscreenshot.lookup(name) @@ -539,8 +632,21 @@ class VMModel(object): expr = "/domain/devices/graphics/@type" res = xmlutils.xpath_get_text(xml, expr) - graphics_type = res[0] if res else None - + if res: + graphics_type = res[0] + else: + ## Checking for serial console, if not + ## returning None + expr = "/domain/devices/console/@type" + res = xmlutils.xpath_get_text(xml, expr) + if res: + if res[0] == "pty": + graphics_type = "serial" + else: + graphics_type = None + else: + graphics_type = None + expr = "/domain/devices/graphics/@listen" res = xmlutils.xpath_get_text(xml, expr) graphics_listen = res[0] if res else None @@ -558,8 +664,15 @@ class VMModel(object): expr = "/domain/devices/graphics[@type='%s']/@passwdValidTo" res = xmlutils.xpath_get_text(xml, expr % graphics_type) if res: - to = time.mktime(time.strptime(res[0], '%Y-%m-%dT%H:%M:%S')) - graphics_passwdValidTo = to - time.mktime(time.gmtime()) + currdate = datetime.strptime((time.strftime('%Y-%m-%dT%H:%M:%S',time.gmtime())),'%Y-%m-%dT%H:%M:%S') + graphics_expiredate = (datetime.strptime(res[0],'%Y-%m-%dT%H:%M:%S')) + mymax = max((currdate, graphics_expiredate)) + expirediff = currdate - graphics_expiredate + if mymax == currdate: + ## time has expired + graphics_passwdValidTo = abs(expirediff).seconds * -1 + else: + graphics_passwdValidTo = abs(expirediff).seconds return (graphics_type, graphics_listen, graphics_port, graphics_passwd, graphics_passwdValidTo) @@ -567,6 +680,8 @@ class VMModel(object): def connect(self, name): # (type, listen, port, passwd, passwdValidTo) graphics_port = self._vm_get_graphics(name)[2] + graphics = self._vm_get_graphics(name) + graphics_type, graphics_listen, graphics_port = graphics if graphics_port is not None: vnc.add_proxy_token(name, graphics_port) else: diff --git a/src/kimchi/vmtemplate.py b/src/kimchi/vmtemplate.py index c5bb7b3..f735d14 100644 --- a/src/kimchi/vmtemplate.py +++ b/src/kimchi/vmtemplate.py @@ -217,12 +217,21 @@ drive=drive-%(bus)s0-1-0,id=%(bus)s0-1-0'/> <target type='virtio' name='com.redhat.spice.0'/> </channel> """ + + serial_xml = """ + <console type='pty'> + <target type='serial' port='0'/> + <address type='spapr-vio' reg='0x30000000'/> + </console> + """ graphics = dict(self.info['graphics']) if params: graphics.update(params) graphics_xml = graphics_xml % graphics if graphics['type'] == 'spice': graphics_xml = graphics_xml + spicevmc_xml + elif graphics['type'] == 'serial' : + graphics_xml = serial_xml return graphics_xml def _get_scsi_disks_xml(self): diff --git a/ui/css/theme-default/guest-edit.css b/ui/css/theme-default/guest-edit.css index 74c2237..8515cda 100644 --- a/ui/css/theme-default/guest-edit.css +++ b/ui/css/theme-default/guest-edit.css @@ -63,6 +63,12 @@ width: 470px; } +.guest-edit-wrapper-controls > .dropdown { + margin: 5px 0 0 1px; + width: 440px; +} + + #form-guest-edit-storage .guest-edit-wrapper-controls { width: 486px; } diff --git a/ui/css/theme-default/storage.css b/ui/css/theme-default/storage.css index f635c2f..eeb783c 100644 --- a/ui/css/theme-default/storage.css +++ b/ui/css/theme-default/storage.css @@ -222,64 +222,6 @@ width: 13px; } -.toolable { - position: relative; -} - -.toolable .tooltip { - display: none; - border: 2px solid #0B6BAD; - background: #fff; - padding: 6px; - position: absolute; - color: #666666; - font-weight: bold; - font-size: 11px; - -webkit-border-radius: 5px; - -moz-border-radius: 5px; - border-radius: 5px; - z-index: 100; - top: -300%; - left: -140%; - white-space: nowrap; -} - -.toolable:hover .tooltip { - display: block; -} - -.toolable .tooltip:after { - -moz-border-bottom-colors: none; - -moz-border-left-colors: none; - -moz-border-right-colors: none; - -moz-border-top-colors: none; - border-color: #fff transparent transparent; - border-image: none; - border-style: solid; - border-width: 7px; - content: ""; - display: block; - left: 15px; - position: absolute; - bottom: -14px; -} - -.toolable .tooltip:before { - -moz-border-bottom-colors: none; - -moz-border-left-colors: none; - -moz-border-right-colors: none; - -moz-border-top-colors: none; - border-color: #0B6BAD transparent transparent; - border-image: none; - border-style: solid; - border-width: 8px; - content: ""; - display: block; - left: 14px; - position: absolute; - bottom: -18px; -} - .inactive { background: #E80501; background: linear-gradient(to bottom, #E88692 0%, #E84845 50%, diff --git a/ui/js/src/kimchi.guest_edit_main.js b/ui/js/src/kimchi.guest_edit_main.js index 938dfd9..2e71736 100644 --- a/ui/js/src/kimchi.guest_edit_main.js +++ b/ui/js/src/kimchi.guest_edit_main.js @@ -386,6 +386,7 @@ kimchi.guest_edit_main = function() { }; initStorageListeners(); + setupInterface(); setupPermission(); @@ -398,6 +399,31 @@ kimchi.guest_edit_main = function() { kimchi.topic('kimchi/vmCDROMReplaced').unsubscribe(onReplaced); kimchi.topic('kimchi/vmCDROMDetached').unsubscribe(onDetached); }; + + graphics_type = guest.graphics.type + + var graphicsForm = $('#form-guest-edit-general'); + $('input[name="graphics"]', graphicsForm).val(graphics_type); + + + var vncOpt = [{label: 'VNC', value: 'vnc'}]; + kimchi.select('guest-edit-general-graphics-list', vncOpt); + + var enableSpice = function() { + if (kimchi.capabilities == undefined) { + setTimeout(enableSpice, 2000); + return; + } + if (kimchi.capabilities.qemu_spice == true) { + spiceOpt = [{label: 'Spice', value: 'spice'}] + kimchi.select('guest-edit-general-graphics-list', spiceOpt); + } + if (kimchi.capabilities.hostarch == 'ppc64') { + serialOpt = [{label: 'Serial', value: 'serial'}] + kimchi.select('guest-edit-general-graphics-list', serialOpt); + } + }; + enableSpice(); }; kimchi.retrieveVM(kimchi.selectedGuest, initContent); @@ -405,13 +431,15 @@ kimchi.guest_edit_main = function() { var generalSubmit = function(event) { $(saveButton).prop('disabled', true); var data=$('#form-guest-edit-general').serializeObject(); + graphics_type=data.graphics if(data['memory']!=undefined) { data['memory'] = Number(data['memory']); } if(data['cpus']!=undefined) { data['cpus'] = Number(data['cpus']); } - + data['graphics'] = {} + data['graphics']['type'] = graphics_type kimchi.updateVM(kimchi.selectedGuest, data, function() { kimchi.listVmsAuto(); kimchi.window.close(); diff --git a/ui/js/src/kimchi.guest_main.js b/ui/js/src/kimchi.guest_main.js index dbe8753..5731634 100644 --- a/ui/js/src/kimchi.guest_main.js +++ b/ui/js/src/kimchi.guest_main.js @@ -266,11 +266,26 @@ kimchi.createGuestLi = function(vmObject, prevScreenImage, openMenu) { } var consoleActions=guestActions.find("[name=vm-console]"); + if ((vmObject.graphics['type'] == 'vnc') || (vmObject.graphics['type'] == 'spice')) { + if (vmRunningBool) { + consoleActions.on("click", kimchi.openVmConsole); + consoleActions.show(); + } + else { + consoleActions.hide(); + consoleActions.off("click",kimchi.openVmConsole); - if ((vmObject.graphics['type'] == 'vnc') || (vmObject.graphics['type'] == 'spice')) { - consoleActions.on("click", kimchi.openVmConsole); - consoleActions.show(); - } else { //we don't recognize the VMs supported graphics, so hide the menu choice + } + + } + else if ((vmObject.graphics['type'] == 'serial') && (vmRunningBool)) { + consoleActions.prop('disabled', true); + tooltip = $("#connectserial label") + tooltip.show() + tooltip.addClass("tooltip") + consoleActions.show(); + } + else { //we don't recognize the VMs supported graphics, so hide the menu choice consoleActions.hide(); consoleActions.off("click",kimchi.openVmConsole); } diff --git a/ui/js/src/kimchi.template_edit_main.js b/ui/js/src/kimchi.template_edit_main.js index cb43091..68d1a07 100644 --- a/ui/js/src/kimchi.template_edit_main.js +++ b/ui/js/src/kimchi.template_edit_main.js @@ -48,6 +48,10 @@ kimchi.template_edit_main = function() { var vncOpt = [{label: 'VNC', value: 'vnc'}]; kimchi.select('template-edit-graphics-list', vncOpt); + if (kimchi.capabilities.hostarch == 'ppc64') { + serialOpt = [{label: 'Serial', value: 'serial'}] + kimchi.select('template-edit-graphics-list', serialOpt); + } var enableSpice = function() { if (kimchi.capabilities == undefined) { setTimeout(enableSpice, 2000); diff --git a/ui/pages/guest-edit.html.tmpl b/ui/pages/guest-edit.html.tmpl index 0b7dad3..1e5e2b4 100644 --- a/ui/pages/guest-edit.html.tmpl +++ b/ui/pages/guest-edit.html.tmpl @@ -41,6 +41,7 @@ <li> <a href="#form-guest-edit-permission">$_("Permission")</a> </li> + </li> </ul> <form id="form-guest-edit-general"> <fieldset class="guest-edit-fieldset"> @@ -93,6 +94,26 @@ type="text" disabled="disabled" /> </div> + + + <div> + <div class="guest-edit-wrapper-label"> + <label for="guest-edit-graphics-textbox"> + $_("Graphics") + </label> + </div> + + <div class="guest-edit-wrapper-controls"> + <div class="btn dropdown popable"> + <input id="guest-edit-general-graphics" name="graphics" type="hidden"/> + + <span class="text" id="guest-edit-general-graphics-label"></span><span class="arrow"></span> + <div class="popover" style="width: 100%"> + <ul class="select-list" id="guest-edit-general-graphics-list" data-target="guest-edit-general-graphics" data-label="guest-edit-general-graphics-label"> + </ul> + </div> + </div> + </div> </div> </fieldset> </form> diff --git a/ui/pages/guest.html.tmpl b/ui/pages/guest.html.tmpl index 43fb350..de3b6bd 100644 --- a/ui/pages/guest.html.tmpl +++ b/ui/pages/guest.html.tmpl @@ -55,7 +55,10 @@ <div name="actionmenu" class="btn dropdown popable vm-action" style="width: 70px"> <span class="text">$_("Actions")</span><span class="arrow"></span> <div class="popover actionsheet right-side" style="width: 250px"> - <button class="button-big shutoff-disabled" name="vm-console" ><span class="text">$_("Connect")</span></button> + <button class="button-big toolable shutoff-disabled" name="vm-console"><span class="text">$_("Connect")</span><label name="connectserial" class="tooltip connectserial" display="hidden">$_("Use virsh console to connect to this guest")</label></button> + <!-- <button class="button-big toolable shutoff-disabled" name="vm-console"><span class="text">$_("Connect")</span></button> --> + + <button class="button-big shutoff-disabled" name="vm-media"><span class="text">$_("Manage Media")</span></button> <button class="button-big running-disabled" name="vm-edit"><span class="text">$_("Edit")</span></button> <button class="button-big shutoff-hidden" name="vm-reset"><span class="text">$_("Reset")</span></button> <button class="button-big shutoff-hidden" name="vm-shutdown"><span class="text">$_("Shut Down")</span></button> diff --git a/ui/pages/i18n.json.tmpl b/ui/pages/i18n.json.tmpl index d920ae2..f25c6bb 100644 --- a/ui/pages/i18n.json.tmpl +++ b/ui/pages/i18n.json.tmpl @@ -172,4 +172,5 @@ "KCHVMSTOR0001E": "$_("CDROM path need to be a valid local path and cannot be blank.")", "KCHVMSTOR0002E": "$_("Disk pool or volume cannot be blank.")" + } diff --git a/ui/pages/tabs/storage.html.tmpl b/ui/pages/tabs/storage.html.tmpl index 87205bd..563504d 100644 --- a/ui/pages/tabs/storage.html.tmpl +++ b/ui/pages/tabs/storage.html.tmpl @@ -58,10 +58,10 @@ </div> <div class="storage-state"> <div class="status-dot toolable active" data-state="{state}"> - <label class="tooltip">$_("active")</label> + <label class="tooltip storage">$_("active")</label> </div> <div class="status-dot toolable inactive" data-state="{state}"> - <label class="tooltip">$_("inactive")</label> + <label class="tooltip storage">$_("inactive")</label> </div> </div> <div class="storage-location"> -- 1.9.3

Some overview: 1. We use 4 spaces for indentation in all files. I didn't apply the patch but it seems there are some tabs in it. 2. We use 80 characters limit You can run "make check-local" to make sure your code is respecting it in other pep8 rules. 3. You need to update docs/API.md to document this new behavior 4. You need to update mockmodel.py As its name says it is a mock model used when running Kimchi with --test. It used by user to check Kimchi feature without affecting the system. 5. Add tests for it. Under /tests we have unit tests that are ran by "make check" test_rest.py uses mockmodel and test the REST API test_model.py test the model itself and so on 6. I suggest spitting this patch in (at least) 2 patches: one for backend and other one for UI That way the patch is not blocked by some comments on UI and it is easy to review small patch =) 7. More comments inline below. On 09/09/2014 11:14 AM, bbaude@redhat.com wrote:
From: Brent Baude <bbaude@redhat.com>
This patch allows you to change the graphics type for a cold guest. It will also allow a serial choice for ppc64 machines where a user can then run virsh console to deal with low-bandwidth issues.
--- src/kimchi/API.json | 5 +- src/kimchi/model/config.py | 2 + src/kimchi/model/vms.py | 177 +++++++++++++++++++++++++++------ src/kimchi/vmtemplate.py | 9 ++ ui/css/theme-default/guest-edit.css | 6 ++ ui/css/theme-default/storage.css | 58 ----------- ui/js/src/kimchi.guest_edit_main.js | 30 +++++- ui/js/src/kimchi.guest_main.js | 23 ++++- ui/js/src/kimchi.template_edit_main.js | 4 + ui/pages/guest-edit.html.tmpl | 21 ++++ ui/pages/guest.html.tmpl | 5 +- ui/pages/i18n.json.tmpl | 1 + ui/pages/tabs/storage.html.tmpl | 4 +- 13 files changed, 246 insertions(+), 99 deletions(-)
diff --git a/src/kimchi/API.json b/src/kimchi/API.json index 8a95804..335f1fd 100644 --- a/src/kimchi/API.json +++ b/src/kimchi/API.json @@ -9,7 +9,7 @@ "type": "object", "properties": { "type": { - "enum": ["spice", "vnc"], + "enum": ["spice", "vnc", "serial"], "error": "KCHVM0014E"
You need to update the message "KCHVM0014E" in the i18n.py file to add the new graphics type there.
}, "listen": { @@ -274,7 +274,8 @@ "type": "integer", "minimum": 512, "error": "KCHTMPL0013E" - } + }, + "graphics": { "$ref": "#/kimchitype/graphics" } } }, "networks_create": { diff --git a/src/kimchi/model/config.py b/src/kimchi/model/config.py index 1c00cfe..0b7ecc3 100644 --- a/src/kimchi/model/config.py +++ b/src/kimchi/model/config.py @@ -20,6 +20,7 @@ from multiprocessing.pool import ThreadPool
import cherrypy +import platform
from kimchi.basemodel import Singleton from kimchi.config import config as kconfig @@ -106,6 +107,7 @@ class CapabilitiesModel(object): return {'libvirt_stream_protocols': self.libvirt_stream_protocols, 'qemu_spice': self._qemu_support_spice(), 'qemu_stream': self.qemu_stream, + 'hostarch': platform.machine(), 'screenshot': VMScreenshot.get_stream_test_result(), 'system_report_tool': bool(report_tool), 'update_tool': update_tool, diff --git a/src/kimchi/model/vms.py b/src/kimchi/model/vms.py index 58686cd..8f69cef 100644 --- a/src/kimchi/model/vms.py +++ b/src/kimchi/model/vms.py @@ -20,10 +20,12 @@ from lxml.builder import E import lxml.etree as ET from lxml import etree, objectify +import platform import os import random import string import time +from datetime import datetime,timedelta import uuid from xml.etree import ElementTree
@@ -247,8 +249,13 @@ class VMModel(object): self.groups = import_class('kimchi.model.host.GroupsModel')(**kargs)
def update(self, name, params): + if 'ticket' in params: + password = params['ticket'].get("passwd")
It was updated to "graphics" instead of "ticket"
+ password = password if password is not None else "".join( + random.sample(string.ascii_letters + string.digits, 8)) + params['ticket']['passwd'] = password
This was also changed recently. The password will be automatically generated if the passed value is an empty string,
dom = self.get_vm(name, self.conn) - dom = self._static_vm_update(dom, params) + dom = self._static_vm_update(dom, params, name) self._live_vm_update(dom, params) return dom.name().decode('utf-8')
@@ -305,41 +312,107 @@ class VMModel(object): os_elem = E.os({"distro": distro, "version": version}) set_metadata_node(dom, os_elem)
- def _update_graphics(self, dom, xml, params): - root = objectify.fromstring(xml) - graphics = root.devices.find("graphics") - if graphics is None: + def _update_graphics(self, dom, xml, params, name):
+## root = objectify.fromstring(xml) +## graphics = root.devices.find("graphics") +
If the above code is not needed anymore remove the lines instead of comment them
+ curgraphics = self._vm_get_graphics(name) + graphics_type, graphics_listen, graphics_port, graphics_password, graphics_validto = curgraphics + + if graphics_type is None : return xml + + if graphics_password != None : params['graphics']['passwd'] = graphics_password
Split it in 2 lines. It is easy to read.
+ if graphics_validto != None : + params['graphics']['passwdValidTo'] = graphics_validto
- password = params['graphics'].get("passwd") - if password is not None and len(password.strip()) == 0: - password = "".join(random.sample(string.ascii_letters +
+ try: + ## Determine if a change in graphics type has been submitted + if ('graphics' in params) and (params['graphics']['type'] != None): + hasGraphicTypeChange = True + except: + hasGraphicTypeChange = False +
If the code gets this point, the "graphics" is in params dict. So you just need to check for type if params["graphics"].get("type") is not None: # continue the follow to change the graphics type
+ if hasGraphicTypeChange: + newGraphicsType=params['graphics']['type'] + + ## Remove current graphics implementations by node + newxml=xml + root = etree.fromstring(newxml) + devices = root.find("devices") + if graphics_type == 'vnc': + nodeDelete=[devices.find("graphics"),devices.find("video")] + elif graphics_type == 'spice': + nodeDelete=[devices.find("graphics"),devices.find("channel"), devices.find("video")] + elif graphics_type == 'serial': + nodeDelete=[devices.find("serial"),devices.find("console")] + else: + nodeDelete='' + if nodeDelete: + for i in nodeDelete: + devices.remove(i) + ## Add new graphics information back in by node + if newGraphicsType == 'spice': + newchannel=etree.SubElement(devices,'channel',type='spicevmc') + newtarget=etree.SubElement(newchannel,'target',type='virtio',name='com.redhat.spice.0') + newgraphics=etree.SubElement(devices,'graphics',type='spice', listen='127.0.0.1', autoport='yes') + if 'passwd' in params['graphics']: newgraphics.attrib['passwd'] = graphics_password + if 'passwdValidTo' in params['graphics']: newgraphics.attrib['passwdValidTo'] = self._convert_datetime(str(graphics_validto)) + newlisten=etree.SubElement(newgraphics,'listen',type='address',address='127.0.0.1') + elif newGraphicsType == 'vnc': + newgraphics=etree.SubElement(devices,'graphics',type='vnc', listen='127.0.0.1', autoport='yes', port='-1') + newlisten=etree.SubElement(newgraphics,'listen',type='address',address='127.0.0.1') + if 'passwd' in params['graphics']: newgraphics.attrib['passwd'] = graphics_password + if 'passwdValidTo' in params['graphics']: newgraphics.attrib['passwdValidTo'] = self._convert_datetime(str(graphics_validto)) + + elif newGraphicsType == 'serial': + newconsole=etree.SubElement(devices,'console',type='pty') + newtarget=etree.SubElement(newconsole,'target',type='serial', port='0') + newaddress=etree.SubElement(newconsole,'address',type='spapr-vio', reg='0x30000000') + xml = etree.tostring(root)
This is really a big piece of code. I suggest move it to a function. And thanks for using etree to generate the XML. I'd say to you create a new module to input it. As we can reuse it while creating the VM too. src/kimchi/xmlutils/graphics.py And then we can insert more and more modules there.
+ if ('passwd' in params['graphics']) or ('passwdValidTo' in params['graphics']): + root = objectify.fromstring(xml) + graphics = root.devices.find("graphics") + + password = params['graphics'].get("passwd") + if password is not None and len(password.strip()) == 0: + password = "".join(random.sample(string.ascii_letters + string.digits, 8))
- if password is not None: - graphics.attrib['passwd'] = password + if password is not None: + graphics.attrib['passwd'] = password
- expire = params['graphics'].get("passwdValidTo") - to = graphics.attrib.get('passwdValidTo') - if to is not None: - if (time.mktime(time.strptime(to, '%Y-%m-%dT%H:%M:%S')) - - time.time() <= 0): - expire = expire if expire is not None else 30 + expire = params['graphics'].get("passwdValidTo") + to = graphics.attrib.get('passwdValidTo') + if to is not None: + if (time.mktime(time.strptime(to, '%Y-%m-%dT%H:%M:%S')) + - time.time() <= 0): + expire = expire if expire is not None else 30
- if expire is not None: - expire_time = time.gmtime(time.time() + float(expire)) - valid_to = time.strftime('%Y-%m-%dT%H:%M:%S', expire_time) - graphics.attrib['passwdValidTo'] = valid_to + if expire is not None: + expire_time = time.gmtime(time.time() + float(expire)) + valid_to = time.strftime('%Y-%m-%dT%H:%M:%S', expire_time) + graphics.attrib['passwdValidTo'] = valid_to
- if not dom.isActive(): - return ET.tostring(root, encoding="utf-8") + if not dom.isActive(): + return ET.tostring(root, encoding="utf-8")
- xml = dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE) - dom.updateDeviceFlags(etree.tostring(graphics), + xml = dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE) + dom.updateDeviceFlags(etree.tostring(graphics), libvirt.VIR_DOMAIN_AFFECT_LIVE) return xml
- def _static_vm_update(self, dom, params): + def _convert_datetime(self,deltaseconds): + deltaseconds = int(deltaseconds) + # takes the delta (in seconds) between the current time + # and the expiration time for the vnc password + mycur = datetime.strptime((time.strftime('%Y-%m-%dT%H:%M:%S',time.gmtime())),'%Y-%m-%dT%H:%M:%S') + expiredate = time.strptime((str(mycur + timedelta(seconds=deltaseconds))),'%Y-%m-%d %H:%M:%S') + + return time.strftime('%Y-%m-%dT%H:%M:%S',expiredate) + + + def _static_vm_update(self, dom, params, name): state = DOM_STATE_MAP[dom.info()[0]] old_xml = new_xml = dom.XMLDesc(0)
@@ -355,7 +428,7 @@ class VMModel(object): new_xml = xmlutils.xml_item_update(new_xml, xpath, val)
if 'graphics' in params: - new_xml = self._update_graphics(dom, new_xml, params) + new_xml = self._update_graphics(dom, new_xml, params, name)
conn = self.conn.get() try: @@ -383,15 +456,35 @@ class VMModel(object): dom = ElementTree.fromstring(dom.XMLDesc(0)) return dom.find('devices/video') is not None
+ def _get_ticket(self, dom): + xml = dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE) + root = objectify.fromstring(xml) + graphic = root.devices.find("graphics") + if graphic is None: + return {"passwd": None, "expire": None} + passwd = graphic.attrib.get('passwd') + ticket = {"passwd": passwd} + valid_to = graphic.attrib.get('passwdValidTo') + ticket['expire'] = None + if valid_to is not None: + to = time.mktime(time.strptime(valid_to, '%Y-%m-%dT%H:%M:%S')) + ticket['expire'] = to - time.mktime(time.gmtime()) + + return ticket +
It was removed recently
def lookup(self, name): dom = self.get_vm(name, self.conn) info = dom.info() state = DOM_STATE_MAP[info[0]] screenshot = None - # (type, listen, port, passwd, passwdValidTo) graphics = self._vm_get_graphics(name) + graphics_type = graphics[0] graphics_port = graphics[2] graphics_port = graphics_port if state == 'running' else None
+ if graphics_type is None and platform.machine() == "ppc64": + ## Checking for ppc64 serial console + graphics_type = "serial" +
If the host is a ppc64 macihne it does not mean the VM has the serial console, right? So you should respect the value returned by _vm_get_graphics(name)
try: if state == 'running' and self._has_video(dom): screenshot = self.vmscreenshot.lookup(name) @@ -539,8 +632,21 @@ class VMModel(object):
expr = "/domain/devices/graphics/@type" res = xmlutils.xpath_get_text(xml, expr) - graphics_type = res[0] if res else None - + if res: + graphics_type = res[0] + else: + ## Checking for serial console, if not + ## returning None + expr = "/domain/devices/console/@type" + res = xmlutils.xpath_get_text(xml, expr) + if res: + if res[0] == "pty": + graphics_type = "serial" + else: + graphics_type = None + else: + graphics_type = None + expr = "/domain/devices/graphics/@listen" res = xmlutils.xpath_get_text(xml, expr) graphics_listen = res[0] if res else None @@ -558,8 +664,15 @@ class VMModel(object): expr = "/domain/devices/graphics[@type='%s']/@passwdValidTo" res = xmlutils.xpath_get_text(xml, expr % graphics_type) if res: - to = time.mktime(time.strptime(res[0], '%Y-%m-%dT%H:%M:%S')) - graphics_passwdValidTo = to - time.mktime(time.gmtime()) + currdate = datetime.strptime((time.strftime('%Y-%m-%dT%H:%M:%S',time.gmtime())),'%Y-%m-%dT%H:%M:%S') + graphics_expiredate = (datetime.strptime(res[0],'%Y-%m-%dT%H:%M:%S')) + mymax = max((currdate, graphics_expiredate)) + expirediff = currdate - graphics_expiredate + if mymax == currdate: + ## time has expired + graphics_passwdValidTo = abs(expirediff).seconds * -1 + else: + graphics_passwdValidTo = abs(expirediff).seconds
return (graphics_type, graphics_listen, graphics_port, graphics_passwd, graphics_passwdValidTo) @@ -567,6 +680,8 @@ class VMModel(object): def connect(self, name): # (type, listen, port, passwd, passwdValidTo) graphics_port = self._vm_get_graphics(name)[2] + graphics = self._vm_get_graphics(name) + graphics_type, graphics_listen, graphics_port = graphics if graphics_port is not None: vnc.add_proxy_token(name, graphics_port) else: diff --git a/src/kimchi/vmtemplate.py b/src/kimchi/vmtemplate.py index c5bb7b3..f735d14 100644 --- a/src/kimchi/vmtemplate.py +++ b/src/kimchi/vmtemplate.py @@ -217,12 +217,21 @@ drive=drive-%(bus)s0-1-0,id=%(bus)s0-1-0'/> <target type='virtio' name='com.redhat.spice.0'/> </channel> """ +
+ serial_xml = """ + <console type='pty'> + <target type='serial' port='0'/> + <address type='spapr-vio' reg='0x30000000'/> + </console> + """
By doing the XML module I suggested before you can reuse the XML generated by etree here.
graphics = dict(self.info['graphics']) if params: graphics.update(params) graphics_xml = graphics_xml % graphics if graphics['type'] == 'spice': graphics_xml = graphics_xml + spicevmc_xml + elif graphics['type'] == 'serial' : + graphics_xml = serial_xml return graphics_xml
def _get_scsi_disks_xml(self): diff --git a/ui/css/theme-default/guest-edit.css b/ui/css/theme-default/guest-edit.css index 74c2237..8515cda 100644 --- a/ui/css/theme-default/guest-edit.css +++ b/ui/css/theme-default/guest-edit.css @@ -63,6 +63,12 @@ width: 470px; }
+.guest-edit-wrapper-controls > .dropdown { + margin: 5px 0 0 1px; + width: 440px; +} + + #form-guest-edit-storage .guest-edit-wrapper-controls { width: 486px; } diff --git a/ui/css/theme-default/storage.css b/ui/css/theme-default/storage.css index f635c2f..eeb783c 100644 --- a/ui/css/theme-default/storage.css +++ b/ui/css/theme-default/storage.css @@ -222,64 +222,6 @@ width: 13px; }
-.toolable { - position: relative; -} - -.toolable .tooltip { - display: none; - border: 2px solid #0B6BAD; - background: #fff; - padding: 6px; - position: absolute; - color: #666666; - font-weight: bold; - font-size: 11px; - -webkit-border-radius: 5px; - -moz-border-radius: 5px; - border-radius: 5px; - z-index: 100; - top: -300%; - left: -140%; - white-space: nowrap; -} - -.toolable:hover .tooltip { - display: block; -} - -.toolable .tooltip:after { - -moz-border-bottom-colors: none; - -moz-border-left-colors: none; - -moz-border-right-colors: none; - -moz-border-top-colors: none; - border-color: #fff transparent transparent; - border-image: none; - border-style: solid; - border-width: 7px; - content: ""; - display: block; - left: 15px; - position: absolute; - bottom: -14px; -} - -.toolable .tooltip:before { - -moz-border-bottom-colors: none; - -moz-border-left-colors: none; - -moz-border-right-colors: none; - -moz-border-top-colors: none; - border-color: #0B6BAD transparent transparent; - border-image: none; - border-style: solid; - border-width: 8px; - content: ""; - display: block; - left: 14px; - position: absolute; - bottom: -18px; -} -
To where the above content went ?
.inactive { background: #E80501; background: linear-gradient(to bottom, #E88692 0%, #E84845 50%, diff --git a/ui/js/src/kimchi.guest_edit_main.js b/ui/js/src/kimchi.guest_edit_main.js index 938dfd9..2e71736 100644 --- a/ui/js/src/kimchi.guest_edit_main.js +++ b/ui/js/src/kimchi.guest_edit_main.js @@ -386,6 +386,7 @@ kimchi.guest_edit_main = function() { };
initStorageListeners(); + setupInterface(); setupPermission();
@@ -398,6 +399,31 @@ kimchi.guest_edit_main = function() { kimchi.topic('kimchi/vmCDROMReplaced').unsubscribe(onReplaced); kimchi.topic('kimchi/vmCDROMDetached').unsubscribe(onDetached); }; + + graphics_type = guest.graphics.type + + var graphicsForm = $('#form-guest-edit-general'); + $('input[name="graphics"]', graphicsForm).val(graphics_type); + + + var vncOpt = [{label: 'VNC', value: 'vnc'}]; + kimchi.select('guest-edit-general-graphics-list', vncOpt); + + var enableSpice = function() { + if (kimchi.capabilities == undefined) { + setTimeout(enableSpice, 2000); + return; + } + if (kimchi.capabilities.qemu_spice == true) { + spiceOpt = [{label: 'Spice', value: 'spice'}] + kimchi.select('guest-edit-general-graphics-list', spiceOpt); + } + if (kimchi.capabilities.hostarch == 'ppc64') { + serialOpt = [{label: 'Serial', value: 'serial'}] + kimchi.select('guest-edit-general-graphics-list', serialOpt); + } + }; + enableSpice(); };
kimchi.retrieveVM(kimchi.selectedGuest, initContent); @@ -405,13 +431,15 @@ kimchi.guest_edit_main = function() { var generalSubmit = function(event) { $(saveButton).prop('disabled', true); var data=$('#form-guest-edit-general').serializeObject(); + graphics_type=data.graphics if(data['memory']!=undefined) { data['memory'] = Number(data['memory']); } if(data['cpus']!=undefined) { data['cpus'] = Number(data['cpus']); } - + data['graphics'] = {} + data['graphics']['type'] = graphics_type kimchi.updateVM(kimchi.selectedGuest, data, function() { kimchi.listVmsAuto(); kimchi.window.close(); diff --git a/ui/js/src/kimchi.guest_main.js b/ui/js/src/kimchi.guest_main.js index dbe8753..5731634 100644 --- a/ui/js/src/kimchi.guest_main.js +++ b/ui/js/src/kimchi.guest_main.js @@ -266,11 +266,26 @@ kimchi.createGuestLi = function(vmObject, prevScreenImage, openMenu) { }
var consoleActions=guestActions.find("[name=vm-console]"); + if ((vmObject.graphics['type'] == 'vnc') || (vmObject.graphics['type'] == 'spice')) { + if (vmRunningBool) { + consoleActions.on("click", kimchi.openVmConsole); + consoleActions.show(); + } + else { + consoleActions.hide(); + consoleActions.off("click",kimchi.openVmConsole);
- if ((vmObject.graphics['type'] == 'vnc') || (vmObject.graphics['type'] == 'spice')) { - consoleActions.on("click", kimchi.openVmConsole); - consoleActions.show(); - } else { //we don't recognize the VMs supported graphics, so hide the menu choice + } + + } + else if ((vmObject.graphics['type'] == 'serial') && (vmRunningBool)) { + consoleActions.prop('disabled', true); + tooltip = $("#connectserial label") + tooltip.show() + tooltip.addClass("tooltip") + consoleActions.show(); + } + else { //we don't recognize the VMs supported graphics, so hide the menu choice consoleActions.hide(); consoleActions.off("click",kimchi.openVmConsole); } diff --git a/ui/js/src/kimchi.template_edit_main.js b/ui/js/src/kimchi.template_edit_main.js index cb43091..68d1a07 100644 --- a/ui/js/src/kimchi.template_edit_main.js +++ b/ui/js/src/kimchi.template_edit_main.js @@ -48,6 +48,10 @@ kimchi.template_edit_main = function() {
var vncOpt = [{label: 'VNC', value: 'vnc'}]; kimchi.select('template-edit-graphics-list', vncOpt); + if (kimchi.capabilities.hostarch == 'ppc64') { + serialOpt = [{label: 'Serial', value: 'serial'}] + kimchi.select('template-edit-graphics-list', serialOpt); + } var enableSpice = function() { if (kimchi.capabilities == undefined) { setTimeout(enableSpice, 2000); diff --git a/ui/pages/guest-edit.html.tmpl b/ui/pages/guest-edit.html.tmpl index 0b7dad3..1e5e2b4 100644 --- a/ui/pages/guest-edit.html.tmpl +++ b/ui/pages/guest-edit.html.tmpl @@ -41,6 +41,7 @@ <li> <a href="#form-guest-edit-permission">$_("Permission")</a> </li> + </li> </ul> <form id="form-guest-edit-general"> <fieldset class="guest-edit-fieldset"> @@ -93,6 +94,26 @@ type="text" disabled="disabled" /> </div> + + + <div> + <div class="guest-edit-wrapper-label"> + <label for="guest-edit-graphics-textbox"> + $_("Graphics") + </label> + </div> + + <div class="guest-edit-wrapper-controls"> + <div class="btn dropdown popable"> + <input id="guest-edit-general-graphics" name="graphics" type="hidden"/> + + <span class="text" id="guest-edit-general-graphics-label"></span><span class="arrow"></span> + <div class="popover" style="width: 100%"> + <ul class="select-list" id="guest-edit-general-graphics-list" data-target="guest-edit-general-graphics" data-label="guest-edit-general-graphics-label"> + </ul> + </div> + </div> + </div> </div> </fieldset> </form> diff --git a/ui/pages/guest.html.tmpl b/ui/pages/guest.html.tmpl index 43fb350..de3b6bd 100644 --- a/ui/pages/guest.html.tmpl +++ b/ui/pages/guest.html.tmpl @@ -55,7 +55,10 @@ <div name="actionmenu" class="btn dropdown popable vm-action" style="width: 70px"> <span class="text">$_("Actions")</span><span class="arrow"></span> <div class="popover actionsheet right-side" style="width: 250px"> - <button class="button-big shutoff-disabled" name="vm-console" ><span class="text">$_("Connect")</span></button> + <button class="button-big toolable shutoff-disabled" name="vm-console"><span class="text">$_("Connect")</span><label name="connectserial" class="tooltip connectserial" display="hidden">$_("Use virsh console to connect to this guest")</label></button> + <!-- <button class="button-big toolable shutoff-disabled" name="vm-console"><span class="text">$_("Connect")</span></button> --> + + <button class="button-big shutoff-disabled" name="vm-media"><span class="text">$_("Manage Media")</span></button> <button class="button-big running-disabled" name="vm-edit"><span class="text">$_("Edit")</span></button> <button class="button-big shutoff-hidden" name="vm-reset"><span class="text">$_("Reset")</span></button> <button class="button-big shutoff-hidden" name="vm-shutdown"><span class="text">$_("Shut Down")</span></button> diff --git a/ui/pages/i18n.json.tmpl b/ui/pages/i18n.json.tmpl index d920ae2..f25c6bb 100644 --- a/ui/pages/i18n.json.tmpl +++ b/ui/pages/i18n.json.tmpl @@ -172,4 +172,5 @@
"KCHVMSTOR0001E": "$_("CDROM path need to be a valid local path and cannot be blank.")", "KCHVMSTOR0002E": "$_("Disk pool or volume cannot be blank.")" + } diff --git a/ui/pages/tabs/storage.html.tmpl b/ui/pages/tabs/storage.html.tmpl index 87205bd..563504d 100644 --- a/ui/pages/tabs/storage.html.tmpl +++ b/ui/pages/tabs/storage.html.tmpl @@ -58,10 +58,10 @@ </div> <div class="storage-state"> <div class="status-dot toolable active" data-state="{state}"> - <label class="tooltip">$_("active")</label> + <label class="tooltip storage">$_("active")</label> </div> <div class="status-dot toolable inactive" data-state="{state}"> - <label class="tooltip">$_("inactive")</label> + <label class="tooltip storage">$_("inactive")</label> </div> </div> <div class="storage-location">
participants (2)
-
Aline Manera
-
bbaude@redhat.com