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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Compact toolbar at the top of the window:
- **Project** - Set project path | Refresh to rescan scripts
- **Terminal** - Set Git Bash path
- **Venv** - Set venv activate path for scripts that interact with Python | Clear venv path to revert to auto-detect
- **Notification** - Dropdown to turn scheduler notifications **On** or **Off** (persisted in config). When On, a toast is shown for each scheduler-related event.
- **Theme toggle** - Switch between dark and light themes (persisted across restarts)

- **Page selector** - Switch between **Home** (script detail panel) and **Scheduler** (schedules and run history). The sidebar remains visible in both views.
Expand Down Expand Up @@ -124,6 +125,17 @@ A dedicated **Scheduler** page (via the Home | Scheduler selector) lets you run

Schedules and run history are stored in the `Scheduler` folder: `Scheduler/schedules.json`, `Scheduler/scheduler_history.json`, `Scheduler/history_logs.json`. Run logs are written to `Scheduler/logs/`.

### System notifications

When notifications are enabled (Toolbar → **Notification** → **Scheduled: On**), the app shows a small toast in the bottom-right of the screen for scheduler events only:

- **Scheduled run started** - The script process was started by the scheduler.
- **Scheduled run finished** - The run exited on its own.
- **Scheduled run killed** - The run was stopped (by you or by the scheduler before the next run).
- **Scheduled run error** - The run failed to start (e.g. script not found, not under project path, or launch exception).

Each toast shows the schedule name, script name, rule (e.g. interval and duration), and time until the next run. Toasts use the same theme as the app (dark or light), stay on top of other windows, auto-close after a few seconds, and can be closed early with the close button. Multiple toasts are shown one after another.

## How scripts are discovered

The app scans the **selected folder** recursively and lists every `.sh` file. Names are shown relative to the project root (e.g. `backend/run.sh`, `scripts/docker-up.sh`). Scripts **run with CWD = their own folder**, not the project root.
Expand Down
2 changes: 1 addition & 1 deletion assets/trash.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,18 @@ def toggle_favorite(script_path: str) -> bool:
fav.add(script_path)
save_favorites(fav)
return True


def load_scheduler_notification_enabled() -> bool:
"""Returns True when scheduler system notifications are enabled."""
raw = _load_all().get("scheduler_notification_enabled")
if isinstance(raw, bool):
return raw
return False


def save_scheduler_notification_enabled(enabled: bool) -> None:
"""Persists the scheduler system notification enabled flag."""
data = _load_all()
data["scheduler_notification_enabled"] = bool(enabled)
_save_all(data)
150 changes: 149 additions & 1 deletion src/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,19 @@
QWidget,
)

from datetime import datetime

import utils
from config import (
load_favorites,
load_project_path,
load_scheduler_notification_enabled,
load_script_categories,
load_terminal_path,
load_theme,
load_venv_activate_path,
save_project_path,
save_scheduler_notification_enabled,
save_script_category,
save_terminal_path,
save_theme,
Expand All @@ -48,17 +52,19 @@
from utils import get_process_tree_after_spawn, kill_script_process, run_script_in_gitbash, run_script_in_gitbash_captured

from scheduler_data import create_history_entry, now_iso
from scheduler_engine import get_due_schedules, validate_trigger
from scheduler_engine import format_rule_display, get_due_schedules, get_next_run, validate_trigger
from scheduler_storage import (
append_history_entry,
append_log,
get_run_log_file_path,
load_history,
load_schedules,
replace_log,
save_schedules,
update_history_entry,
)
from scheduler_ui import SchedulerContentWidget
from notifications import show_notification, update_notification_theme

CATEGORY_OPTIONS = ("None", "backend", "frontend")
CATEGORY_FILTER_OPTIONS = ("All", "Backend", "Frontend", "Running")
Expand Down Expand Up @@ -219,6 +225,58 @@ def __init__(self):
def _palette(self) -> dict:
return DARK_PALETTE if self._theme == "dark" else LIGHT_PALETTE

def _format_next_run_for_notification(self, schedule: dict) -> str:
if not schedule.get("enabled", False):
return "—"
next_run = get_next_run(schedule)
if next_run is None:
return "—"
now = datetime.now(next_run.tzinfo) if next_run.tzinfo is not None else datetime.now()
delta = next_run - now
total_seconds = int(delta.total_seconds())
if schedule.get("rule_type") == "time":
return next_run.strftime("%H:%M %d/%m/%y")
if total_seconds <= 0:
return "in 0s"
if total_seconds < 60:
return f"in {total_seconds}s"
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
if hours and minutes:
return f"in {hours}h {minutes}m"
if hours:
return f"in {hours}h"
return f"in {minutes}m"

def _build_notification_payload(
self,
schedule: dict,
script_name: Optional[str] = None,
error_message: Optional[str] = None,
) -> dict:
schedule_name = schedule.get("name", "—")
script_path = schedule.get("script_path") or ""
if script_name is None:
script_name = os.path.basename(script_path) if script_path else "—"
rule_type = format_rule_display(schedule)
next_run_text = self._format_next_run_for_notification(schedule)
return {
"schedule_name": schedule_name,
"script_name": script_name,
"rule_type": rule_type,
"next_run": next_run_text,
"error_message": error_message,
}

def _get_schedule_and_history_for_id(self, history_id: str) -> tuple[Optional[dict], Optional[dict]]:
runs = load_history()
history = next((r for r in runs if r.get("id") == history_id), None)
if history is None:
return None, None
schedules = load_schedules()
schedule = next((s for s in schedules if s.get("id") == history.get("schedule_id")), None)
return schedule, history

def _build_top_bar(self) -> QWidget:
bar = QWidget()
bar.setObjectName("topBar")
Expand Down Expand Up @@ -264,6 +322,21 @@ def _build_top_bar(self) -> QWidget:
)
row.addWidget(venv_btn)

notification_btn = QPushButton("Notification")
notification_btn.setObjectName("topBarBtn")
notification_btn.clicked.connect(
lambda: self._show_button_menu(
notification_btn,
[
(
f"Scheduled: {'On' if load_scheduler_notification_enabled() else 'Off'}",
self._toggle_scheduler_notifications,
),
],
)
)
row.addWidget(notification_btn)

row.addStretch()

theme_label = "☀ Light" if self._theme == "dark" else "🌙 Dark"
Expand All @@ -274,6 +347,10 @@ def _build_top_bar(self) -> QWidget:

return bar

def _toggle_scheduler_notifications(self) -> None:
enabled = load_scheduler_notification_enabled()
save_scheduler_notification_enabled(not enabled)

def _toggle_theme(self) -> None:
self._theme = "light" if self._theme == "dark" else "dark"
save_theme(self._theme)
Expand All @@ -284,6 +361,7 @@ def _toggle_theme(self) -> None:
if hasattr(self, "_scheduler_widget"):
self._scheduler_widget.update_log_highlighter_palette(self._palette)
self._scheduler_widget.refresh_current_view()
update_notification_theme(self._palette)

def _build_paths_row(self) -> QWidget:
row_widget = QWidget()
Expand Down Expand Up @@ -1045,6 +1123,18 @@ def _kill_script_row(self, row: dict) -> None:
return
history_id = row.get("scheduler_history_id")
if history_id:
if load_scheduler_notification_enabled():
schedule, history = self._get_schedule_and_history_for_id(history_id)
if schedule:
payload = self._build_notification_payload(schedule)
show_notification(
event_type="finished_killed",
schedule_name=payload["schedule_name"],
script_name=payload["script_name"],
rule_type=payload["rule_type"],
next_run=payload["next_run"],
palette=self._palette,
)
update_history_entry(history_id, {
"status": "killed",
"finished_at": now_iso(),
Expand Down Expand Up @@ -1073,6 +1163,18 @@ def check_processes(self) -> None:
continue
history_id = row.get("scheduler_history_id")
if history_id:
if load_scheduler_notification_enabled():
schedule, history = self._get_schedule_and_history_for_id(history_id)
if schedule:
payload = self._build_notification_payload(schedule)
show_notification(
event_type="finished_exited",
schedule_name=payload["schedule_name"],
script_name=payload["script_name"],
rule_type=payload["rule_type"],
next_run=payload["next_run"],
palette=self._palette,
)
update_history_entry(history_id, {
"status": "exited",
"finished_at": now_iso(),
Expand Down Expand Up @@ -1219,13 +1321,38 @@ def _execute_scheduled_run(self, schedule: dict) -> None:
)
append_history_entry(entry)
self._mark_schedule_triggered(schedule)
if load_scheduler_notification_enabled():
payload = self._build_notification_payload(schedule, error_message=error)
if not script_path:
payload["script_name"] = error
show_notification(
event_type="error",
schedule_name=payload["schedule_name"],
script_name=payload["script_name"],
rule_type=payload["rule_type"],
next_run=payload["next_run"],
palette=self._palette,
error_message=payload["error_message"],
)
return

row = self._get_row(script_path)

if row and self._is_row_running(row):
history_id = row.get("scheduler_history_id")
if history_id:
if load_scheduler_notification_enabled():
schedule_row, history = self._get_schedule_and_history_for_id(history_id)
if schedule_row:
payload = self._build_notification_payload(schedule_row)
show_notification(
event_type="finished_killed",
schedule_name=payload["schedule_name"],
script_name=payload["script_name"],
rule_type=payload["rule_type"],
next_run=payload["next_run"],
palette=self._palette,
)
update_history_entry(history_id, {
"status": "killed",
"finished_at": now_iso(),
Expand Down Expand Up @@ -1290,13 +1417,34 @@ def _execute_scheduled_run(self, schedule: dict) -> None:

if script_path == self._selected_script_path:
self._render_detail_panel()
if load_scheduler_notification_enabled():
payload = self._build_notification_payload(schedule)
show_notification(
event_type="start",
schedule_name=payload["schedule_name"],
script_name=payload["script_name"],
rule_type=payload["rule_type"],
next_run=payload["next_run"],
palette=self._palette,
)
except Exception as exc:
update_history_entry(entry["id"], {
"status": "failed",
"started_at": None,
"error_message": str(exc),
})
self._mark_schedule_triggered(schedule)
if load_scheduler_notification_enabled():
payload = self._build_notification_payload(schedule, error_message=str(exc))
show_notification(
event_type="error",
schedule_name=payload["schedule_name"],
script_name=payload["script_name"],
rule_type=payload["rule_type"],
next_run=payload["next_run"],
palette=self._palette,
error_message=payload["error_message"],
)

def _mark_schedule_triggered(self, schedule: dict) -> None:
schedule["last_triggered_at"] = now_iso()
Expand Down
Loading
Loading