Source code for schrodinger.application.job_monitor.job_monitor_table

from schrodinger.application.job_monitor import job_monitor_models
from schrodinger.application.job_monitor import job_monitor_stylesheets as style
from schrodinger.application.job_monitor import util
from schrodinger.application.job_monitor.job_monitor_models import \
    RequestedStatus
from schrodinger.application.job_monitor.job_monitor_progress_bar import \
    paint_progress
from schrodinger.application.job_monitor.job_monitor_progress_bar import \
    should_paint_progress
from schrodinger.job.jobcontrol import DisplayStatus
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt import delegates
from schrodinger.ui.qt.mapperwidgets import plptable

ICON_PATH = ":/job_monitor/icons/"
ARROW_COLUMN_TOOLTIP = "Click arrow or double-click row to view job details"
DOWNLOADING_TT = "Download in progress"
READY_TO_DOWNLOAD_TT = "Automatic download not started"


[docs]class JobTableSpec(plptable.TableSpec): """ Columns go: Indicator Icon | # | Job Name | Progress | Last Updated | "Next" Icon """ indicator = plptable.FieldColumn(job_monitor_models.JobModel, title="", sample_data="O") relative_id = plptable.FieldColumn(job_monitor_models.JobModel.relative_id, title="#") jobname = plptable.ParamColumn(title='Job Name', sample_data="HELLOOOOOOOOOOO I'M A JOB") progress = plptable.ParamColumn(title='Progress', sample_data="IM A LONG PROGRESS BAR") updated = plptable.ParamColumn(title="Last Updated", sample_data='9:34am') arrow = plptable.ParamColumn(title="", sample_data=">>") ## TOOLTIP ROLE ##
[docs] @arrow.data_method(Qt.ToolTipRole) def arrow_tooltip_display(self, job_model): return ARROW_COLUMN_TOOLTIP
[docs] @updated.data_method(Qt.ToolTipRole) def updated_tooltip_display(self, job_model): return get_updated_time_tooltip(job_model.last_updated)
## DISPLAY/DECORATION ROLES ##
[docs] @indicator.data_method(Qt.DecorationRole) def indicator_icon(self, job_model): return QtGui.QIcon( get_icon_path_for_status(job_model.status, job_model.requested_status))
[docs] @jobname.data_method(Qt.DisplayRole) def jobname_display(self, job_model): return job_model.job_name
[docs] @jobname.data_method(Qt.ToolTipRole) def jobname_tooltip_display(self, job_model): return f'Job name: {job_model.job_name}'
[docs] @progress.data_method(Qt.DisplayRole) def current_progress_display(self, job_model): if not should_paint_progress(job_model): if job_model.requested_status == RequestedStatus.STOP: return "Stop requested..." elif job_model.requested_status == RequestedStatus.CANCEL: return "Cancel requested..." elif job_model.status: return job_model.status.value else: return "Unknown" return None
[docs] @progress.data_method(Qt.ToolTipRole) def download_icon_tooltip(self, job_model): if (job_model.download_status == job_monitor_models.DownloadStatus.DOWNLOADING): return DOWNLOADING_TT elif (job_model.download_status == job_monitor_models.DownloadStatus.READY_TO_DOWNLOAD): return READY_TO_DOWNLOAD_TT
[docs] @updated.data_method(Qt.DisplayRole) def updated_display(self, job_model): return get_updated_time_display(job_model.last_updated)
## FONT ROLE ##
[docs] @jobname.data_method(Qt.FontRole) @progress.data_method(Qt.FontRole) def font_role(self, job_model): """ All columns are italic when a job is no longer active """ font = QtGui.QFont() if not job_model.is_active: font.setItalic(True) return font
[docs] @updated.data_method(Qt.FontRole) def updated_font(self, job_model): """ Updated column is always italic """ font = QtGui.QFont() font.setItalic(True) return font
## TEXT ALIGNMENT ROLE ##
[docs] @jobname.data_method(Qt.TextAlignmentRole) def jobname_text_alignment(self, job_model): return Qt.AlignLeft | Qt.AlignVCenter
[docs] @updated.data_method(Qt.TextAlignmentRole) @progress.data_method(Qt.TextAlignmentRole) def text_alignment_role(self, job_model): return Qt.AlignCenter
## FOREGROUND ROLE ##
[docs] @jobname.data_method(Qt.ForegroundRole) @progress.data_method(Qt.ForegroundRole) def foreground_role(self, job_model): """ All columns are italic when a job is no longer active (until the job files are downloaded) and in gray color while the active jobs are in black color. """ if not job_model.is_active and ( job_model.download_status == job_monitor_models.DownloadStatus.DOWNLOADED): return style.table_nonactive_color return style.table_active_color
[docs] @updated.data_method(Qt.ForegroundRole) def updated_foreground(self, job_model): if not job_model.is_active: return style.table_nonactive_color return style.table_last_updated_color
[docs]class JobTableWidget(plptable.PLPTableWidget): """" :ivar deleteJobRecordRequested: Signal emitted when 'Delete Job Record' right-click menu-item is clicked. Emitted with a list of job ids. :vartype deleteJobRecordRequested: QtCore.pyqtSignal """ stopRequested = QtCore.pyqtSignal(list) cancelRequested = QtCore.pyqtSignal(list) collectDiagnosticsRequested = QtCore.pyqtSignal(list) deleteJobRecordRequested = QtCore.pyqtSignal(list)
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setSpec(JobTableSpec()) self._setupTableView() self.addProxy(QtCore.QSortFilterProxyModel()) self.addProxy(TableProxy()) self.view.setStyleSheet("") self.view.selectionModel().selectionChanged.connect(self.saveSelection) self.selected_job_models = None
def _setupTableView(self): self.view.verticalHeader().hide() ## DELEGATES ## self.view.setItemDelegate(JobTableDelegate(self.view)) self.job_details_btn_delegate = JobDetailsArrowDelegate(self.view) self.view.setItemDelegateForColumn(5, self.job_details_btn_delegate) self.view.setItemDelegateForColumn(3, JobProgressDelegate(self.view)) ## HEADER ## h_header = HorizontalHeader() self.view.setHorizontalHeader(h_header) h_header.setSectionResizeMode(0, QtWidgets.QHeaderView.Fixed) h_header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) h_header.setSectionResizeMode(2, QtWidgets.QHeaderView.Stretch) h_header.setSectionResizeMode(3, QtWidgets.QHeaderView.Fixed) h_header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) h_header.setSectionResizeMode(5, QtWidgets.QHeaderView.Fixed) ## CONTEXT MENU ## self.view.customContextMenuRequested.connect( self.onContextMenuRequested) self.view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) ## OTHER ## self.view.setSortingEnabled(True) self.view.sortByColumn(-1, Qt.DescendingOrder) # No sort at first self.view.setShowGrid(False) self.view.setAlternatingRowColors(True) self.view.setSelectionMode(self.view.ExtendedSelection)
[docs] def saveSelection(self): self.selected_job_models = self.selectedParams()
[docs] def restoreSelection(self): """ Restore previously saved selection of jobs in the table. """ if self.selected_job_models is not None: available_job_models = [ job_model for job_model in self.selected_job_models if job_model in self.plp ] self.setSelectedParams(available_job_models)
[docs] def initLayOut(self): super().initLayOut() self.widget_layout.setContentsMargins(0, 0, 0, 0) self.main_layout.setContentsMargins(0, 0, 0, 0)
[docs] def onContextMenuRequested(self, pos): if not self.view.indexAt(pos).isValid(): return menu = self._createContextMenu() if menu: menu.exec(self.view.mapToGlobal(pos))
def _createContextMenu(self): """ Create a context menu for the selected job models :return: A populated context menu, or None if there was no actions to populate the QMenu with :rtype: QtWidgets.QMenu or NoneType """ sel_job_models = self.selectedParams() if not sel_job_models: return diagnosticable_job_ids = [ job_model.job_id for job_model in sel_job_models if job_model.is_top_level_job ] stoppable_job_ids = [ job_model.job_id for job_model in sel_job_models if job_model.status == DisplayStatus.RUNNING and job_model.requested_status == RequestedStatus.NONE ] cancelable_job_ids = [ job_model.job_id for job_model in sel_job_models if job_model.is_active and job_model.requested_status == RequestedStatus.NONE ] removable_job_ids = [ job_model.job_id for job_model in sel_job_models if job_model.status in ( DisplayStatus.COMPLETED, DisplayStatus.FAILED, DisplayStatus.CANCELED, DisplayStatus.STOPPED, ) ] menu = self._populateContextMenu( diagnosticable_job_ids, stoppable_job_ids, cancelable_job_ids, removable_job_ids, ) if menu.actions(): return menu else: return None def _populateContextMenu(self, diagnosticable_job_ids, stoppable_job_ids, cancelable_job_ids, removable_job_ids): """ Get a populated context menu appropriate for the given job ids. :param diagnosticable_job_ids: Jobs that can create a postmortem archive. In general this is all top level jobs :type diagnosticable_job_ids: list[str] :param stoppable_job_ids: Jobs that can be stopped. Most jobs cannot be stopped, except certain Desmond jobs :type stoppable_job_ids: list[str] :param cancelable_job_ids: Jobs that can be canceled(killed). This should be all running jobs :type cancelable_job_ids: list[str] :param removable_job_ids: Jobs that can be deleted from the job db. :type removable_job_ids: list[str] :return: A populated menu :rtype: QtWidgets.QMenu """ menu = QtWidgets.QMenu(self) # Collect diagnostics - Appears for top level jobs only if diagnosticable_job_ids: collect_diagnostics_action = menu.addAction( "Collect Diagnostics...") collect_diagnostics_action.triggered.connect( lambda: self.collectDiagnosticsRequested.emit( diagnosticable_job_ids)) if (diagnosticable_job_ids) and (stoppable_job_ids or cancelable_job_ids): menu.addSeparator() # Stop - Only running jobs that support it if stoppable_job_ids: stop_action = menu.addAction("Stop") stop_action.triggered.connect( lambda: self.stopRequested.emit(stoppable_job_ids)) # Cancel - Running jobs only if cancelable_job_ids: cancel_action = menu.addAction("Cancel") cancel_action.triggered.connect( lambda: self.cancelRequested.emit(cancelable_job_ids)) # Delete Job Record- Completed/Failed job only if removable_job_ids: delete_action = menu.addAction("Delete Job Record") delete_action.triggered.connect( lambda: self.deleteJobRecordRequested.emit(removable_job_ids)) return menu
[docs] def refresh(self): """ Trigger a repaint of the table, letting the "last updated" column recalculate how long it's been since the job model was updated """ self.view.viewport().update()
[docs]class HorizontalHeader(QtWidgets.QHeaderView):
[docs] def __init__(self): super().__init__(Qt.Horizontal) self.setSectionsClickable(True)
[docs] def mouseReleaseEvent(self, ev): """ Do not make first and last columns sortable """ col = self.logicalIndexAt(ev.pos()) if col == 0 or col == 5: return super().mouseReleaseEvent(ev)
[docs]class TableProxy(QtCore.QIdentityProxyModel):
[docs] def headerData(self, section, orientation, role): """ Job name column header should be left aligned See Qt documentation for additional method documentation. """ if role == Qt.TextAlignmentRole: if section in {1, 2}: return Qt.AlignLeft | Qt.AlignVCenter return super().headerData(section, orientation, role)
[docs]class JobTableDelegate(delegates.MouseTrackingDelegateMixin, QtWidgets.QStyledItemDelegate):
[docs] def paint(self, painter, option, index): """ Don't paint focus around table cells (its just a dotted line around the cell you clicked on) """ option = QtWidgets.QStyleOptionViewItem(option) self.initStyleOption(option, index) option.state &= ~QtWidgets.QStyle.State_HasFocus super().paint(painter, option, index)
[docs]class JobDetailsArrowDelegate(JobTableDelegate): """ A delegate that paints an arrow when hovering over a row, signifying that there is a job details pane awaiting a click :ivar clicked: a signal emitted when the arrow delegate is left-clicked. Emitted with the index of the cell that is clicked. :vartype clicked: QtCore.pyqtSignal """ clicked = QtCore.pyqtSignal(QtCore.QModelIndex)
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.pixmap = QtGui.QPixmap(ICON_PATH + "next-arrow.png")
[docs] def paint(self, painter, option, index): super().paint(painter, option, index) if self._mouse_rc is not None: if self._mouse_rc[0] is index.row(): # If we are painting a row that has the mouse in it, draw the # "next" icon painter.setRenderHint(QtGui.QPainter.SmoothPixmapTransform) targ_rect = QtCore.QRect(option.rect) targ_rect.setWidth(option.rect.height()) painter.drawPixmap(targ_rect, self.pixmap, self.pixmap.rect())
[docs] def editorEvent(self, event, model, option, index): """ Handle mouse clicks: Emit the `clicked` signal when the left button of mouse on the cell is released. """ if event.type() == QtCore.QEvent.MouseButtonPress and event.button( ) == Qt.LeftButton: # store the position that is clicked self._pressed = index return True elif event.type() == QtCore.QEvent.MouseButtonRelease: if self._pressed == index: self.clicked.emit(index) self._pressed = None return True self._pressed = None return False
[docs]class JobProgressDelegate(JobTableDelegate): """ A delegate that paints a progress bar if the job is running and has set a max progress, otherwise just paints the status. Also paints the download status indicator for the completed jobs. """
[docs] def paint(self, painter, option, index): super().paint(painter, option, index) job_model = index.data(plptable.ROW_OBJECT_ROLE) # Job progress bar if should_paint_progress(job_model): h_padding = int(.1 * option.rect.width()) v_padding = int(.15 * option.rect.height()) target_rect = QtCore.QRect( option.rect.left() + h_padding, option.rect.top() + v_padding, option.rect.width() - 2 * h_padding, option.rect.height() - 2 * v_padding, ) progress = job_model.current_progress / job_model.max_progress paint_progress(painter, target_rect, progress) # Download indicator if not job_model.is_active: pixmap = None if (job_model.download_status == job_monitor_models.DownloadStatus.READY_TO_DOWNLOAD): pixmap = QtGui.QPixmap(ICON_PATH + "inactive-spinner.png") elif (job_model.download_status == job_monitor_models.DownloadStatus.DOWNLOADING): pixmap = QtGui.QPixmap(ICON_PATH + "active-spinner.png") if pixmap is None: return target_rect = QtCore.QRect(option.rect) x = target_rect.right() - 50 y = target_rect.top() + 8 painter.drawPixmap(x, y, 15, 15, pixmap)
[docs]class SubJobTableWidget(JobTableWidget): pass
[docs]def get_updated_time_display(updated_time): """ Return the updated timestamp for the last updated field of job. :param updated_time: UTC epoch time when the job was last updated :type updated_time: float :rtype: str """ # We first calculate the relative time display ago = int(util.get_current_utc_timestamp() - updated_time) ago_min = ago // 60 if ago_min < 1: return "Just now" elif ago_min <= 20: return f'{ago_min} min ago' # If relative time is more than 20 mins from now, we calculate the # absolute time local_datetime = util.convert_to_local_timezone(updated_time) _time = local_datetime.strftime( util.TIME_DISPLAY_FORMAT).lower().lstrip('0') if util.is_same_day(local_datetime): return _time _date = local_datetime.strftime("%d %b") return f"{_time} {_date}"
[docs]def get_updated_time_tooltip(updated_time): """ Return the updated timestamp for the tooltip of last updated field of job. :param updated_time: UTC epoch time when the job was last updated :type updated_time: float :rtype: str """ local_datetime = util.convert_to_local_timezone(updated_time) _time = local_datetime.strftime( util.TIME_DISPLAY_FORMAT).lower().lstrip('0') _date = local_datetime.strftime("%d %b") return f"Last Changed: {_time} {_date}"
[docs]def get_icon_path_for_status(status, requested_status=RequestedStatus.NONE): """" Return the requisite icon's path according to the status. :type status: jobcontrol.DisplayStatus :param requested_status: Requested status job is in. :type requested_status: RequestedStatus """ if requested_status == RequestedStatus.STOP: return ICON_PATH + "stop-requested.png" elif requested_status == RequestedStatus.CANCEL: return ICON_PATH + "cancel-requested.png" elif status == DisplayStatus.WAITING: return ICON_PATH + "waiting.png" elif status == DisplayStatus.RUNNING: return ICON_PATH + "running.png" elif status == DisplayStatus.CANCELED: return ICON_PATH + "killed.png" elif status == DisplayStatus.STOPPED: return ICON_PATH + "stop.png" elif status == DisplayStatus.FAILED: return ICON_PATH + "failed.png" elif status == DisplayStatus.COMPLETED: return ICON_PATH + "completed.png" else: return ICON_PATH + "unavailable.png"