Merge remote-tracking branch 'origin/pr/135'

* origin/pr/135:
  Fixed bug in handling state changes
  Improved template manager UI
  Missing file added
  Added links to template manager to qube manager
  Initial template manager
This commit is contained in:
Marek Marczykowski-Górecki 2018-12-08 01:25:04 +01:00
commit 7dfa33b633
No known key found for this signature in database
GPG Key ID: 063938BA42CFA724
7 changed files with 616 additions and 2 deletions

View File

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Exec=qubes-template-manager
Icon=qubes-manager
Terminal=false
Name=Qubes Template Manager
GenericName=Qubes Template Manager
StartupNotify=false
Categories=System;

View File

@ -1041,6 +1041,12 @@ class VmManagerWindow(ui_qubemanager.Ui_VmManagerWindow, QtGui.QMainWindow):
self.qubes_app) self.qubes_app)
global_settings_window.exec_() global_settings_window.exec_()
# noinspection PyArgumentList
@QtCore.pyqtSlot(name='on_action_manage_templates_triggered')
def action_manage_templates_triggered(self):
# pylint: disable=invalid-name, no-self-use
subprocess.check_call('qubes-template-manager')
# noinspection PyArgumentList # noinspection PyArgumentList
@QtCore.pyqtSlot(name='on_action_show_network_triggered') @QtCore.pyqtSlot(name='on_action_show_network_triggered')
def action_show_network_triggered(self): def action_show_network_triggered(self):

View File

@ -0,0 +1,459 @@
#!/usr/bin/python3
#
# The Qubes OS Project, http://www.qubes-os.org
#
# Copyright (C) 2018 Marta Marczykowska-Górecka
# <marmarta@invisiblethingslab.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program; if not, see <http://www.gnu.org/licenses/>.
#
#
import sys
import os
import os.path
import traceback
import quamash
import asyncio
from contextlib import suppress
from qubesadmin import Qubes
from qubesadmin import exc
from qubesadmin import events
from PyQt4 import QtGui # pylint: disable=import-error
from PyQt4 import QtCore # pylint: disable=import-error
from PyQt4 import Qt # pylint: disable=import-error
from . import ui_templatemanager # pylint: disable=no-name-in-module
column_names = ['State', 'Qube', 'Current template', 'New template']
class TemplateManagerWindow(
ui_templatemanager.Ui_MainWindow, QtGui.QMainWindow):
def __init__(self, qt_app, qubes_app, dispatcher, parent=None):
# pylint: disable=unused-argument
super(TemplateManagerWindow, self).__init__()
self.setupUi(self)
self.qubes_app = qubes_app
self.qt_app = qt_app
self.dispatcher = dispatcher
self.rows_in_table = {}
self.templates = []
self.timers = []
self.prepare_lists()
self.initialize_table_events()
self.buttonBox.button(QtGui.QDialogButtonBox.Ok).clicked.connect(
self.apply)
self.buttonBox.button(QtGui.QDialogButtonBox.Cancel).clicked.connect(
self.cancel)
self.buttonBox.button(QtGui.QDialogButtonBox.Reset).clicked.connect(
self.reset)
self.change_all_combobox.currentIndexChanged.connect(
self.change_all_changed)
self.clear_selection_button.clicked.connect(self.clear_selection)
self.vm_list.show()
def prepare_lists(self):
self.templates = [vm.name for vm in self.qubes_app.domains
if vm.klass == 'TemplateVM']
self.change_all_combobox.addItem('(select template)')
for template in self.templates:
self.change_all_combobox.addItem(template)
vms_with_templates = [vm for vm in self.qubes_app.domains
if getattr(vm, 'template', None)]
self.vm_list.setColumnCount(len(column_names))
self.vm_list.setRowCount(len(vms_with_templates))
row_count = 0
for vm in vms_with_templates:
row = VMRow(vm, row_count, self.vm_list, column_names,
self.templates)
self.rows_in_table[vm.name] = row
row_count += 1
self.vm_list.setHorizontalHeaderLabels(['', 'Qube', 'Current', 'New'])
self.vm_list.resizeColumnsToContents()
def initialize_table_events(self):
self.vm_list.cellDoubleClicked.connect(self.table_double_click)
self.vm_list.cellClicked.connect(self.table_click)
self.vm_list.horizontalHeader().sortIndicatorChanged.connect(
self.sorting_changed)
self.dispatcher.add_handler('domain-pre-start', self.vm_state_changed)
self.dispatcher.add_handler('domain-start-failed',
self.vm_state_changed)
self.dispatcher.add_handler('domain-stopped', self.vm_state_changed)
self.dispatcher.add_handler('domain-shutdown', self.vm_state_changed)
self.dispatcher.add_handler('domain-add', self.vm_added)
self.dispatcher.add_handler('domain-delete', self.vm_removed)
def vm_added(self, _submitter, _event, vm, **_kwargs):
# unfortunately, a VM just in the moment of creation may not have
# a template it will have in a second - e.g., when cloning
timer = Qt.QTimer()
timer.setSingleShot(True)
timer.timeout.connect(lambda: self._vm_added(vm, timer))
self.timers.append(timer)
timer.start(1000) # 1s
def _vm_added(self, vm_name, timer):
self.timers.remove(timer)
try:
vm = self.qubes_app.domains[vm_name]
if not getattr(vm, 'template', None):
return
except (exc.QubesException, KeyError):
return # it was a dispVM that crashed on start
row_no = self.vm_list.rowCount()
self.vm_list.setRowCount(self.vm_list.rowCount() + 1)
row = VMRow(vm, row_no, self.vm_list, column_names,
self.templates)
self.rows_in_table[vm.name] = row
self.vm_list.show()
def vm_removed(self, _submitter, _event, **kwargs):
if kwargs['vm'] not in self.rows_in_table:
return
self.vm_list.removeRow(self.rows_in_table[kwargs['vm']].name_item.row())
def vm_state_changed(self, vm, event, **_kwargs):
try:
if vm.name not in self.rows_in_table:
return
except exc.QubesException:
return # it was a crashing DispVM or closed DispVM
if event == 'domain-pre-start':
self.rows_in_table[vm.name].vm_state_change(is_running=True)
elif event == 'domain-start-failed':
self.rows_in_table[vm.name].vm_state_change(is_running=False)
elif event == 'domain-stopped':
self.rows_in_table[vm.name].vm_state_change(is_running=False)
elif event == 'domain-shutdown':
self.rows_in_table[vm.name].vm_state_change(is_running=False)
def sorting_changed(self, index, _order):
# this is very much not perfect, but QTableWidget does not
# want to be sorted on custom widgets
if index == column_names.index('New template') or \
index == column_names.index('State'):
self.vm_list.horizontalHeader().setSortIndicator(
-1, QtCore.Qt.AscendingOrder)
def clear_selection(self):
for row in self.rows_in_table.values():
if row.checkbox:
row.checkbox.setChecked(False)
def change_all_changed(self):
if self.change_all_combobox.currentIndex() == 0:
return
selected_template = self.change_all_combobox.currentText()
for row in self.rows_in_table.values():
if row.checkbox and row.checkbox.isChecked():
row.new_item.setCurrentIndex(
row.new_item.findText(selected_template))
self.change_all_combobox.setCurrentIndex(0)
def table_double_click(self, row, column):
template_column = column_names.index('Current template')
if column != template_column:
return
template_name = self.vm_list.item(row, column).text()
for row_number in range(0, self.vm_list.rowCount()):
if self.vm_list.item(
row_number, template_column).text() == template_name:
checkbox = self.vm_list.cellWidget(
row_number, column_names.index('State'))
if checkbox:
if row_number == row:
# this is because double click registers as a
# single click and a double click
checkbox.setChecked(False)
else:
checkbox.setChecked(True)
def table_click(self, row, column):
if column == column_names.index('New template'):
return
checkbox = self.vm_list.cellWidget(row, column_names.index('State'))
if not checkbox:
return
checkbox.setChecked(not checkbox.isChecked())
def reset(self):
for row in self.rows_in_table.values():
if row.new_item:
row.new_item.reset_choice()
if row.checkbox:
row.checkbox.setChecked(False)
def cancel(self):
self.close()
def apply(self):
errors = {}
for vm, row in self.rows_in_table.items():
if row.new_item and row.new_item.changed:
try:
setattr(self.qubes_app.domains[vm],
'template', row.new_item.currentText())
except Exception as ex: # pylint: disable=broad-except
errors[vm] = str(ex)
if errors:
error_messages = [vm + ": " + errors[vm] for vm in errors]
QtGui.QMessageBox.warning(
self,
self.tr("Errors encountered!"),
self.tr(
"Errors encountered on template change in the following "
"qubes: <br> {}.").format("<br> ".join(error_messages)))
self.close()
class VMNameItem(QtGui.QTableWidgetItem):
# pylint: disable=too-few-public-methods
def __init__(self, vm):
super(VMNameItem, self).__init__()
self.vm = vm
self.setText(self.vm.name)
self.setIcon(QtGui.QIcon.fromTheme(vm.label.icon))
class StatusItem(QtGui.QTableWidgetItem):
def __init__(self, vm):
super(StatusItem, self).__init__()
self.vm = vm
self.state = None
def set_state(self, is_running):
self.state = is_running
if self.state:
self.setIcon(QtGui.QIcon.fromTheme('dialog-warning'))
self.setToolTip("Cannot change template on a running VM.")
else:
self.setIcon(QtGui.QIcon())
self.setToolTip("")
def __lt__(self, other):
if self.state == other.state:
return self.vm.name < other.vm.name
return self.state < other.state
class CurrentTemplateItem(QtGui.QTableWidgetItem):
# pylint: disable=too-few-public-methods
def __init__(self, vm):
super(CurrentTemplateItem, self).__init__()
self.vm = vm
self.setText(self.vm.template.name)
def __lt__(self, other):
if self.text() == other.text():
return self.vm.name < other.vm.name
return self.text() < other.text()
class NewTemplateItem(QtGui.QComboBox):
def __init__(self, vm, templates, table_widget):
super(NewTemplateItem, self).__init__()
self.vm = vm
self.table_widget = table_widget
self.changed = False
for template in templates:
self.addItem(template)
self.setCurrentIndex(self.findText(vm.template.name))
self.start_value = self.currentText()
self.currentIndexChanged.connect(self.choice_changed)
def choice_changed(self):
if self.currentText() != self.start_value:
self.changed = True
self.setStyleSheet('font-weight: bold')
else:
self.changed = False
self.setStyleSheet('font-weight: normal')
def reset_choice(self):
self.setCurrentIndex(self.findText(self.start_value))
class VMRow:
# pylint: disable=too-few-public-methods
def __init__(self, vm, row_no, table_widget, columns, templates):
self.vm = vm
self.table_widget = table_widget
self.templates = templates
# state
self.state_item = StatusItem(self.vm)
table_widget.setItem(row_no, columns.index('State'), self.state_item)
self.checkbox = QtGui.QCheckBox()
# icon and name
self.name_item = VMNameItem(self.vm)
table_widget.setItem(row_no, columns.index('Qube'), self.name_item)
# current template
self.current_item = CurrentTemplateItem(self.vm)
table_widget.setItem(row_no, columns.index('Current template'),
self.current_item)
# new template
self.dummy_new_item = QtGui.QTableWidgetItem("qube is running")
self.new_item = NewTemplateItem(self.vm, templates, table_widget)
table_widget.setItem(row_no, columns.index('New template'),
self.dummy_new_item)
self.vm_state_change(self.vm.is_running(), row_no)
def vm_state_change(self, is_running, row=None):
self.state_item.set_state(is_running)
if not row:
row = 0
while row < self.table_widget.rowCount():
if self.table_widget.item(
row, column_names.index('Qube')).text() == \
self.name_item.text():
break
row += 1
# hiding cellWidgets does not work in a qTableWidget
if not is_running:
self.new_item = NewTemplateItem(self.vm, self.templates,
self.table_widget)
self.checkbox = QtGui.QCheckBox()
self.table_widget.setCellWidget(
row, column_names.index('New template'), self.new_item)
self.table_widget.setCellWidget(
row, column_names.index('State'), self.checkbox)
else:
new_template = self.table_widget.cellWidget(
row, column_names.index('New template'))
if new_template:
self.table_widget.removeCellWidget(
row, column_names.index('New template'))
self.new_item = None
checkbox = self.table_widget.cellWidget(
row, column_names.index('State'))
if checkbox:
self.table_widget.removeCellWidget(
row, column_names.index('State'))
self.checkbox = None
# Bases on the original code by:
# Copyright (c) 2002-2007 Pascal Varet <p.varet@gmail.com>
def handle_exception(exc_type, exc_value, exc_traceback):
filename, line, dummy, dummy = traceback.extract_tb(exc_traceback).pop()
filename = os.path.basename(filename)
error = "%s: %s" % (exc_type.__name__, exc_value)
strace = ""
stacktrace = traceback.extract_tb(exc_traceback)
while stacktrace:
(filename, line, func, txt) = stacktrace.pop()
strace += "----\n"
strace += "line: %s\n" % txt
strace += "func: %s\n" % func
strace += "line no.: %d\n" % line
strace += "file: %s\n" % filename
msg_box = QtGui.QMessageBox()
msg_box.setDetailedText(strace)
msg_box.setIcon(QtGui.QMessageBox.Critical)
msg_box.setWindowTitle("Houston, we have a problem...")
msg_box.setText("Whoops. A critical error has occured. "
"This is most likely a bug in Qubes Manager.<br><br>"
"<b><i>%s</i></b>" % error +
"<br/>at line <b>%d</b><br/>of file %s.<br/><br/>"
% (line, filename))
msg_box.exec_()
def loop_shutdown():
pending = asyncio.Task.all_tasks()
for task in pending:
with suppress(asyncio.CancelledError):
task.cancel()
def main():
qt_app = QtGui.QApplication(sys.argv)
qt_app.setOrganizationName("The Qubes Project")
qt_app.setOrganizationDomain("http://qubes-os.org")
qt_app.setApplicationName("Qube Manager")
qt_app.setWindowIcon(QtGui.QIcon.fromTheme("qubes-manager"))
qt_app.lastWindowClosed.connect(loop_shutdown)
qubes_app = Qubes()
loop = quamash.QEventLoop(qt_app)
asyncio.set_event_loop(loop)
dispatcher = events.EventsDispatcher(qubes_app)
manager_window = TemplateManagerWindow(qt_app, qubes_app, dispatcher)
manager_window.show()
try:
loop.run_until_complete(
asyncio.ensure_future(dispatcher.listen_for_events()))
except asyncio.CancelledError:
pass
except Exception: # pylint: disable=broad-except
loop_shutdown()
exc_type, exc_value, exc_traceback = sys.exc_info()[:3]
handle_exception(exc_type, exc_value, exc_traceback)
if __name__ == "__main__":
main()

View File

@ -48,6 +48,7 @@ cp qubes-vm-create.desktop $RPM_BUILD_ROOT/usr/share/applications/
cp qubes-backup.desktop $RPM_BUILD_ROOT/usr/share/applications/ cp qubes-backup.desktop $RPM_BUILD_ROOT/usr/share/applications/
cp qubes-backup-restore.desktop $RPM_BUILD_ROOT/usr/share/applications/ cp qubes-backup-restore.desktop $RPM_BUILD_ROOT/usr/share/applications/
cp qubes-qube-manager.desktop $RPM_BUILD_ROOT/usr/share/applications/ cp qubes-qube-manager.desktop $RPM_BUILD_ROOT/usr/share/applications/
cp qubes-template-manager.desktop $RPM_BUILD_ROOT/usr/share/applications/
%post %post
update-desktop-database &> /dev/null || : update-desktop-database &> /dev/null || :
@ -68,6 +69,7 @@ rm -rf $RPM_BUILD_ROOT
/usr/bin/qubes-backup-restore /usr/bin/qubes-backup-restore
/usr/bin/qubes-qube-manager /usr/bin/qubes-qube-manager
/usr/bin/qubes-log-viewer /usr/bin/qubes-log-viewer
/usr/bin/qubes-template-manager
/usr/libexec/qubes-manager/mount_for_backup.sh /usr/libexec/qubes-manager/mount_for_backup.sh
/usr/libexec/qubes-manager/qvm_about.sh /usr/libexec/qubes-manager/qvm_about.sh
@ -95,6 +97,7 @@ rm -rf $RPM_BUILD_ROOT
%{python3_sitelib}/qubesmanager/utils.py %{python3_sitelib}/qubesmanager/utils.py
%{python3_sitelib}/qubesmanager/bootfromdevice.py %{python3_sitelib}/qubesmanager/bootfromdevice.py
%{python3_sitelib}/qubesmanager/device_list.py %{python3_sitelib}/qubesmanager/device_list.py
%{python3_sitelib}/qubesmanager/template_manager.py
%{python3_sitelib}/qubesmanager/resources_rc.py %{python3_sitelib}/qubesmanager/resources_rc.py
@ -112,6 +115,7 @@ rm -rf $RPM_BUILD_ROOT
%{python3_sitelib}/qubesmanager/ui_informationnotes.py %{python3_sitelib}/qubesmanager/ui_informationnotes.py
%{python3_sitelib}/qubesmanager/ui_qubemanager.py %{python3_sitelib}/qubesmanager/ui_qubemanager.py
%{python3_sitelib}/qubesmanager/ui_devicelist.py %{python3_sitelib}/qubesmanager/ui_devicelist.py
%{python3_sitelib}/qubesmanager/ui_templatemanager.py
%{python3_sitelib}/qubesmanager/i18n/qubesmanager_*.qm %{python3_sitelib}/qubesmanager/i18n/qubesmanager_*.qm
%{python3_sitelib}/qubesmanager/i18n/qubesmanager_*.ts %{python3_sitelib}/qubesmanager/i18n/qubesmanager_*.ts
@ -129,6 +133,7 @@ rm -rf $RPM_BUILD_ROOT
/usr/share/applications/qubes-backup.desktop /usr/share/applications/qubes-backup.desktop
/usr/share/applications/qubes-backup-restore.desktop /usr/share/applications/qubes-backup-restore.desktop
/usr/share/applications/qubes-qube-manager.desktop /usr/share/applications/qubes-qube-manager.desktop
/usr/share/applications/qubes-template-manager.desktop
%changelog %changelog
@CHANGELOG@ @CHANGELOG@

View File

@ -25,6 +25,7 @@ if __name__ == '__main__':
'qubes-backup = qubesmanager.backup:main', 'qubes-backup = qubesmanager.backup:main',
'qubes-backup-restore = qubesmanager.restore:main', 'qubes-backup-restore = qubesmanager.restore:main',
'qubes-qube-manager = qubesmanager.qube_manager:main', 'qubes-qube-manager = qubesmanager.qube_manager:main',
'qubes-log-viewer = qubesmanager.log_dialog:main' 'qubes-log-viewer = qubesmanager.log_dialog:main',
'qubes-template-manager = qubesmanager.template_manager:main'
], ],
}) })

View File

@ -244,7 +244,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>1100</width> <width>1100</width>
<height>46</height> <height>28</height>
</rect> </rect>
</property> </property>
<property name="contextMenuPolicy"> <property name="contextMenuPolicy">
@ -256,6 +256,7 @@
</property> </property>
<addaction name="action_global_settings"/> <addaction name="action_global_settings"/>
<addaction name="action_show_network"/> <addaction name="action_show_network"/>
<addaction name="action_manage_templates"/>
<addaction name="action_backup"/> <addaction name="action_backup"/>
<addaction name="action_restore"/> <addaction name="action_restore"/>
<addaction name="action_exit"/> <addaction name="action_exit"/>
@ -821,6 +822,14 @@
<string>&amp;Exit Qube Manager</string> <string>&amp;Exit Qube Manager</string>
</property> </property>
</action> </action>
<action name="action_manage_templates">
<property name="text">
<string>Manage templates for qubes</string>
</property>
<property name="toolTip">
<string>Launch a tool that allows multiple templates to be changed at once</string>
</property>
</action>
</widget> </widget>
<resources> <resources>
<include location="../resources.qrc"/> <include location="../resources.qrc"/>

125
ui/templatemanager.ui Normal file
View File

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>574</width>
<height>718</height>
</rect>
</property>
<property name="windowTitle">
<string>Template Manager</string>
</property>
<widget class="QWidget" name="centralwidget">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<property name="sizeConstraint">
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<item>
<widget class="QTableWidget" name="vm_list">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::NoSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="gridStyle">
<enum>Qt::DotLine</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<property name="cornerButtonEnabled">
<bool>false</bool>
</property>
<attribute name="horizontalHeaderDefaultSectionSize">
<number>20</number>
</attribute>
<attribute name="horizontalHeaderMinimumSectionSize">
<number>4</number>
</attribute>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="label_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Change all selected to:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="change_all_combobox">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;To select all qubes with a given template, double-click the template name in any row.&lt;/p&gt;&lt;p&gt;&lt;span style=&quot; font-weight:600;&quot;&gt;IMPORTANT&lt;/span&gt;: Changes will be applied only when you click OK.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="clear_selection_button">
<property name="text">
<string>Clear Selection</string>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok|QDialogButtonBox::Reset</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
<resources/>
<connections/>
</ui>