template_manager.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. #!/usr/bin/python3
  2. #
  3. # The Qubes OS Project, http://www.qubes-os.org
  4. #
  5. # Copyright (C) 2018 Marta Marczykowska-Górecka
  6. # <marmarta@invisiblethingslab.com>
  7. #
  8. # This program is free software; you can redistribute it and/or
  9. # modify it under the terms of the GNU General Public License
  10. # as published by the Free Software Foundation; either version 2
  11. # of the License, or (at your option) any later version.
  12. #
  13. # This program is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU Lesser General Public License along
  19. # with this program; if not, see <http://www.gnu.org/licenses/>.
  20. #
  21. #
  22. from qubesadmin import exc
  23. from PyQt5 import QtWidgets, QtGui, QtCore # pylint: disable=import-error
  24. from . import ui_templatemanager # pylint: disable=no-name-in-module
  25. from . import utils
  26. column_names = ['State', 'Qube', 'Current template', 'New template']
  27. class TemplateManagerWindow(
  28. ui_templatemanager.Ui_MainWindow, QtWidgets.QMainWindow):
  29. def __init__(self, qt_app, qubes_app, dispatcher, parent=None):
  30. # pylint: disable=unused-argument
  31. super(TemplateManagerWindow, self).__init__()
  32. self.setupUi(self)
  33. self.qubes_app = qubes_app
  34. self.qt_app = qt_app
  35. self.dispatcher = dispatcher
  36. self.rows_in_table = {}
  37. self.templates = []
  38. self.timers = []
  39. self.prepare_lists()
  40. self.initialize_table_events()
  41. self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).clicked.connect(
  42. self.apply)
  43. self.buttonBox.button(
  44. QtWidgets.QDialogButtonBox.Cancel).clicked.connect(self.cancel)
  45. self.buttonBox.button(QtWidgets.QDialogButtonBox.Reset).clicked.connect(
  46. self.reset)
  47. self.change_all_combobox.currentIndexChanged.connect(
  48. self.change_all_changed)
  49. self.clear_selection_button.clicked.connect(self.clear_selection)
  50. self.vm_list.show()
  51. def setup_application(self):
  52. self.qt_app.setApplicationName(self.tr("Template Manager"))
  53. self.qt_app.setWindowIcon(QtGui.QIcon.fromTheme("qubes-manager"))
  54. def prepare_lists(self):
  55. self.templates = [vm.name for vm in self.qubes_app.domains
  56. if vm.klass == 'TemplateVM']
  57. self.change_all_combobox.addItem(self.tr('(select template)'))
  58. for template in self.templates:
  59. self.change_all_combobox.addItem(template)
  60. vms_with_templates = [vm for vm in self.qubes_app.domains
  61. if getattr(vm, 'template', None) and
  62. vm.klass != 'DispVM']
  63. self.vm_list.setColumnCount(len(column_names))
  64. self.vm_list.setRowCount(len(vms_with_templates))
  65. row_count = 0
  66. for vm in vms_with_templates:
  67. row = VMRow(vm, row_count, self.vm_list, column_names,
  68. self.templates)
  69. self.rows_in_table[vm.name] = row
  70. row_count += 1
  71. self.vm_list.setHorizontalHeaderLabels(
  72. ['', self.tr('Qube'), self.tr('Current'), self.tr('New')])
  73. self.vm_list.resizeColumnsToContents()
  74. def initialize_table_events(self):
  75. self.vm_list.cellDoubleClicked.connect(self.table_double_click)
  76. self.vm_list.cellClicked.connect(self.table_click)
  77. self.vm_list.horizontalHeader().sortIndicatorChanged.connect(
  78. self.sorting_changed)
  79. self.dispatcher.add_handler('domain-pre-start', self.vm_state_changed)
  80. self.dispatcher.add_handler('domain-start-failed',
  81. self.vm_state_changed)
  82. self.dispatcher.add_handler('domain-stopped', self.vm_state_changed)
  83. self.dispatcher.add_handler('domain-shutdown', self.vm_state_changed)
  84. self.dispatcher.add_handler('domain-add', self.vm_added)
  85. self.dispatcher.add_handler('domain-delete', self.vm_removed)
  86. def vm_added(self, _submitter, _event, vm, **_kwargs):
  87. # unfortunately, a VM just in the moment of creation may not have
  88. # a template it will have in a second - e.g., when cloning
  89. timer = QtCore.QTimer()
  90. timer.setSingleShot(True)
  91. timer.timeout.connect(lambda: self._vm_added(vm, timer))
  92. self.timers.append(timer)
  93. timer.start(1000) # 1s
  94. def _vm_added(self, vm_name, timer):
  95. self.timers.remove(timer)
  96. try:
  97. vm = self.qubes_app.domains[vm_name]
  98. if not getattr(vm, 'template', None) or vm.klass == 'DispVM':
  99. return
  100. except (exc.QubesException, KeyError):
  101. return # it was a dispVM that crashed on start
  102. row_no = self.vm_list.rowCount()
  103. self.vm_list.setRowCount(self.vm_list.rowCount() + 1)
  104. row = VMRow(vm, row_no, self.vm_list, column_names,
  105. self.templates)
  106. self.rows_in_table[vm.name] = row
  107. self.vm_list.show()
  108. def vm_removed(self, _submitter, _event, **kwargs):
  109. if kwargs['vm'] not in self.rows_in_table:
  110. return
  111. self.vm_list.removeRow(self.rows_in_table[kwargs['vm']].name_item.row())
  112. def vm_state_changed(self, vm, event, **_kwargs):
  113. try:
  114. if vm.name not in self.rows_in_table:
  115. return
  116. except exc.QubesException:
  117. return # it was a crashing DispVM or closed DispVM
  118. if event == 'domain-pre-start':
  119. self.rows_in_table[vm.name].vm_state_change(is_running=True)
  120. elif event == 'domain-start-failed':
  121. self.rows_in_table[vm.name].vm_state_change(is_running=False)
  122. elif event == 'domain-stopped':
  123. self.rows_in_table[vm.name].vm_state_change(is_running=False)
  124. elif event == 'domain-shutdown':
  125. self.rows_in_table[vm.name].vm_state_change(is_running=False)
  126. def sorting_changed(self, index, _order):
  127. # this is very much not perfect, but QTableWidget does not
  128. # want to be sorted on custom widgets
  129. if index == column_names.index('New template') or \
  130. index == column_names.index('State'):
  131. self.vm_list.horizontalHeader().setSortIndicator(
  132. -1, QtCore.Qt.AscendingOrder)
  133. def clear_selection(self):
  134. for row in self.rows_in_table.values():
  135. if row.checkbox:
  136. row.checkbox.setChecked(False)
  137. def change_all_changed(self):
  138. if self.change_all_combobox.currentIndex() == 0:
  139. return
  140. selected_template = self.change_all_combobox.currentText()
  141. for row in self.rows_in_table.values():
  142. if row.checkbox and row.checkbox.isChecked():
  143. row.new_item.setCurrentIndex(
  144. row.new_item.findText(selected_template))
  145. self.change_all_combobox.setCurrentIndex(0)
  146. def table_double_click(self, row, column):
  147. template_column = column_names.index('Current template')
  148. if column != template_column:
  149. return
  150. template_name = self.vm_list.item(row, column).text()
  151. for row_number in range(0, self.vm_list.rowCount()):
  152. if self.vm_list.item(
  153. row_number, template_column).text() == template_name:
  154. checkbox = self.vm_list.cellWidget(
  155. row_number, column_names.index('State'))
  156. if checkbox:
  157. if row_number == row:
  158. # this is because double click registers as a
  159. # single click and a double click
  160. checkbox.setChecked(False)
  161. else:
  162. checkbox.setChecked(True)
  163. def table_click(self, row, column):
  164. if column == column_names.index('New template'):
  165. return
  166. checkbox = self.vm_list.cellWidget(row, column_names.index('State'))
  167. if not checkbox:
  168. return
  169. checkbox.setChecked(not checkbox.isChecked())
  170. def reset(self):
  171. for row in self.rows_in_table.values():
  172. if row.new_item:
  173. row.new_item.reset_choice()
  174. if row.checkbox:
  175. row.checkbox.setChecked(False)
  176. def cancel(self):
  177. self.close()
  178. def apply(self):
  179. errors = {}
  180. for vm, row in self.rows_in_table.items():
  181. if row.new_item and row.new_item.changed:
  182. try:
  183. setattr(self.qubes_app.domains[vm],
  184. 'template', row.new_item.currentText())
  185. except Exception as ex: # pylint: disable=broad-except
  186. errors[vm] = str(ex)
  187. if errors:
  188. error_messages = [vm + ": " + errors[vm] for vm in errors]
  189. QtWidgets.QMessageBox.warning(
  190. self,
  191. self.tr("Errors encountered!"),
  192. self.tr(
  193. "Errors encountered on template change in the following "
  194. "qubes: <br> {}.").format("<br> ".join(error_messages)))
  195. self.close()
  196. class VMNameItem(QtWidgets.QTableWidgetItem):
  197. # pylint: disable=too-few-public-methods
  198. def __init__(self, vm):
  199. super(VMNameItem, self).__init__()
  200. self.vm = vm
  201. self.setText(self.vm.name)
  202. self.setIcon(QtGui.QIcon.fromTheme(vm.label.icon))
  203. class StatusItem(QtWidgets.QTableWidgetItem):
  204. def __init__(self, vm):
  205. super(StatusItem, self).__init__()
  206. self.vm = vm
  207. self.state = None
  208. def set_state(self, is_running):
  209. self.state = is_running
  210. if self.state:
  211. self.setIcon(QtGui.QIcon.fromTheme('dialog-warning'))
  212. self.setToolTip(QtCore.QCoreApplication.translate(
  213. "template-manager", "Cannot change template on a running VM."))
  214. else:
  215. self.setIcon(QtGui.QIcon())
  216. self.setToolTip("")
  217. def __lt__(self, other):
  218. if self.state == other.state:
  219. return self.vm.name < other.vm.name
  220. return self.state < other.state
  221. class CurrentTemplateItem(QtWidgets.QTableWidgetItem):
  222. # pylint: disable=too-few-public-methods
  223. def __init__(self, vm):
  224. super(CurrentTemplateItem, self).__init__()
  225. self.vm = vm
  226. self.setText(self.vm.template.name)
  227. def __lt__(self, other):
  228. if self.text() == other.text():
  229. return self.vm.name < other.vm.name
  230. return self.text() < other.text()
  231. class NewTemplateItem(QtWidgets.QComboBox):
  232. def __init__(self, vm, templates, table_widget):
  233. super(NewTemplateItem, self).__init__()
  234. self.vm = vm
  235. self.table_widget = table_widget
  236. self.changed = False
  237. for template in templates:
  238. self.addItem(template)
  239. self.setCurrentIndex(self.findText(vm.template.name))
  240. self.start_value = self.currentText()
  241. self.currentIndexChanged.connect(self.choice_changed)
  242. def choice_changed(self):
  243. if self.currentText() != self.start_value:
  244. self.changed = True
  245. self.setStyleSheet('font-weight: bold')
  246. else:
  247. self.changed = False
  248. self.setStyleSheet('font-weight: normal')
  249. def reset_choice(self):
  250. self.setCurrentIndex(self.findText(self.start_value))
  251. class VMRow:
  252. # pylint: disable=too-few-public-methods
  253. def __init__(self, vm, row_no, table_widget, columns, templates):
  254. self.vm = vm
  255. self.table_widget = table_widget
  256. self.templates = templates
  257. # state
  258. self.state_item = StatusItem(self.vm)
  259. table_widget.setItem(row_no, columns.index('State'), self.state_item)
  260. self.checkbox = QtWidgets.QCheckBox()
  261. # icon and name
  262. self.name_item = VMNameItem(self.vm)
  263. table_widget.setItem(row_no, columns.index('Qube'), self.name_item)
  264. # current template
  265. self.current_item = CurrentTemplateItem(self.vm)
  266. table_widget.setItem(row_no, columns.index('Current template'),
  267. self.current_item)
  268. # new template
  269. self.dummy_new_item = QtWidgets.QTableWidgetItem(
  270. QtCore.QCoreApplication.translate("TemplateManager",
  271. "qube is running"))
  272. self.new_item = NewTemplateItem(self.vm, templates, table_widget)
  273. table_widget.setItem(row_no, columns.index('New template'),
  274. self.dummy_new_item)
  275. self.vm_state_change(self.vm.is_running(), row_no)
  276. def vm_state_change(self, is_running, row=None):
  277. self.state_item.set_state(is_running)
  278. if not row:
  279. row = 0
  280. while row < self.table_widget.rowCount():
  281. if self.table_widget.item(
  282. row, column_names.index('Qube')).text() == \
  283. self.name_item.text():
  284. break
  285. row += 1
  286. # hiding cellWidgets does not work in a qTableWidget
  287. if not is_running:
  288. self.new_item = NewTemplateItem(self.vm, self.templates,
  289. self.table_widget)
  290. self.checkbox = QtWidgets.QCheckBox()
  291. self.table_widget.setCellWidget(
  292. row, column_names.index('New template'), self.new_item)
  293. self.table_widget.setCellWidget(
  294. row, column_names.index('State'), self.checkbox)
  295. else:
  296. new_template = self.table_widget.cellWidget(
  297. row, column_names.index('New template'))
  298. if new_template:
  299. self.table_widget.removeCellWidget(
  300. row, column_names.index('New template'))
  301. self.new_item = None
  302. checkbox = self.table_widget.cellWidget(
  303. row, column_names.index('State'))
  304. if checkbox:
  305. self.table_widget.removeCellWidget(
  306. row, column_names.index('State'))
  307. self.checkbox = None
  308. def main():
  309. utils.run_asynchronous(TemplateManagerWindow)
  310. if __name__ == "__main__":
  311. main()