""" Dashboard Widget for Python Scheduler Control panel style with critical jobs, active executions, and recent history """ import json import socket from datetime import datetime, timedelta from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QScrollArea, QGroupBox, QGridLayout, QTableWidget, QTableWidgetItem, QPushButton, QHeaderView ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont, QColor, QPalette from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.date import DateTrigger from loguru import logger from core.database import SchedulerDatabase from core.config import SchedulerConfig from core.cron_utils import convert_unix_to_iso_dayofweek class DashboardWidget(QWidget): """Dashboard with control panel style""" def __init__(self, db: SchedulerDatabase, config: SchedulerConfig): super().__init__() self.db = db self.config = config self.current_week_start = self._get_week_start(datetime.now()) self._setup_ui() self.refresh() def _setup_ui(self): """Setup dashboard UI""" main_layout = QVBoxLayout(self) main_layout.setContentsMargins(20, 20, 20, 20) main_layout.setSpacing(20) # Top row - Critical jobs and Active now top_row = QHBoxLayout() # Critical jobs panel self.critical_panel = self._create_panel("🔴 CRITICAL JOBS", "#ffebee") self.critical_content = QVBoxLayout() self.critical_panel.setLayout(self.critical_content) top_row.addWidget(self.critical_panel, 1) # Active now panel self.active_panel = self._create_panel("▶️ ACTIVE NOW", "#e8f5e9") self.active_content = QVBoxLayout() self.active_panel.setLayout(self.active_content) top_row.addWidget(self.active_panel, 1) main_layout.addLayout(top_row) # Recent executions panel with scroll area self.recent_panel = self._create_panel("📊 RECENT EXECUTIONS", "#e3f2fd") # Create scroll area for recent executions recent_scroll = QScrollArea() recent_scroll.setWidgetResizable(True) recent_scroll.setFrameShape(QFrame.Shape.NoFrame) recent_scroll.setMaximumHeight(300) # Limit height to prevent overflow # Container widget for scroll area recent_container = QWidget() self.recent_content = QVBoxLayout() recent_container.setLayout(self.recent_content) recent_scroll.setWidget(recent_container) # Add scroll area to panel panel_layout = QVBoxLayout() panel_layout.addWidget(recent_scroll) self.recent_panel.setLayout(panel_layout) main_layout.addWidget(self.recent_panel, 1) # Weekly timeline panel self.timeline_panel = self._create_panel("📅 WEEKLY TIMELINE", "#fff3e0") self.timeline_content = QVBoxLayout() self.timeline_panel.setLayout(self.timeline_content) main_layout.addWidget(self.timeline_panel, 2) main_layout.addStretch() def _create_panel(self, title: str, bg_color: str) -> QGroupBox: """Create a styled panel""" panel = QGroupBox(title) # Style panel.setStyleSheet(f""" QGroupBox {{ font-size: 14px; font-weight: bold; border: 2px solid #ccc; border-radius: 8px; margin-top: 10px; padding: 15px; background-color: {bg_color}; }} QGroupBox::title {{ subcontrol-origin: margin; left: 10px; padding: 0 5px; }} """) return panel def refresh(self): """Refresh dashboard data""" try: self._refresh_critical_jobs() self._refresh_active_jobs() self._refresh_recent_executions() self._refresh_weekly_timeline() except Exception as e: logger.error(f"Error refreshing dashboard: {e}") def _refresh_critical_jobs(self): """Refresh critical jobs section""" # Clear existing self._clear_layout(self.critical_content) # Get critical jobs all_jobs = self.db.get_all_jobs(enabled_only=True, hostname=socket.gethostname()) critical_jobs = [j for j in all_jobs if j['criticality'] == 'critical'] if not critical_jobs: label = QLabel("No critical jobs configured") label.setStyleSheet("color: #666; font-style: italic;") self.critical_content.addWidget(label) else: for job in critical_jobs: job_label = QLabel(f"● {job['name']}") job_label.setStyleSheet("font-size: 12px; color: #c62828; font-weight: bold;") self.critical_content.addWidget(job_label) self.critical_content.addStretch() def _refresh_active_jobs(self): """Refresh active jobs section""" # Clear existing self._clear_layout(self.active_content) # Get active executions active = self.db.get_active_executions(hostname=socket.gethostname()) if not active: label = QLabel("No jobs running") label.setStyleSheet("color: #666; font-style: italic;") self.active_content.addWidget(label) else: for execution in active: job_label = QLabel(f"▶ {execution['job_name']}") job_label.setStyleSheet("font-size: 12px; color: #2e7d32; font-weight: bold;") self.active_content.addWidget(job_label) # Add status status_label = QLabel(f" Status: {execution['status']}") status_label.setStyleSheet("font-size: 10px; color: #666;") self.active_content.addWidget(status_label) self.active_content.addStretch() def _refresh_recent_executions(self): """Refresh recent executions section — consecutive same-job runs are grouped""" self._clear_layout(self.recent_content) limit = self.config.get('gui.dashboard_recent_jobs', 20) recent = [ ex for ex in self.db.get_recent_executions(limit=limit, hostname=socket.gethostname()) if ex.get('show_on_dashboard_value', 1) ] if not recent: label = QLabel("No recent executions") label.setStyleSheet("color: #666; font-style: italic;") self.recent_content.addWidget(label) else: # Collapse consecutive runs of the same job into a single row grouped = [] for execution in recent: row = dict(execution) if grouped and grouped[-1]['job_name'] == row['job_name']: grouped[-1]['_count'] += 1 grouped[-1]['_statuses'].append(row['status']) grouped[-1]['_times'].append(row['start_time']) else: row['_count'] = 1 row['_statuses'] = [row['status']] row['_times'] = [row['start_time']] grouped.append(row) for execution in grouped: count = execution['_count'] statuses = execution['_statuses'] has_failure = any(s in ('failed', 'timeout') for s in statuses) exec_frame = QFrame() exec_frame.setFrameStyle(QFrame.Shape.Box | QFrame.Shadow.Plain) exec_frame.setLineWidth(1) exec_frame.setStyleSheet("background-color: white; border-radius: 4px; padding: 5px;") exec_layout = QHBoxLayout(exec_frame) exec_layout.setContentsMargins(10, 5, 10, 5) # Time (most recent run) time_str = execution['start_time'][:19] if execution['start_time'] else "N/A" time_label = QLabel(time_str) time_label.setFixedWidth(150) time_label.setStyleSheet("font-family: monospace; font-size: 11px;") exec_layout.addWidget(time_label) # Status icon: ⚠️ if any failure in the group if count > 1 and has_failure: status_icon = '⚠️' else: status_icon = self._get_status_icon(execution['status']) status_label = QLabel(status_icon) status_label.setFixedWidth(30) exec_layout.addWidget(status_label) # Job name + repeat badge name_text = execution['job_name'] name_label = QLabel(name_text) name_label.setStyleSheet("font-size: 11px; font-weight: bold;") exec_layout.addWidget(name_label, 1) if count > 1: badge = QLabel(f"×{count}") badge.setStyleSheet( "font-size: 10px; font-weight: bold; color: white;" "background-color: #1976d2; border-radius: 8px; padding: 1px 6px;" ) badge.setToolTip( "Esecuzioni raggruppate:\n" + "\n".join(t[:19] for t in execution['_times'] if t) ) exec_layout.addWidget(badge) # Duration if execution['duration_seconds']: duration_str = f"{execution['duration_seconds']:.1f}s" else: duration_str = "N/A" duration_label = QLabel(duration_str) duration_label.setFixedWidth(60) duration_label.setAlignment(Qt.AlignmentFlag.AlignRight) duration_label.setStyleSheet("font-family: monospace; font-size: 11px;") exec_layout.addWidget(duration_label) # Exit code exit_code_str = str(execution['exit_code']) if execution['exit_code'] is not None else "N/A" exit_label = QLabel(f"Exit: {exit_code_str}") exit_label.setFixedWidth(60) exit_label.setAlignment(Qt.AlignmentFlag.AlignRight) exit_label.setStyleSheet("font-family: monospace; font-size: 11px;") exec_layout.addWidget(exit_label) self.recent_content.addWidget(exec_frame) self.recent_content.addStretch() def _refresh_weekly_timeline(self): """Refresh weekly timeline calendar view""" # Save scroll position if table exists scroll_pos = None if self.timeline_content.count() > 1: # Header + table old_table = self.timeline_content.itemAt(1).widget() if isinstance(old_table, QTableWidget): scroll_pos = old_table.verticalScrollBar().value() # Clear existing content self._clear_layout(self.timeline_content) # Navigation header nav_layout = QHBoxLayout() prev_btn = QPushButton("◀ Prev") prev_btn.setFixedWidth(60) prev_btn.clicked.connect(self._prev_week) nav_layout.addWidget(prev_btn) week_end = self.current_week_start + timedelta(days=6) week_label = QLabel(f"{self.current_week_start.strftime('%d %b')} - {week_end.strftime('%d %b %Y')}") week_label.setAlignment(Qt.AlignmentFlag.AlignCenter) week_label.setStyleSheet("font-weight: bold; font-size: 12px;") nav_layout.addWidget(week_label, 1) today_btn = QPushButton("Today") today_btn.setFixedWidth(60) today_btn.clicked.connect(self._reset_to_current_week) nav_layout.addWidget(today_btn) next_btn = QPushButton("Next ▶") next_btn.setFixedWidth(60) next_btn.clicked.connect(self._next_week) nav_layout.addWidget(next_btn) self.timeline_content.addLayout(nav_layout) # Create timeline table (SETTIMANA LAVORATIVA 08:00-18:00) table = QTableWidget() table.setColumnCount(5) # 5 days (Mon-Fri) table.setRowCount(21) # 21 half-hour slots (08:00-18:00 included) # Headers headers = ["Mon", "Tue", "Wed", "Thu", "Fri"] table.setHorizontalHeaderLabels(headers) # Vertical headers (time slots from 08:00 to 18:00) start_hour = 8 for row in range(21): hour = start_hour + (row // 2) minute = (row % 2) * 30 time_label = f"{hour:02d}:{minute:02d}" table.setVerticalHeaderItem(row, QTableWidgetItem(time_label)) # Get week executions executions = self._get_week_executions() # Populate table self._populate_timeline_table(table, executions) # Table styling table.setAlternatingRowColors(True) table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) table.setSelectionMode(QTableWidget.SelectionMode.NoSelection) # Disable auto scroll on selection/update table.setAutoScroll(False) table.setVerticalScrollMode(QTableWidget.ScrollMode.ScrollPerPixel) table.setHorizontalScrollMode(QTableWidget.ScrollMode.ScrollPerPixel) # Prevent auto-resize that causes scroll jumping table.setSizeAdjustPolicy(QTableWidget.SizeAdjustPolicy.AdjustToContentsOnFirstShow) # Set fixed height to fit in window # Reduced height: 19 rows * 20px + header + margins = ~420px table.setFixedHeight(350) # Disable focus to prevent scroll jumping table.setFocusPolicy(Qt.FocusPolicy.NoFocus) # Adjust row and column sizes table.verticalHeader().setDefaultSectionSize(25) # Row height table.verticalHeader().setVisible(True) # Ensure vertical header is visible # Equal width for all day columns for col in range(5): table.horizontalHeader().setSectionResizeMode(col, QHeaderView.ResizeMode.Stretch) self.timeline_content.addWidget(table) # Restore scroll position if it was saved if scroll_pos is not None: table.verticalScrollBar().setValue(scroll_pos) def _get_week_start(self, date): """Get Monday of the week for given date""" return (date - timedelta(days=date.weekday())).replace(hour=0, minute=0, second=0, microsecond=0) def _prev_week(self): """Navigate to previous week""" self.current_week_start -= timedelta(days=7) self._refresh_weekly_timeline() def _next_week(self): """Navigate to next week""" self.current_week_start += timedelta(days=7) self._refresh_weekly_timeline() def _reset_to_current_week(self): """Reset to current week""" self.current_week_start = self._get_week_start(datetime.now()) self._refresh_weekly_timeline() def _get_week_executions(self): """Calculate all schedule executions for current week""" try: # Import pytz for timezone handling import pytz tz = pytz.timezone("Europe/Rome") # Make week_start and week_end timezone-aware week_start = tz.localize(self.current_week_start) week_end = week_start + timedelta(days=7) schedules = self.db.get_all_schedules(enabled_only=True, hostname=socket.gethostname()) executions = [] for schedule in schedules: # Skip manual schedules if schedule['schedule_type'] == 'manual': continue # Skip sync schedules (not displayed on dashboard) # Sync operations are monitored in dedicated Sync tab if schedule['target_type'] == 'sync_profile': continue # Skip if target deleted if schedule.get('target_name') is None: continue # Skip jobs marked as hidden from dashboard if not schedule.get('show_on_dashboard_value', 1): continue try: config = json.loads(schedule['schedule_config']) if isinstance(schedule['schedule_config'], str) else schedule['schedule_config'] except Exception as e: logger.error(f"Failed to parse schedule config for schedule {schedule['id']}: {e}") continue # Build trigger trigger = self._build_trigger_for_timeline(schedule['schedule_type'], config) if not trigger: continue # Calculate all executions in week previous = None now = week_start max_iterations = 1000 # Safety limit iterations = 0 while iterations < max_iterations: try: next_run = trigger.get_next_fire_time(previous, now) except Exception as e: logger.error(f"Error calculating next_run for schedule {schedule['id']}: {e}") break if not next_run or next_run >= week_end: break executions.append({ 'datetime': next_run, 'schedule_id': schedule['id'], 'schedule_name': schedule['name'], 'target_type': schedule['target_type'], 'target_name': schedule['target_name'] }) previous = next_run now = next_run + timedelta(seconds=1) # Advance slightly to avoid infinite loops iterations += 1 return executions except Exception as e: logger.error(f"Error in _get_week_executions: {e}", exc_info=True) return [] def _build_trigger_for_timeline(self, schedule_type, config): """Build APScheduler trigger for timeline calculations""" try: tz = "Europe/Rome" if schedule_type == 'cron': cron_expr = config.get('cron') if not cron_expr: return None # Convert Unix day-of-week (0=Sun) to ISO day-of-week (0=Mon) cron_expr_iso = convert_unix_to_iso_dayofweek(cron_expr) return CronTrigger.from_crontab(cron_expr_iso, timezone=tz) elif schedule_type == 'interval': return IntervalTrigger(timezone=tz, **config) elif schedule_type == 'date': run_date = config.get('run_date') if not run_date: return None return DateTrigger(run_date=run_date, timezone=tz) else: return None except Exception as e: logger.warning(f"Could not build trigger: {e}") return None def _populate_timeline_table(self, table, executions): """Populate timeline table — deduplicated by schedule name within each 30-min slot""" # slots[key] = { schedule_name: {'fire_times': [...], 'target_type': str, 'target_name': str} } slots = {} for execution in executions: exec_dt = execution['datetime'] if exec_dt.tzinfo: exec_dt = exec_dt.astimezone().replace(tzinfo=None) day_of_week = exec_dt.weekday() hour = exec_dt.hour minute = exec_dt.minute if hour < 8 or hour > 18 or (hour == 18 and minute > 0) or day_of_week >= 5: continue slot_index = (hour - 8) * 2 + (1 if minute >= 30 else 0) key = (slot_index, day_of_week) if key not in slots: slots[key] = {} sname = execution['schedule_name'] if sname not in slots[key]: slots[key][sname] = { 'fire_times': [], 'target_type': execution['target_type'], 'target_name': execution['target_name'], } slots[key][sname]['fire_times'].append(exec_dt) # Populate cells for (row, col), schedule_map in slots.items(): unique_names = list(schedule_map.keys()) n_unique = len(unique_names) # Cell label: single name (with ×N if repeated) or "N tasks" if n_unique == 1: sname = unique_names[0] fires = len(schedule_map[sname]['fire_times']) if fires > 1: cell_text = f"{sname[:12]} ×{fires}" else: cell_text = sname[:15] else: cell_text = f"{n_unique} tasks" # Tooltip: one line per unique schedule, with fire count and sample times tooltip_lines = [] for sname, sdata in schedule_map.items(): fires = len(sdata['fire_times']) target = sdata['target_name'] if fires == 1: t = sdata['fire_times'][0].strftime('%H:%M') tooltip_lines.append(f"{t} {sname} ({target})") else: sample = ', '.join(ft.strftime('%H:%M') for ft in sdata['fire_times'][:3]) if fires > 3: sample += '…' tooltip_lines.append(f"×{fires} {sname} [{sample}] ({target})") item = QTableWidgetItem(cell_text) item.setToolTip("\n".join(tooltip_lines)) item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) # Color by predominant target type types = [sdata['target_type'] for sdata in schedule_map.values()] backup_count = types.count('backup_profile') job_count = types.count('job') group_count = types.count('group') if backup_count > 0 and job_count == 0 and group_count == 0: item.setBackground(QColor("#bbdefb")) elif job_count > 0 and backup_count == 0 and group_count == 0: item.setBackground(QColor("#c8e6c9")) elif group_count > 0 and backup_count == 0 and job_count == 0: item.setBackground(QColor("#e1bee7")) else: item.setBackground(QColor("#fff9c4")) table.setItem(row, col, item) # Fill empty cells for row in range(19): for col in range(5): if table.item(row, col) is None: table.setItem(row, col, QTableWidgetItem("")) def _calculate_next_run(self, schedule_type: str, schedule_config: dict): """Calculate next run datetime for a schedule""" try: tz = "Europe/Rome" if schedule_type == 'cron': cron_expr = schedule_config.get('cron') if not cron_expr: return None trigger = CronTrigger.from_crontab(cron_expr, timezone=tz) elif schedule_type == 'interval': trigger = IntervalTrigger(timezone=tz, **schedule_config) elif schedule_type == 'date': run_date = schedule_config.get('run_date') if not run_date: return None if isinstance(run_date, str): try: run_date = datetime.fromisoformat(run_date) except Exception: pass trigger = DateTrigger(run_date=run_date, timezone=tz) else: return None return trigger.get_next_fire_time(previous_fire_time=None, now=datetime.now(trigger.timezone)) except Exception as exc: logger.warning(f"Could not compute next run: {exc}") return None def _format_schedule_text(self, schedule_type: str, schedule_config: dict) -> str: """Human-readable schedule description""" if schedule_type == 'cron': return f"Cron: {schedule_config.get('cron', 'N/A')}" if schedule_type == 'interval': parts = [] for key in ['weeks', 'days', 'hours', 'minutes', 'seconds']: value = schedule_config.get(key) if value: parts.append(f"{value} {key}") return f"Every {', '.join(parts)}" if parts else "Interval schedule" if schedule_type == 'date': return f"One-time: {schedule_config.get('run_date', 'N/A')}" if schedule_type == 'manual': return "Manual execution" return str(schedule_type) def _format_target(self, schedule: dict) -> str: """Label target with its type""" target_name = schedule.get('target_name') or f"ID {schedule.get('target_id')}" type_map = { 'job': 'Job', 'group': 'Group', 'backup_profile': 'Backup' } prefix = type_map.get(schedule.get('target_type'), schedule.get('target_type', 'Target')) return f"{prefix}: {target_name}" def _get_status_icon(self, status: str) -> str: """Get status icon for execution""" icons = { 'completed': '✅', 'failed': '❌', 'running': '▶️', 'timeout': '⏱️', 'cancelled': '⛔', 'queued': '⏳' } return icons.get(status, '❓') def _clear_layout(self, layout): """Clear all widgets from layout""" while layout.count(): item = layout.takeAt(0) if item.widget(): item.widget().deleteLater() elif item.layout(): self._clear_layout(item.layout())