Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/vorta/assets/UI/jobs_page.ui
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>JobsPage</class>
<widget class="QWidget" name="JobsPage">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>687</width>
<height>384</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTableView" name="jobsTable">
<property name="font">
<font>
<pointsize>11</pointsize>
<bold>false</bold>
</font>
</property>
<property name="showGrid">
<bool>false</bool>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
15 changes: 15 additions & 0 deletions src/vorta/assets/UI/schedule_tab.ui
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,21 @@
<layout class="QGridLayout" name="shellCommandsLayout">
</layout>
</widget>
<widget class="QWidget" name="page_jobs">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>687</width>
<height>384</height>
</rect>
</property>
<attribute name="label">
<string>Jobs</string>
</attribute>
<layout class="QGridLayout" name="jobsLayout">
</layout>
</widget>
</widget>
</item>
</layout>
Expand Down
46 changes: 45 additions & 1 deletion src/vorta/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,30 @@ class ScheduleStatus(NamedTuple):
time: dt | None = None


#: Timer states that hold a time for a run, and the row status each one shows as.
PENDING_STATUSES = {
ScheduleStatusType.SCHEDULED: JobModel.Status.SCHEDULED.value,
ScheduleStatusType.PAUSED: JobModel.Status.PAUSED.value,
}


class PendingJob(NamedTuple):
"""A run the scheduler is holding a time for, but that hasn't been recorded yet."""

profile_id: int
profile_name: str
repo_url: str | None
scheduled_at: dt
status: str


class VortaScheduler(QtCore.QObject):
#: The schedule for a profile changed.
schedule_changed = QtCore.pyqtSignal()

#: A job outcome was recorded.
jobs_changed = QtCore.pyqtSignal()

def __init__(self) -> None:
super().__init__()

Expand Down Expand Up @@ -519,6 +539,24 @@ def next_job_for_profile(self, profile_id: int) -> ScheduleStatus:
return ScheduleStatus(ScheduleStatusType.UNSCHEDULED)
return ScheduleStatus(job['type'], time=job.get('dt')) # type: ignore[arg-type]

def pending_jobs(self) -> list[PendingJob]:
"""The runs currently on the clock, soonest first."""
pending = []

for profile_id, timer in self.timers.items():
status = PENDING_STATUSES.get(timer['type'])
if status is None:
continue

profile = BackupProfileModel.get_or_none(id=profile_id)
if profile is None:
continue

repo_url = profile.repo.url if profile.repo else None
pending.append(PendingJob(profile_id, profile.name, repo_url, timer['dt'], status))

return sorted(pending, key=lambda job: job.scheduled_at)

def _record_skip(
self,
profile: BackupProfileModel,
Expand All @@ -545,9 +583,15 @@ def _record_skip(
if scheduled_at is None:
JobModel.create(**lookup, **details)
else:
JobModel.get_or_create(**lookup, defaults=details)
_, recorded = JobModel.get_or_create(**lookup, defaults=details)
if not recorded:
return
except pw.PeeweeException:
logger.warning('Could not record job for profile %s.', profile.id, exc_info=True)
return

# Two of the call sites hold `self.lock`, and the jobs view reads the table on this signal.
QTimer.singleShot(0, self.jobs_changed.emit)

def create_backup(self, profile_id: int, trigger: str = JobModel.Trigger.SCHEDULED.value) -> None:
notifier = VortaNotifications.pick()
Expand Down
1 change: 1 addition & 0 deletions src/vorta/store/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ class Status(Enum):
FAILED = 'failed'
SKIPPED = 'skipped'
INTERRUPTED = 'interrupted'
PAUSED = 'paused'

class Type(Enum):
BACKUP = 'backup'
Expand Down
58 changes: 58 additions & 0 deletions src/vorta/views/jobs_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from PyQt6 import uic
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QAbstractItemView, QHeaderView

from vorta.store.models import JobModel
from vorta.utils import get_asset
from vorta.views.base_tab import BaseTab
from vorta.views.partials.jobs_table_model import JobRow, JobsTableModel
from vorta.views.partials.sort_proxy import SortProxyModel

uifile = get_asset('UI/jobs_page.ui')
JobsPageUI, JobsPageBase = uic.loadUiType(uifile)

RECORD_LIMIT = 200


class JobsPage(BaseTab, JobsPageBase, JobsPageUI):
def __init__(self, parent=None, profile_provider=None):
super().__init__(parent=parent, profile_provider=profile_provider)
self.setupUi(self)

self._model = JobsTableModel(self)
self._proxy = SortProxyModel(self)
self._proxy.setSourceModel(self._model)
self.jobsTable.setModel(self._proxy)

self._pending = []
self._records = []

self.init_ui()
self.track_backup_finished(self.reload_records)
self.track_signal(self.app.scheduler.jobs_changed, self.reload_records)
self.track_signal(self.app.scheduler.schedule_changed, self.reload_pending)
self.reload_records()
self.reload_pending()

def init_ui(self):
self.jobsTable.setAlternatingRowColors(True)
header = self.jobsTable.horizontalHeader()
header.setVisible(True)
for i in range(self._model.columnCount()):
header.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(JobsTableModel.COL_REASON, QHeaderView.ResizeMode.Stretch)
self.jobsTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.jobsTable.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.jobsTable.sortByColumn(JobsTableModel.COL_TIME, Qt.SortOrder.DescendingOrder)

def reload_records(self):
records = JobModel.select().order_by(JobModel.created_at.desc()).limit(RECORD_LIMIT)
self._records = [JobRow.from_record(record) for record in records]
self._redraw()

def reload_pending(self):
self._pending = [JobRow.from_pending(pending) for pending in self.app.scheduler.pending_jobs()]
self._redraw()

def _redraw(self):
self._model.set_rows(self._pending + self._records)
144 changes: 144 additions & 0 deletions src/vorta/views/partials/jobs_table_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""
Qt table model exposing scheduled and recorded jobs to the JobsPage `QTableView`.

"""

from __future__ import annotations

from datetime import datetime as dt
from typing import TYPE_CHECKING, Any, List, NamedTuple, Optional

from PyQt6.QtCore import QT_TRANSLATE_NOOP, QAbstractTableModel, QModelIndex, Qt

from vorta.i18n import translate
from vorta.store.models import JobModel

if TYPE_CHECKING:
from vorta.scheduler import PendingJob


class JobRow(NamedTuple):
"""One row of the jobs table, from either a stored record or a pending run."""

time: dt
profile_name: Optional[str]
repo_url: Optional[str]
job_type: Optional[str]
trigger: Optional[str]
status: str
reason: Optional[str]

@classmethod
def from_record(cls, job: JobModel) -> 'JobRow':
return cls(
time=job.created_at,
profile_name=job.profile_name,
repo_url=job.repo_url,
job_type=job.job_type,
trigger=job.trigger,
status=job.status,
reason=job.reason,
)

@classmethod
def from_pending(cls, pending: 'PendingJob') -> 'JobRow':
return cls(
time=pending.scheduled_at,
profile_name=pending.profile_name,
repo_url=pending.repo_url,
job_type=JobModel.Type.BACKUP.value,
trigger=JobModel.Trigger.SCHEDULED.value,
status=pending.status,
reason=None,
)


class JobsTableModel(QAbstractTableModel):
"""Read-only table model for pending and recorded scheduler jobs."""

# Column indices in render order.
COL_TIME = 0
COL_PROFILE = 1
COL_REPOSITORY = 2
COL_TYPE = 3
COL_TRIGGER = 4
COL_STATUS = 5
COL_REASON = 6

_HEADERS = (
QT_TRANSLATE_NOOP('JobsPage', 'Time'),
QT_TRANSLATE_NOOP('JobsPage', 'Profile'),
QT_TRANSLATE_NOOP('JobsPage', 'Repository'),
QT_TRANSLATE_NOOP('JobsPage', 'Type'),
QT_TRANSLATE_NOOP('JobsPage', 'Trigger'),
QT_TRANSLATE_NOOP('JobsPage', 'Status'),
QT_TRANSLATE_NOOP('JobsPage', 'Reason'),
)

def __init__(self, parent: Optional[Any] = None):
"""Init."""
super().__init__(parent)
self._rows: List[JobRow] = []

def set_rows(self, rows: List[JobRow]) -> None:
"""Replace the model contents and notify attached views."""
self.beginResetModel()
self._rows = list(rows)
self.endResetModel()

def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
if parent.isValid():
return 0
return len(self._rows)

def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
if parent.isValid():
return 0
return len(self._HEADERS)

def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
if not index.isValid():
return None

row = self._rows[index.row()]
column = index.column()

if role == Qt.ItemDataRole.UserRole:
return self._sort_key(row, column)

if role != Qt.ItemDataRole.DisplayRole:
return None

if column == self.COL_TIME:
return row.time.strftime('%Y-%m-%d %H:%M')
if column == self.COL_PROFILE:
return row.profile_name
if column == self.COL_REPOSITORY:
return row.repo_url
if column == self.COL_TYPE:
return row.job_type
if column == self.COL_TRIGGER:
return row.trigger
if column == self.COL_STATUS:
return row.status
if column == self.COL_REASON:
return row.reason
return None

def _sort_key(self, row: JobRow, column: int) -> Any:
# JobRow fields are declared in column order, so the raw value sorts the column.
if 0 <= column < len(row):
return row[column]
return None

def headerData(
self,
section: int,
orientation: Qt.Orientation,
role: int = Qt.ItemDataRole.DisplayRole,
) -> Any:
if role != Qt.ItemDataRole.DisplayRole:
return None
if orientation == Qt.Orientation.Horizontal and 0 <= section < len(self._HEADERS):
return translate('JobsPage', self._HEADERS[section])
return None
8 changes: 8 additions & 0 deletions src/vorta/views/schedule_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from vorta.utils import get_asset
from vorta.views.base_tab import BaseTab
from vorta.views.jobs_page import JobsPage
from vorta.views.log_page import LogPage
from vorta.views.networks_page import NetworksPage
from vorta.views.schedule_page import SchedulePage
Expand All @@ -21,6 +22,7 @@ def __init__(self, parent=None, profile_provider=None):
self.init_shell_commands_page()
self.init_networks_page()
self.init_schedule_page()
self.init_jobs_page()
self.track_palette_change(call_now=True)
self.track_backup_finished(self.logPage.populate_logs)

Expand All @@ -44,8 +46,14 @@ def init_schedule_page(self):
self.scheduleLayout.addWidget(self.schedulePage)
self.schedulePage.show()

def init_jobs_page(self):
self.jobsPage = JobsPage(self, profile_provider=self.profile)
self.jobsLayout.addWidget(self.jobsPage)
self.jobsPage.show()

def set_icons(self):
self.toolBox.setItemIcon(0, get_colored_icon('clock-o'))
self.toolBox.setItemIcon(1, get_colored_icon('wifi'))
self.toolBox.setItemIcon(2, get_colored_icon('tasks'))
self.toolBox.setItemIcon(3, get_colored_icon('terminal'))
self.toolBox.setItemIcon(4, get_colored_icon('view-list-details'))
Loading
Loading