From 961484907fb13f213dfbff8d604f757779163535 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 03:01:26 +0000 Subject: [PATCH 1/2] Initial plan From 0c9d007856833dd0b18db5a94b448abcc217baec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 03:11:22 +0000 Subject: [PATCH 2/2] feat: add schedule timeout and retry controls for backup and cloud sync Agent-Logs-Url: https://github.com/mlapaglia/Borgitory/sessions/1887ed9c-b874-4afb-be32-be7562a39f75 Co-authored-by: mlapaglia <4184746+mlapaglia@users.noreply.github.com> --- ...a9c21_add_schedule_retry_timeout_fields.py | 52 ++++++++++++++ src/borgitory/api/schedules.py | 19 +++-- src/borgitory/models/database.py | 8 +++ src/borgitory/models/schemas.py | 61 ++++++++++++++++ src/borgitory/services/jobs/job_service.py | 4 ++ .../task_executors/backup_task_executor.py | 66 +++++++++++++++-- .../cloud_sync_task_executor.py | 70 +++++++++++++++++-- .../services/scheduling/schedule_service.py | 25 ++++++- .../services/scheduling/scheduler_service.py | 4 ++ .../services/task_definition_builder.py | 21 +++++- .../partials/schedules/create_form.html | 46 ++++++++++++ .../partials/schedules/edit_form.html | 48 +++++++++++++ .../schedules/schedule_list_content.html | 7 ++ .../schedules/test_manual_run_apscheduler.py | 8 +++ .../test_schedule_validation_service.py | 33 +++++++++ tests/unit/test_task_definition_builder.py | 29 ++++++++ 16 files changed, 477 insertions(+), 24 deletions(-) create mode 100644 src/borgitory/alembic/versions/4d6f8f8a9c21_add_schedule_retry_timeout_fields.py diff --git a/src/borgitory/alembic/versions/4d6f8f8a9c21_add_schedule_retry_timeout_fields.py b/src/borgitory/alembic/versions/4d6f8f8a9c21_add_schedule_retry_timeout_fields.py new file mode 100644 index 00000000..1475c7c8 --- /dev/null +++ b/src/borgitory/alembic/versions/4d6f8f8a9c21_add_schedule_retry_timeout_fields.py @@ -0,0 +1,52 @@ +"""Add schedule retry and timeout fields + +Revision ID: 4d6f8f8a9c21 +Revises: 18b9095bc772 +Create Date: 2026-05-22 03:10:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "4d6f8f8a9c21" +down_revision: Union[str, Sequence[str], None] = "18b9095bc772" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + with op.batch_alter_table("schedules", schema=None) as batch_op: + batch_op.add_column( + sa.Column("backup_timeout_seconds", sa.Integer(), nullable=True) + ) + batch_op.add_column( + sa.Column( + "backup_retry_count", sa.Integer(), nullable=False, server_default="0" + ) + ) + batch_op.add_column( + sa.Column("cloud_sync_timeout_seconds", sa.Integer(), nullable=True) + ) + batch_op.add_column( + sa.Column( + "cloud_sync_retry_count", + sa.Integer(), + nullable=False, + server_default="0", + ) + ) + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table("schedules", schema=None) as batch_op: + batch_op.drop_column("cloud_sync_retry_count") + batch_op.drop_column("cloud_sync_timeout_seconds") + batch_op.drop_column("backup_retry_count") + batch_op.drop_column("backup_timeout_seconds") diff --git a/src/borgitory/api/schedules.py b/src/borgitory/api/schedules.py index b4e1469e..b9d8fabe 100644 --- a/src/borgitory/api/schedules.py +++ b/src/borgitory/api/schedules.py @@ -96,10 +96,15 @@ async def create_schedule( source_path=schedule.source_path or "", cloud_sync_config_id=schedule.cloud_sync_config_id, prune_config_id=schedule.prune_config_id, + check_config_id=schedule.check_config_id, notification_config_id=schedule.notification_config_id, pre_job_hooks=schedule.pre_job_hooks, post_job_hooks=schedule.post_job_hooks, patterns=schedule.patterns, + backup_timeout_seconds=schedule.backup_timeout_seconds, + backup_retry_count=schedule.backup_retry_count, + cloud_sync_timeout_seconds=schedule.cloud_sync_timeout_seconds, + cloud_sync_retry_count=schedule.cloud_sync_retry_count, ) if result.is_error or not result.schedule: @@ -489,7 +494,7 @@ async def move_hook( {"hook_type": hook_type, "hooks": current_hooks}, ) - except ValueError, TypeError, KeyError: + except (ValueError, TypeError, KeyError): return HTMLResponse(content='
') @@ -519,7 +524,7 @@ async def remove_hook_field( {"hook_type": hook_type, "hooks": current_hooks}, ) - except ValueError, TypeError, KeyError: + except (ValueError, TypeError, KeyError): return HTMLResponse(content='') @@ -537,7 +542,7 @@ async def get_hooks_modal( # Get data from the actual form field names pre_hooks_json = str(json_data.get("pre_job_hooks", "[]")) post_hooks_json = str(json_data.get("post_job_hooks", "[]")) - except ValueError, TypeError, KeyError: + except (ValueError, TypeError, KeyError): pre_hooks_json = "[]" post_hooks_json = "[]" @@ -580,7 +585,7 @@ async def save_hooks( try: pre_count = len(json.loads(pre_hooks_json)) if pre_hooks_json else 0 post_count = len(json.loads(post_hooks_json)) if post_hooks_json else 0 - except json.JSONDecodeError, TypeError: + except (json.JSONDecodeError, TypeError): pre_count = 0 post_count = 0 @@ -663,7 +668,7 @@ async def move_pattern( {"patterns": current_patterns}, ) - except ValueError, TypeError, KeyError: + except (ValueError, TypeError, KeyError): return HTMLResponse(content='') @@ -690,7 +695,7 @@ async def remove_pattern_field( {"patterns": current_patterns}, ) - except ValueError, TypeError, KeyError: + except (ValueError, TypeError, KeyError): return HTMLResponse(content='') @@ -741,7 +746,7 @@ async def save_patterns( try: total_count = len(json.loads(patterns_json)) if patterns_json else 0 - except json.JSONDecodeError, TypeError: + except (json.JSONDecodeError, TypeError): total_count = 0 return templates.TemplateResponse( diff --git a/src/borgitory/models/database.py b/src/borgitory/models/database.py index d8ef6312..fdb90823 100644 --- a/src/borgitory/models/database.py +++ b/src/borgitory/models/database.py @@ -252,6 +252,14 @@ class Schedule(Base): pre_job_hooks: Mapped[str | None] = mapped_column(Text, nullable=True) post_job_hooks: Mapped[str | None] = mapped_column(Text, nullable=True) patterns: Mapped[str | None] = mapped_column(Text, nullable=True) + backup_timeout_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) + backup_retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + cloud_sync_timeout_seconds: Mapped[int | None] = mapped_column( + Integer, nullable=True + ) + cloud_sync_retry_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0 + ) repository: Mapped["Repository"] = relationship( "Repository", back_populates="schedules" diff --git a/src/borgitory/models/schemas.py b/src/borgitory/models/schemas.py index 9e34b23a..f275432c 100644 --- a/src/borgitory/models/schemas.py +++ b/src/borgitory/models/schemas.py @@ -313,6 +313,10 @@ class ScheduleCreate(ScheduleBase): pre_job_hooks: Optional[str] = None post_job_hooks: Optional[str] = None patterns: Optional[str] = None + backup_timeout_seconds: Optional[int] = Field(None, gt=0) + backup_retry_count: int = Field(default=0, ge=0) + cloud_sync_timeout_seconds: Optional[int] = Field(None, gt=0) + cloud_sync_retry_count: int = Field(default=0, ge=0) @field_validator("cloud_sync_config_id", mode="before") @classmethod @@ -383,6 +387,21 @@ def validate_patterns(cls, v: Union[str, None]) -> Optional[str]: raise ValueError(f"Invalid patterns configuration: {error_msg}") return v.strip() + @field_validator( + "backup_timeout_seconds", + "backup_retry_count", + "cloud_sync_timeout_seconds", + "cloud_sync_retry_count", + mode="before", + ) + @classmethod + def validate_optional_numeric_fields( + cls, v: Union[str, int, None] + ) -> Optional[int]: + if v == "" or v is None: + return None + return int(v) + class ScheduleUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=128) @@ -397,6 +416,10 @@ class ScheduleUpdate(BaseModel): pre_job_hooks: Optional[str] = None post_job_hooks: Optional[str] = None patterns: Optional[str] = None + backup_timeout_seconds: Optional[int] = Field(None, gt=0) + backup_retry_count: Optional[int] = Field(None, ge=0) + cloud_sync_timeout_seconds: Optional[int] = Field(None, gt=0) + cloud_sync_retry_count: Optional[int] = Field(None, ge=0) @field_validator("pre_job_hooks", mode="before") @classmethod @@ -482,6 +505,21 @@ def validate_notification_config_id(cls, v: Union[str, int, None]) -> Optional[i return None return int(v) + @field_validator( + "backup_timeout_seconds", + "backup_retry_count", + "cloud_sync_timeout_seconds", + "cloud_sync_retry_count", + mode="before", + ) + @classmethod + def validate_optional_numeric_fields( + cls, v: Union[str, int, None] + ) -> Optional[int]: + if v == "" or v is None: + return None + return int(v) + class Schedule(ScheduleBase): id: int = Field(gt=0) @@ -493,6 +531,10 @@ class Schedule(ScheduleBase): created_at: datetime cloud_sync_config_id: Optional[int] = Field(None, gt=0) prune_config_id: Optional[int] = Field(None, gt=0) + backup_timeout_seconds: Optional[int] = Field(None, gt=0) + backup_retry_count: int = Field(default=0, ge=0) + cloud_sync_timeout_seconds: Optional[int] = Field(None, gt=0) + cloud_sync_retry_count: int = Field(default=0, ge=0) model_config = { "from_attributes": True, @@ -619,6 +661,10 @@ class BackupRequest(BaseModel): pre_job_hooks: Optional[str] = None post_job_hooks: Optional[str] = None patterns: Optional[str] = None + backup_timeout_seconds: Optional[int] = Field(None, gt=0) + backup_retry_count: Optional[int] = Field(None, ge=0) + cloud_sync_timeout_seconds: Optional[int] = Field(None, gt=0) + cloud_sync_retry_count: Optional[int] = Field(None, ge=0) @field_validator("dry_run", mode="before") @classmethod @@ -674,6 +720,21 @@ def validate_patterns(cls, v: Union[str, None]) -> Optional[str]: raise ValueError(f"Invalid patterns configuration: {error_msg}") return v.strip() + @field_validator( + "backup_timeout_seconds", + "backup_retry_count", + "cloud_sync_timeout_seconds", + "cloud_sync_retry_count", + mode="before", + ) + @classmethod + def validate_optional_numeric_fields( + cls, v: Union[str, int, None] + ) -> Optional[int]: + if v == "" or v is None: + return None + return int(v) + class CloudSyncConfigBase(BaseModel): name: str = Field( diff --git a/src/borgitory/services/jobs/job_service.py b/src/borgitory/services/jobs/job_service.py index be359f00..f6d221c5 100644 --- a/src/borgitory/services/jobs/job_service.py +++ b/src/borgitory/services/jobs/job_service.py @@ -136,10 +136,14 @@ async def create_backup_job( repository_name=repository.name, include_backup=True, backup_params=backup_params, + backup_timeout_seconds=backup_request.backup_timeout_seconds, + backup_retry_count=backup_request.backup_retry_count, prune_config_id=backup_request.prune_config_id, check_config_id=backup_request.check_config_id, include_cloud_sync=backup_request.cloud_sync_config_id is not None, cloud_sync_config_id=backup_request.cloud_sync_config_id, + cloud_sync_timeout_seconds=backup_request.cloud_sync_timeout_seconds, + cloud_sync_retry_count=backup_request.cloud_sync_retry_count, notification_config_id=backup_request.notification_config_id, pre_job_hooks=backup_request.pre_job_hooks, post_job_hooks=backup_request.post_job_hooks, diff --git a/src/borgitory/services/jobs/task_executors/backup_task_executor.py b/src/borgitory/services/jobs/task_executors/backup_task_executor.py index 40dcd2b6..d0034fed 100644 --- a/src/borgitory/services/jobs/task_executors/backup_task_executor.py +++ b/src/borgitory/services/jobs/task_executors/backup_task_executor.py @@ -9,6 +9,7 @@ JobEventBroadcasterProtocol, ) from borgitory.protocols.command_protocols import ProcessExecutorProtocol +from borgitory.protocols.command_protocols import ProcessResult from borgitory.protocols.job_output_manager_protocol import JobOutputManagerProtocol from borgitory.protocols.job_database_manager_protocol import JobDatabaseManagerProtocol from borgitory.services.jobs.broadcaster.event_type import EventType @@ -141,14 +142,65 @@ def task_output_callback(line: str) -> None: additional_args=additional_args, environment_overrides=env_overrides, ) - process = await self.job_executor.start_process( - borg_command.command, borg_command.environment - ) + timeout_value = params.get("timeout") + timeout_seconds = int(str(timeout_value)) if timeout_value else None + retry_count_value = params.get("retry_count") + retry_count = int(str(retry_count_value)) if retry_count_value else 0 + total_attempts = max(1, retry_count + 1) + + result = None + for attempt in range(1, total_attempts + 1): + if total_attempts > 1: + task_output_callback( + f"Backup attempt {attempt} of {total_attempts} started" + ) - # Monitor the process (outside context manager since it's long-running) - result = await self.job_executor.monitor_process_output( - process, output_callback=task_output_callback - ) + process = await self.job_executor.start_process( + borg_command.command, borg_command.environment + ) + + try: + if timeout_seconds: + result = await asyncio.wait_for( + self.job_executor.monitor_process_output( + process, output_callback=task_output_callback + ), + timeout=float(timeout_seconds), + ) + else: + result = await self.job_executor.monitor_process_output( + process, output_callback=task_output_callback + ) + except asyncio.TimeoutError: + await self.job_executor.terminate_process(process) + timeout_error = ( + f"Backup timed out after {timeout_seconds}s " + f"(attempt {attempt}/{total_attempts})" + ) + task_output_callback(timeout_error) + logger.warning(timeout_error) + if attempt < total_attempts: + continue + result = None + + if result and result.return_code == 0: + break + + if attempt < total_attempts: + task_output_callback( + f"Backup attempt {attempt} failed, retrying..." + ) + + if result is None: + result = ProcessResult( + return_code=124, + stdout=b"", + stderr=b"", + error=( + f"Backup timed out after {timeout_seconds}s " + f"for all {total_attempts} attempt(s)" + ), + ) logger.info( f"Backup process completed with return code: {result.return_code}" diff --git a/src/borgitory/services/jobs/task_executors/cloud_sync_task_executor.py b/src/borgitory/services/jobs/task_executors/cloud_sync_task_executor.py index 4cfc62e1..d07e1854 100644 --- a/src/borgitory/services/jobs/task_executors/cloud_sync_task_executor.py +++ b/src/borgitory/services/jobs/task_executors/cloud_sync_task_executor.py @@ -13,6 +13,7 @@ JobEventBroadcasterProtocol, ) from borgitory.protocols.command_protocols import ProcessExecutorProtocol +from borgitory.protocols.command_protocols import ProcessResult from borgitory.protocols.job_output_manager_protocol import JobOutputManagerProtocol from borgitory.protocols.job_database_manager_protocol import JobDatabaseManagerProtocol from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -116,13 +117,68 @@ def task_output_callback(line: str) -> None: ) return True - result = await self.job_executor.execute_cloud_sync_task( - repository_path=str(repository_path or ""), - cloud_sync_config_id=cloud_sync_config_id, - session_maker=self.session_maker, - cloud_sync_service=self.cloud_sync_service, - output_callback=task_output_callback, - ) + timeout_value = params.get("timeout") + timeout_seconds = int(str(timeout_value)) if timeout_value else None + retry_count_value = params.get("retry_count") + retry_count = int(str(retry_count_value)) if retry_count_value else 0 + total_attempts = max(1, retry_count + 1) + + result = None + for attempt in range(1, total_attempts + 1): + if total_attempts > 1: + task_output_callback( + f"Cloud sync attempt {attempt} of {total_attempts} started" + ) + + try: + if timeout_seconds: + result = await asyncio.wait_for( + self.job_executor.execute_cloud_sync_task( + repository_path=str(repository_path or ""), + cloud_sync_config_id=cloud_sync_config_id, + session_maker=self.session_maker, + cloud_sync_service=self.cloud_sync_service, + output_callback=task_output_callback, + ), + timeout=float(timeout_seconds), + ) + else: + result = await self.job_executor.execute_cloud_sync_task( + repository_path=str(repository_path or ""), + cloud_sync_config_id=cloud_sync_config_id, + session_maker=self.session_maker, + cloud_sync_service=self.cloud_sync_service, + output_callback=task_output_callback, + ) + except asyncio.TimeoutError: + timeout_error = ( + f"Cloud sync timed out after {timeout_seconds}s " + f"(attempt {attempt}/{total_attempts})" + ) + task_output_callback(timeout_error) + logger.warning(timeout_error) + if attempt < total_attempts: + continue + result = None + + if result and result.return_code == 0: + break + + if attempt < total_attempts: + task_output_callback( + f"Cloud sync attempt {attempt} failed, retrying..." + ) + + if result is None: + result = ProcessResult( + return_code=124, + stdout=b"", + stderr=b"", + error=( + f"Cloud sync timed out after {timeout_seconds}s " + f"for all {total_attempts} attempt(s)" + ), + ) task.return_code = result.return_code task.status = ( diff --git a/src/borgitory/services/scheduling/schedule_service.py b/src/borgitory/services/scheduling/schedule_service.py index a79bbca1..d44fc7da 100644 --- a/src/borgitory/services/scheduling/schedule_service.py +++ b/src/borgitory/services/scheduling/schedule_service.py @@ -141,10 +141,15 @@ async def create_schedule( source_path: str, cloud_sync_config_id: Optional[int] = None, prune_config_id: Optional[int] = None, + check_config_id: Optional[int] = None, notification_config_id: Optional[int] = None, pre_job_hooks: Optional[str] = None, post_job_hooks: Optional[str] = None, patterns: Optional[str] = None, + backup_timeout_seconds: Optional[int] = None, + backup_retry_count: int = 0, + cloud_sync_timeout_seconds: Optional[int] = None, + cloud_sync_retry_count: int = 0, ) -> ScheduleOperationResult: """ Create a new schedule. @@ -179,10 +184,15 @@ async def create_schedule( db_schedule.enabled = True db_schedule.cloud_sync_config_id = cloud_sync_config_id db_schedule.prune_config_id = prune_config_id + db_schedule.check_config_id = check_config_id db_schedule.notification_config_id = notification_config_id db_schedule.pre_job_hooks = pre_job_hooks db_schedule.post_job_hooks = post_job_hooks db_schedule.patterns = patterns + db_schedule.backup_timeout_seconds = backup_timeout_seconds + db_schedule.backup_retry_count = backup_retry_count + db_schedule.cloud_sync_timeout_seconds = cloud_sync_timeout_seconds + db_schedule.cloud_sync_retry_count = cloud_sync_retry_count db.add(db_schedule) await db.commit() @@ -400,7 +410,7 @@ def validate_schedule_creation_data( try: repository_id = int(repository_id) - except ValueError, TypeError: + except (ValueError, TypeError): return False, {}, "Invalid repository ID" # Validate name @@ -414,7 +424,7 @@ def safe_int(value: Any) -> Optional[int]: return None try: return int(value) - except ValueError, TypeError: + except (ValueError, TypeError): return None # Process hooks and patterns (they come as JSON strings) @@ -436,6 +446,17 @@ def safe_json_string(value: Any) -> Optional[str]: "pre_job_hooks": safe_json_string(json_data.get("pre_job_hooks")), "post_job_hooks": safe_json_string(json_data.get("post_job_hooks")), "patterns": safe_json_string(json_data.get("patterns")), + "backup_timeout_seconds": safe_int( + json_data.get("backup_timeout_seconds") + ), + "backup_retry_count": safe_int(json_data.get("backup_retry_count")) or 0, + "cloud_sync_timeout_seconds": safe_int( + json_data.get("cloud_sync_timeout_seconds") + ), + "cloud_sync_retry_count": safe_int( + json_data.get("cloud_sync_retry_count") + ) + or 0, } return True, processed_data, None diff --git a/src/borgitory/services/scheduling/scheduler_service.py b/src/borgitory/services/scheduling/scheduler_service.py index 31bdf89b..62ff12e3 100644 --- a/src/borgitory/services/scheduling/scheduler_service.py +++ b/src/borgitory/services/scheduling/scheduler_service.py @@ -94,6 +94,10 @@ async def execute_scheduled_backup(schedule_id: int) -> None: pre_job_hooks=schedule.pre_job_hooks, post_job_hooks=schedule.post_job_hooks, patterns=schedule.patterns, + backup_timeout_seconds=schedule.backup_timeout_seconds, + backup_retry_count=schedule.backup_retry_count, + cloud_sync_timeout_seconds=schedule.cloud_sync_timeout_seconds, + cloud_sync_retry_count=schedule.cloud_sync_retry_count, ) backup_result = await job_service.create_backup_job( diff --git a/src/borgitory/services/task_definition_builder.py b/src/borgitory/services/task_definition_builder.py index f18b4538..fa669fef 100644 --- a/src/borgitory/services/task_definition_builder.py +++ b/src/borgitory/services/task_definition_builder.py @@ -43,6 +43,8 @@ def build_backup_task( dry_run: bool = False, ignore_lock: bool = False, patterns: List[str] = [], + timeout: Optional[int] = None, + retry_count: Optional[int] = None, ) -> TaskDefinition: """ Build a backup task definition. @@ -72,6 +74,8 @@ def build_backup_task( type=TaskTypeEnum.BACKUP, name=f"Backup {repository_name}", parameters=parameters, + timeout=timeout, + retry_count=retry_count, ) async def build_prune_task_from_config( @@ -240,6 +244,8 @@ def build_cloud_sync_task( self, repository_name: Optional[str] = None, cloud_sync_config_id: Optional[int] = None, + timeout: Optional[int] = None, + retry_count: Optional[int] = None, ) -> TaskDefinition: """ Build a cloud sync task definition. @@ -261,6 +267,8 @@ def build_cloud_sync_task( parameters={ "cloud_sync_config_id": cloud_sync_config_id, }, + timeout=timeout, + retry_count=retry_count, ) async def build_notification_task( @@ -378,12 +386,16 @@ async def build_task_list( repository_name: str, include_backup: bool = True, backup_params: Optional[ConfigDict] = None, + backup_timeout_seconds: Optional[int] = None, + backup_retry_count: Optional[int] = None, prune_config_id: Optional[int] = None, prune_request: Optional[PruneRequest] = None, check_config_id: Optional[int] = None, check_request: Optional[CheckRequest] = None, include_cloud_sync: bool = False, cloud_sync_config_id: Optional[int] = None, + cloud_sync_timeout_seconds: Optional[int] = None, + cloud_sync_retry_count: Optional[int] = None, notification_config_id: Optional[int] = None, pre_job_hooks: Optional[str] = None, post_job_hooks: Optional[str] = None, @@ -446,6 +458,8 @@ async def build_task_list( dry_run, ignore_lock, patterns, + backup_timeout_seconds, + backup_retry_count, ) ) @@ -478,7 +492,12 @@ async def build_task_list( if include_cloud_sync: tasks.append( - self.build_cloud_sync_task(repository_name, cloud_sync_config_id) + self.build_cloud_sync_task( + repository_name, + cloud_sync_config_id, + cloud_sync_timeout_seconds, + cloud_sync_retry_count, + ) ) if notification_config_id: diff --git a/src/borgitory/templates/partials/schedules/create_form.html b/src/borgitory/templates/partials/schedules/create_form.html index 92abd75c..16afa97b 100644 --- a/src/borgitory/templates/partials/schedules/create_form.html +++ b/src/borgitory/templates/partials/schedules/create_form.html @@ -88,6 +88,52 @@