support for boot_status tag; full-line highlight bar
[sface.git] / sface / screens / mainscreen.py
1
2 import datetime
3 import os
4 from PyQt4.QtCore import *
5 from PyQt4.QtGui import *
6
7 #from sfa.util.rspecHelper import RSpec
8 from sfa.rspecs.rspec_parser import parse_rspec
9 from sface.config import config
10 from sface.sfirenew import SfiRenewer
11 from sface.sfiprocess import SfiProcess
12 from sface.screens.sfascreen import SfaScreen
13
14 already_in_nodes = []
15
16 node_status = { "in": "Already Selected",
17                 "out": "Not Selected",
18                 "add": "To be Added",
19                 "remove": "To be Removed"}
20
21 tag_status = { "in": "Already Set",
22                 "out": "Not Set",
23                 "add": "To be Added",
24                 "remove": "To be Removed"}
25
26 default_tags = "Default tags"
27 settable_tags = ['delegations', 'initscript']
28
29 NAME_COLUMN = 0
30 NODE_STATUS_COLUMN = 1
31 MEMBERSHIP_STATUS_COLUMN = 2
32
33 def itemType(index):
34     if index.parent().parent().isValid():
35         return "tag"
36     else:
37         return "node"
38
39
40 class NodeView(QTreeView):
41     def __init__(self, parent):
42         QTreeView.__init__(self, parent)
43
44         self.setAnimated(True)
45         self.setItemsExpandable(True)
46         self.setRootIsDecorated(True)
47         self.setAlternatingRowColors(True)
48 #        self.setSelectionMode(self.MultiSelection)
49         self.setAttribute(Qt.WA_MacShowFocusRect, 0)
50         self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
51         self.setToolTip("Double click on a row to change its status.  Right click on a host to add a tag.")
52
53     def mouseDoubleClickEvent(self, event):
54         index = self.currentIndex()
55         model = index.model()
56         status_index = model.index(index.row(), MEMBERSHIP_STATUS_COLUMN, index.parent())
57         status_data = status_index.data().toString()
58         node_index = model.index(index.row(), NAME_COLUMN, index.parent())
59         node_data = node_index.data().toString()
60
61         if itemType(node_index) == "tag":
62             data = node_index.data().toString()
63             tagname, value = data.split(": ")
64             if tagname not in settable_tags:
65                 # Pop up error msg
66                 QMessageBox.warning(self, "Not settable", "Insufficient permission to change '%s' tag" % tagname)
67                 return
68             if status_data == tag_status['in']:
69                 model.setData(status_index, QString(tag_status['remove']))
70             elif status_data == tag_status['add']:
71                 model.setData(status_index, QString(tag_status['out']))
72             elif status_data == tag_status['remove']:
73                 model.setData(status_index, QString(tag_status['in']))
74             else: model.setData(status_index, QString(node_status['out']))
75         else:
76             # This is a hostname
77             if status_data == node_status['in']:
78                 model.setData(status_index, QString(node_status['remove']))
79             elif status_data == node_status['out']:
80                 model.setData(status_index, QString(node_status['add']))
81             elif status_data in (node_status['add'], node_status['remove']):
82                 if node_data in already_in_nodes: model.setData(status_index, QString(node_status['in']))
83                 else: model.setData(status_index, QString(node_status['out']))
84
85         model.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), node_index, node_index)
86
87     def mousePressEvent(self, event):
88         QTreeView.mousePressEvent(self, event)
89         if event.button() == Qt.LeftButton:
90             return
91
92         # Right click
93         index = self.currentIndex()
94         model = index.model()
95         status_index = model.index(index.row(), 1, index.parent())
96         status_data = status_index.data().toString()
97         node_index = model.index(index.row(), 0, index.parent())
98         node_data = node_index.data().toString()
99
100         if itemType(node_index) == "node":
101             # This is a hostname
102             if status_data in (node_status['in'], node_status['add'], ""):
103                 # Pop up a dialog box for adding a new attribute
104                 tagname, ok = QInputDialog.getItem(self, "Add tag",
105                                                    "Tag name:", settable_tags)
106                 if ok:
107                     value, ok = QInputDialog.getText(self, "Add tag",
108                                                      "Value for tag '%s'" % tagname)
109                     if ok:
110                         # Add a new row to the model for the tag
111
112                         # For testing with the QStandardItemModel
113                         #nodeItem = model.itemFromIndex(index)
114                         #tagstring = QString("%s: %s" % (tagname, value))
115                         #tagItem = QStandardItem(tagstring)
116                         #status = QStandardItem(QString(tag_status['add']))
117                         #nodeItem.appendRow([tagItem, status])
118
119                         # We're using the QSortFilterProxyModel here
120                         src_index = model.mapToSource(index)
121                         src_model = src_index.model()
122                         nodeItem = src_model.itemFromIndex(src_index)
123                         tagstring = QString("%s: %s" % (tagname, value))
124                         tagItem = QStandardItem(tagstring)
125                         status = QStandardItem(QString(tag_status['add']))
126                         nodeItem.appendRow([tagItem, status])
127
128             elif status_data in (node_status['out'], node_status['remove']):
129                 QMessageBox.warning(self, "Not selected", "Can only add tags to selected nodes")
130                 return
131
132         model.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), node_index, node_index)
133
134     def currentChanged(self, current, previous):
135         model = current.model()
136         node_index = model.index(current.row(), 0, current.parent())
137         node_data = node_index.data().toString()
138         self.emit(SIGNAL('hostnameClicked(QString)'), node_data)
139         
140                 
141
142 class NodeNameDelegate(QStyledItemDelegate):
143     def __init__(self, parent):
144         QStyledItemDelegate.__init__(self, parent)
145
146     def paint(self, painter, option, index):
147         model = index.model()
148         status_index = model.index(index.row(), MEMBERSHIP_STATUS_COLUMN, index.parent())
149         status_data = status_index.data().toString()
150
151         fm = QFontMetrics(option.font)
152         rect = QRect(option.rect)
153
154         data = index.data().toString()
155         rect.setHeight(rect.height() - 2)
156         rect.setWidth(fm.width(QString(data)) + 6)
157         rect.setX(rect.x() + 5)
158         rect.setY(rect.y() - 1)
159
160         x, y, h, w = rect.x(), rect.y(), rect.height(), rect.width()
161
162         path = QPainterPath()
163         path.addRoundedRect(x - 1, y + 1, w, h, 4, 4)
164
165         painter.save()
166         painter.setRenderHint(QPainter.Antialiasing)
167
168         if option.state & QStyle.State_Selected:
169             painter.fillRect(option.rect, option.palette.color(QPalette.Active, QPalette.Highlight))
170
171         if itemType(index) == "node":
172             if status_data == node_status['in']: # already in the slice
173                 painter.fillPath(path, QColor.fromRgb(0, 250, 250))
174                 painter.setPen(QColor.fromRgb(0, 0, 0))
175                 painter.drawText(rect, 0, QString(data))
176
177             elif status_data == node_status['add']: # newly added to the slice
178                 painter.fillPath(path, QColor.fromRgb(0, 250, 0))
179                 painter.setPen(QColor.fromRgb(0, 0, 0))
180                 painter.drawText(rect, 0, QString(data))
181
182             elif status_data == node_status['remove']: # removed from the slice
183                 painter.fillPath(path, QColor.fromRgb(250, 0, 0))
184                 painter.setPen(QColor.fromRgb(0, 0, 0))
185                 painter.drawText(rect, 0, QString(data))
186
187             else:
188                 painter.setPen(QColor.fromRgb(0, 0, 0))
189                 painter.drawText(rect, 0, QString(data))
190
191         else:
192             if status_data == tag_status['in']: # already in the slice
193                 painter.fillPath(path, QColor.fromRgb(0, 250, 250))
194                 painter.setPen(QColor.fromRgb(0, 0, 0))
195                 painter.drawText(rect, 0, QString(data))
196
197             elif status_data == tag_status['add']: # newly added to the slice
198                 painter.fillPath(path, QColor.fromRgb(0, 250, 0))
199                 painter.setPen(QColor.fromRgb(0, 0, 0))
200                 painter.drawText(rect, 0, QString(data))
201
202             elif status_data == tag_status['remove']: # removed from the slice
203                 painter.fillPath(path, QColor.fromRgb(250, 0, 0))
204                 painter.setPen(QColor.fromRgb(0, 0, 0))
205                 painter.drawText(rect, 0, QString(data))
206
207             else:
208                 painter.setPen(QColor.fromRgb(0, 0, 0))
209                 painter.drawText(rect, 0, QString(data))
210
211         painter.restore()
212
213 class NodeStatusDelegate(QStyledItemDelegate):
214     def __init__(self, parent):
215         QStyledItemDelegate.__init__(self, parent)
216
217     def paint(self, painter, option, index):
218         model = index.model()
219         nodestatus_index = model.index(index.row(), NODE_STATUS_COLUMN, index.parent())
220         nodestatus_data = nodestatus_index.data().toString()
221
222         fm = QFontMetrics(option.font)
223         rect = QRect(option.rect)
224
225         data = index.data().toString()
226         rect.setHeight(rect.height() - 2)
227         rect.setWidth(fm.width(QString(data)) + 6)
228         rect.setX(rect.x() + 5)
229         rect.setY(rect.y() - 1)
230
231         x, y, h, w = rect.x(), rect.y(), rect.height(), rect.width()
232
233         path = QPainterPath()
234         path.addRoundedRect(x - 1, y + 1, w, h, 4, 4)
235
236         painter.save()
237         painter.setRenderHint(QPainter.Antialiasing)
238
239         if option.state & QStyle.State_Selected:
240             painter.fillRect(option.rect, option.palette.color(QPalette.Active, QPalette.Highlight))
241
242         if (nodestatus_data == ""):
243                 painter.setPen(QColor.fromRgb(0, 0, 0))
244                 painter.drawText(rect, 0, QString(data))
245         elif (nodestatus_data == "boot"):
246                 painter.fillPath(path, QColor.fromRgb(0, 250, 0))
247                 painter.setPen(QColor.fromRgb(0, 0, 0))
248                 painter.drawText(rect, 0, QString(data))
249         else:
250                 painter.fillPath(path, QColor.fromRgb(250, 0, 0))
251                 painter.setPen(QColor.fromRgb(0, 0, 0))
252                 painter.drawText(rect, 0, QString(data))
253
254         painter.restore()
255
256 class SliceWidget(QWidget):
257     def __init__(self, parent):
258         QWidget.__init__(self, parent)
259
260         self.network_names = []
261         self.process = SfiProcess(self)
262
263         self.slicename = QLabel("", self)
264         self.updateSliceName()
265         self.slicename.setScaledContents(False)
266         searchlabel = QLabel ("Search: ", self)
267         searchlabel.setScaledContents(False)
268         searchbox = QLineEdit(self)
269         searchbox.setAttribute(Qt.WA_MacShowFocusRect, 0)
270
271         toplayout = QHBoxLayout()
272         toplayout.addWidget(self.slicename, 0, Qt.AlignLeft)
273         toplayout.addStretch()
274         toplayout.addWidget(searchlabel, 0, Qt.AlignRight)
275         toplayout.addWidget(searchbox, 0, Qt.AlignRight)
276
277         self.nodeView = NodeView(self)
278         self.nodeModel = QStandardItemModel(0, 2, self)
279         self.filterModel = QSortFilterProxyModel(self) # enable filtering
280
281         self.nodeNameDelegate = NodeNameDelegate(self)
282         self.nodeStatusDelegate = NodeStatusDelegate(self)
283
284         refresh = QPushButton("Refresh Slice Data", self)
285         refresh.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
286         renew = QPushButton("Renew Slice", self)
287         renew.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
288         submit = QPushButton("Submit", self)
289         submit.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
290
291         bottomlayout = QHBoxLayout()
292         bottomlayout.addWidget(refresh, 0, Qt.AlignLeft)
293         bottomlayout.addWidget(renew, 0, Qt.AlignLeft)
294         bottomlayout.addStretch()
295         bottomlayout.addWidget(submit, 0, Qt.AlignRight)
296
297         layout = QVBoxLayout()
298         layout.addLayout(toplayout)
299         layout.addWidget(self.nodeView)
300         layout.addLayout(bottomlayout)
301         self.setLayout(layout)
302         self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
303
304         self.connect(refresh, SIGNAL('clicked()'), self.refresh)
305         self.connect(renew, SIGNAL('clicked()'), self.renew)
306         self.connect(submit, SIGNAL('clicked()'), self.submit)
307         self.connect(searchbox, SIGNAL('textChanged(QString)'), self.filter)
308         self.connect(self.nodeView, SIGNAL('hostnameClicked(QString)'),
309                      self.nodeSelectionChanged)
310
311         self.updateView()
312
313     def submitFinished(self):
314         self.setStatus("<font color='green'>Slice data submitted.</font>")
315         QTimer.singleShot(1000, self.refresh)
316
317     def refreshFinished(self):
318         self.setStatus("<font color='green'>Slice data refreshed.</font>", timeout=5000)
319         self.updateView()
320         self.parent().signalAll("rspecUpdated")
321
322     def readSliceRSpec(self):
323         rspec_file = config.getSliceRSpecFile()
324         if os.path.exists(rspec_file):
325             xml = open(rspec_file).read()
326             return parse_rspec(xml)
327         return None
328
329     def setStatus(self, msg, timeout=None):
330         self.parent().setStatus(msg, timeout)
331
332     def checkRunningProcess(self):
333         if self.process.isRunning():
334             self.setStatus("<font color='red'>There is already a process running. Please wait.</font>")
335             return True
336         return False
337
338     def filter(self, filter_string):
339         # for hierarchical models QSortFilterProxyModel applies the
340         # sort recursively. if the parent doesn't match the criteria
341         # we won't be able to match the children. so we need to match
342         # parent (by matching the network_names)
343         networks = ["^%s$" % n for n in self.network_names]
344         filters = networks + [str(filter_string)]
345         self.filterModel.setFilterRegExp(QRegExp('|'.join(filters)))
346
347     def itemStatus(self, item):
348         statusItem = item.parent().child(item.row(), 1)
349         return statusItem.data(Qt.DisplayRole).toString()
350
351     def itemText(self, item):
352         return item.data(Qt.DisplayRole).toString()
353
354     # Recursively walk the tree, making changes to the RSpec
355     def process_subtree(self, rspec, item, depth = 0):
356         change = False
357         model = self.nodeModel
358
359         if depth in [0, 1]:
360             pass
361         elif depth == 2: # Hostname
362             hostname = self.itemText(item)
363             testbed = self.itemText(item.parent())
364             status = self.itemStatus(item)
365             if status == node_status['add']:
366                 print "Add hostname: %s" % hostname
367                 rspec.add_slivers(str(hostname), testbed)
368                 change = True
369             elif status == node_status['remove']:
370                 print "Remove hostname: %s" % hostname
371                 rspec.remove_slivers(str(hostname), testbed)
372                 change = True
373         elif depth == 3: # Tag
374             tag, value = self.itemText(item).split(": ")
375             status = self.itemStatus(item)
376             tag = "%s" % tag     # Prevent weird error from lxml
377             value = "%s" % value # Prevent weird error from lxml
378             node = self.itemText(item.parent())
379             testbed = self.itemText(item.parent().parent())
380             if status == tag_status['add']:
381                 print "Add tag to (%s, %s): %s/%s " % (testbed, node, tag, value)
382                 if node.startsWith(default_tags):
383                     rspec.add_default_sliver_attribute(tag, value, testbed)
384                 else:
385                     rspec.add_sliver_attribute(node, tag, value, testbed)
386                 change = True
387             elif status == tag_status['remove']:
388                 print "Remove tag from (%s, %s): %s/%s " % (testbed, node, tag, value)
389                 if node.startsWith(default_tags):
390                     rspec.remove_default_sliver_attribute(tag, value, testbed)
391                 else:
392                     rspec.remove_sliver_attribute(node, tag, value, testbed)
393                 change = True
394
395         children = item.rowCount()
396         for row in range(0, children):
397             status = self.process_subtree(rspec, item.child(row), depth + 1)
398             change = change or status
399
400         return change
401
402     def submit(self):
403         if self.checkRunningProcess():
404             return
405
406         rspec = self.readSliceRSpec()
407         change = self.process_subtree(rspec, self.nodeModel.invisibleRootItem())
408
409         if not change:
410             self.setStatus("<font color=red>No change in slice data. Not submitting!</font>", timeout=3000)
411             return
412
413         self.disconnect(self.process, SIGNAL('finished()'), self.refreshFinished)
414         self.connect(self.process, SIGNAL('finished()'), self.submitFinished)
415
416         self.process.applyRSpec(rspec)
417         self.setStatus("Sending slice data (RSpec). This will take some time...")
418
419     def renew(self):
420         dlg = RenewWindow(parent=self)
421         if (dlg.exec_() == QDialog.Accepted):
422             self.setStatus("Renewing Slice.")
423
424             self.renewProcess = SfiRenewer(config.getSlice(), dlg.get_new_expiration(), self)
425             self.connect(self.renewProcess, SIGNAL('finished()'), self.renewFinished)
426
427     def renewFinished(self):
428         if self.renewProcess.statusMsg:
429             self.setStatus("Renew " + self.renewProcess.status + ": " + self.renewProcess.statusMsg)
430         else:
431             self.setStatus("Renew " + self.renewProcess.status)
432         self.disconnect(self.renewProcess, SIGNAL('finished()'), self.renewFinished)
433         self.renewProcess = None
434
435     def refresh(self):
436         if not config.getSlice():
437             self.setStatus("<font color='red'>Slice not set yet!</font>")
438             return
439
440         if self.process.isRunning():
441             self.setStatus("<font color='red'>There is already a process running. Please wait.</font>")
442             return
443
444         self.disconnect(self.process, SIGNAL('finished()'), self.submitFinished)
445         self.connect(self.process, SIGNAL('finished()'), self.refreshFinished)
446
447         self.process.getRSpecFromSM()
448         self.setStatus("Refreshing slice data. This will take some time...")
449
450     def updateView(self):
451         global already_in_nodes
452         already_in_nodes = []
453         self.network_names = []
454         self.nodeModel.clear()
455
456         rspec = self.readSliceRSpec()
457         if not rspec:
458             return None
459
460         rootItem = self.nodeModel.invisibleRootItem()
461         #networks = sorted(rspec.get_network_list())
462         networks = rspec.get_networks()
463         for network in networks:
464             self.network_names.append(network)
465
466             #all_nodes = rspec.get_node_list(network)
467             #sliver_nodes = rspec.get_sliver_list(network)
468             all_nodes = rspec.get_nodes(network)
469             sliver_nodes = rspec.get_nodes_with_slivers(network)
470             available_nodes = [ node for node in all_nodes if node not in sliver_nodes ]
471
472             networkItem = QStandardItem(QString(network))
473             msg = "%s Nodes\t%s Selected" % (len(all_nodes), len(sliver_nodes))
474             rootItem.appendRow([networkItem, QStandardItem(QString("")), QStandardItem(QString(msg))])
475
476             already_in_nodes += sliver_nodes
477
478             # Add default slice tags
479             nodeItem = QStandardItem(QString("%s for %s" % (default_tags, network)))
480             statusItem = QStandardItem(QString(""))
481             networkItem.appendRow([nodeItem, statusItem])
482             attrs = rspec.get_default_sliver_attributes(network)
483             for (name, value) in attrs:
484                     tagstring = QString("%s: %s" % (name, value))
485                     tagItem = QStandardItem(tagstring)
486                     status = QStandardItem(QString(tag_status['in']))
487                     nodeStatus = QStandardItem(QString(""))
488                     nodeItem.appendRow([tagItem, nodeStatus, status])
489
490             for node in sliver_nodes:
491                 nodeItem = QStandardItem(QString(node))
492                 statusItem = QStandardItem(QString(node_status['in']))
493                 nodeStatus = QStandardItem(QString(rspec.get_node_element(node, network).attrib.get("boot_state","")))
494                 networkItem.appendRow([nodeItem, nodeStatus, statusItem])
495
496                 attrs = rspec.get_sliver_attributes(node, network)
497                 for (name, value) in attrs:
498                     tagstring = QString("%s: %s" % (name, value))
499                     tagItem = QStandardItem(tagstring)
500                     statusItem = QStandardItem(QString(tag_status['in']))
501                     nodeStatus = QStandardItem(QString(""))
502                     nodeItem.appendRow([tagItem, nodeStatus, statusItem])
503
504             for node in available_nodes:
505                 nodeItem = QStandardItem(QString(node))
506                 statusItem = QStandardItem(QString(node_status['out']))
507                 nodeStatus = QStandardItem(QString(rspec.get_node_element(node, network).attrib.get("boot_state","")))
508                 networkItem.appendRow([nodeItem, nodeStatus, statusItem])
509
510         self.filterModel.setSourceModel(self.nodeModel)
511         self.filterModel.setFilterKeyColumn(-1)
512         self.filterModel.setDynamicSortFilter(True)
513
514         headers = QStringList() << "Hostname or Tag" << "Node Status" << "Membership Status"
515         self.nodeModel.setHorizontalHeaderLabels(headers)
516
517         self.nodeView.setItemDelegateForColumn(0, self.nodeNameDelegate)
518         self.nodeView.setItemDelegateForColumn(1, self.nodeStatusDelegate)
519         self.nodeView.setModel(self.filterModel)
520         self.nodeView.expandAll()
521         self.nodeView.resizeColumnToContents(0)
522         self.nodeView.collapseAll()
523
524     def updateSliceName(self):
525         self.slicename.setText("Slice : %s" % (config.getSlice() or "None"))
526
527     def nodeSelectionChanged(self, hostname):
528         self.parent().nodeSelectionChanged(hostname)
529
530 class RenewWindow(QDialog):
531     def __init__(self, parent=None):
532         super(RenewWindow, self).__init__(parent)
533         self.setWindowTitle("Renew Slivers")
534
535         self.duration = QComboBox()
536
537         self.expirations = []
538
539         durations = ( (1, "One Week"), (2, "Two Weeks"), (3, "Three Weeks"), (4, "One Month") )
540
541         now = datetime.datetime.utcnow()
542         for (weeks, desc) in durations:
543             exp = now + datetime.timedelta(days = weeks * 7)
544             desc = desc + " " + exp.strftime("%Y-%m-%d %H:%M:%S")
545             self.expirations.append(exp)
546             self.duration.addItem(desc)
547
548         self.duration.setCurrentIndex(0)
549
550         buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
551         buttonBox.button(QDialogButtonBox.Ok).setDefault(True)
552
553         layout = QVBoxLayout()
554         layout.addWidget(self.duration)
555         layout.addWidget(buttonBox)
556         self.setLayout(layout)
557
558         self.connect(buttonBox, SIGNAL("accepted()"), self, SLOT("accept()"))
559         self.connect(buttonBox, SIGNAL("rejected()"), self, SLOT("reject()"))
560
561     def accept(self):
562         QDialog.accept(self)
563
564     def get_new_expiration(self):
565         index = self.duration.currentIndex()
566         return self.expirations[index]
567
568 class MainScreen(SfaScreen):
569     def __init__(self, parent):
570         SfaScreen.__init__(self, parent)
571
572         slice = SliceWidget(self)
573         self.init(slice, "Nodes", "OneLab SFA crawler")
574
575     def rspecUpdated(self):
576         self.mainwin.rspecWindow.updateView()
577
578     def configurationChanged(self):
579         self.widget.updateSliceName()
580         self.widget.updateView()
581         self.mainwin.rspecWindow.updateView()
582
583     def nodeSelectionChanged(self, hostname):
584         self.mainwin.nodeSelectionChanged(hostname)