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 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 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]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"