-
-
Notifications
You must be signed in to change notification settings - Fork 56
feat: add duplicate cron job functionality #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -183,3 +183,5 @@ cliff.toml | |
| # script | ||
| update_dependencies.py | ||
| test-server/ | ||
|
|
||
| .idea | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ Full documentation is available at [cronboard.dev](https://cronboard.dev). | |
| - Check cron jobs, view the logs, and detect potential issues when running | ||
| - Autocompletion for paths when creating or editing cron jobs | ||
| - Create cron jobs with validation and human-readable feedback | ||
| - Duplicate existing cron jobs with a single keystroke (preserves schedule, command, and logging) | ||
| - Pause/resume, edit and delete cron jobs | ||
| - View formatted last and next run times | ||
| - Accept `special expressions` like `@daily`, `@yearly`, and `@monthly` | ||
|
|
@@ -74,6 +75,18 @@ Path autocompletion when creating or editing cron jobs helps you enter file path | |
|
|
||
| The default starting point for autocompletion is the home directory of the user whose cron jobs you are managing. Accept a suggestion with the `Tab` key. | ||
|
|
||
| ### 📋 Keyboard Shortcuts | ||
|
|
||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. They shortcurs are covered on the website documentation. That's why I didn't choose to have it on the |
||
| | Key | Action | | ||
| |-----|--------| | ||
| | `c` | Create new cron job | | ||
| | `e` | Edit selected cron job | | ||
| | `d` | Duplicate selected cron job | | ||
| | `D` | Delete selected cron job | | ||
| | `p` | Pause/resume cron job | | ||
| | `/` | Search cron jobs | | ||
| | `L` | View logs for selected job | | ||
|
|
||
| ## ❤️ Do you like my work? | ||
|
|
||
| If you find the project useful, you can support the author here: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,12 +38,14 @@ def __init__( | |
| remote=False, | ||
| ssh_client=None, | ||
| crontab_user=None, | ||
| log_enabled=None, | ||
| ) -> None: | ||
| super().__init__() | ||
| self.expression = expression | ||
| self.command = command | ||
| self.identificator = identificator | ||
| self.log_enabled = has_wrapper(command) if command else False | ||
| # If log_enabled is explicitly provided, use it; otherwise detect from command | ||
| self.log_enabled = log_enabled if log_enabled is not None else (has_wrapper(command) if command else False) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here are you using |
||
| self.cron: CronTab = cron | ||
| self.remote = remote | ||
| self.ssh_client = ssh_client | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,8 @@ | |
|
|
||
|
|
||
| class CronTable(DataTable): | ||
| NO_ID_LABEL = "No ID" | ||
|
|
||
| BINDINGS = [ | ||
| Binding("/", "cron_search", "Search"), | ||
| Binding("escape", "clear_search", "Clear Search"), | ||
|
|
@@ -28,6 +30,7 @@ class CronTable(DataTable): | |
| Binding("r", "refresh", "Refresh"), | ||
| Binding("p", "pause_cronjob", "Pause Toggle"), | ||
| Binding("e", "edit_cronjob", "Edit"), | ||
| Binding("d", "duplicate_cronjob", "Duplicate"), | ||
| Binding("L", "view_logs", "View Logs"), | ||
| ] | ||
|
|
||
|
|
@@ -85,6 +88,7 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No | |
| "edit_cronjob", | ||
| "delete_cronjob", | ||
| "pause_cronjob", | ||
| "duplicate_cronjob", | ||
| "cursor_up", | ||
| "cursor_down", | ||
| "cursor_left", | ||
|
|
@@ -103,7 +107,7 @@ def parse_cron(self, cron): | |
| expr = job.slices.render() | ||
| cmd = command_without_wrapper(job.command) | ||
| log_enabled = has_wrapper(job.command) | ||
| identificator = job.comment if job.comment else "No ID" | ||
| identificator = job.comment if job.comment else self.NO_ID_LABEL | ||
| try: | ||
| active_status = "Active" if job.is_enabled() else "Paused" | ||
| schedule = job.schedule(date_from=datetime.now()) | ||
|
|
@@ -436,6 +440,35 @@ def write_remote_crontab(self): | |
| print(f"❌ Error writing remote crontab: {e}") | ||
| return False | ||
|
|
||
| def action_duplicate_cronjob(self) -> None: | ||
| """Duplicate the selected cronjob and open in edit mode.""" | ||
| row = self.get_row_at(self.cursor_row) | ||
| identificator = row[0] | ||
| expr = row[1] | ||
| cmd = row[2] | ||
| log_enabled = row[3] == "True" # Convert string to bool | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are you converting a bool to string? And why are you converting it this way? |
||
|
|
||
| # Generate new ID with "_copy" suffix (no spaces allowed in IDs) | ||
| if identificator == self.NO_ID_LABEL: | ||
| new_identificator = "No_ID_copy" | ||
| else: | ||
| new_identificator = f"{identificator}_copy" | ||
|
|
||
| # Open editor with duplicated job data | ||
| used_cron = self.ssh_cron if self.remote and self.ssh_client else self.cron | ||
| self.app.action_create_cronjob( | ||
| used_cron, | ||
| remote=self.remote, | ||
| ssh_client=self.ssh_client, | ||
| crontab_user=self.crontab_user, | ||
| duplicate_data={ | ||
| "identificator": new_identificator, | ||
| "expression": expr, | ||
| "command": cmd, | ||
| "log_enabled": log_enabled, | ||
| }, | ||
| ) | ||
|
|
||
| def action_view_logs(self) -> None: | ||
| """View logs for the selected cronjob.""" | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| """Simple tests for duplicate cronjob functionality.""" | ||
|
|
||
| from cronboard.widgets.CronTable import CronTable | ||
|
|
||
|
|
||
| def test_duplicate_binding_exists(): | ||
| """Verify 'd' keybinding is registered for duplicate.""" | ||
| binding_keys = [b.key for b in CronTable.BINDINGS] | ||
| assert "d" in binding_keys | ||
|
|
||
|
|
||
| def test_duplicate_binding_calls_action(): | ||
| """Verify 'd' binding calls action_duplicate_cronjob.""" | ||
| binding = next(b for b in CronTable.BINDINGS if b.key == "d") | ||
| assert binding.action == "duplicate_cronjob" | ||
|
|
||
|
|
||
| def test_action_duplicate_cronjob_method_exists(): | ||
| """Verify action_duplicate_cronjob method exists and is callable.""" | ||
| assert hasattr(CronTable, "action_duplicate_cronjob") | ||
| assert callable(getattr(CronTable, "action_duplicate_cronjob")) | ||
|
|
||
|
|
||
| def test_duplicate_binding_label(): | ||
| """Verify 'd' binding has correct description.""" | ||
| binding = next(b for b in CronTable.BINDINGS if b.key == "d") | ||
| assert binding.description == "Duplicate" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What I meant was: why did you add this to the file?