mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
feat(scheduling): add cron builder and refactor scheduling components (Closes #165)
Add cron expression support for M3U and EPG refreshes with interactive builder modal and quick reference examples. Refactor backup scheduling to use shared ScheduleInput component for consistency. - New CronBuilder modal with preset buttons and custom field editors - Info popover with common cron expression examples - Shared ScheduleInput component for interval/cron toggle
This commit is contained in:
parent
a05f89ab85
commit
890992a9f9
15 changed files with 1376 additions and 585 deletions
|
|
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Cron scheduling support for M3U and EPG refreshes: Added interactive cron expression builder with preset buttons and custom field editors, plus info popover with common cron examples. Refactored backup scheduling to use shared ScheduleInput component for consistency across all scheduling interfaces. (Closes #165)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Modular mode PostgreSQL/Redis connection checks: Replaced raw Python socket checks with native tools (`pg_isready` for PostgreSQL and `socket.create_connection` for Redis) in modular deployment mode to prevent indefinite hangs in Docker environments with non-standard networking or DNS configurations. Now properly supports IPv4 and IPv6 configurations. (Fixes #952) - Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from django_celery_beat.models import PeriodicTask, CrontabSchedule
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
|
||||
from core.models import CoreSettings
|
||||
from core.scheduling import (
|
||||
create_or_update_periodic_task,
|
||||
delete_periodic_task,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -105,98 +109,25 @@ def _sync_periodic_task() -> None:
|
|||
settings = get_schedule_settings()
|
||||
|
||||
if not settings["enabled"]:
|
||||
# Delete the task if it exists
|
||||
task = PeriodicTask.objects.filter(name=BACKUP_SCHEDULE_TASK_NAME).first()
|
||||
if task:
|
||||
old_crontab = task.crontab
|
||||
task.delete()
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
delete_periodic_task(BACKUP_SCHEDULE_TASK_NAME)
|
||||
logger.info("Backup schedule disabled, removed periodic task")
|
||||
return
|
||||
|
||||
# Get old crontab before creating new one
|
||||
old_crontab = None
|
||||
try:
|
||||
old_task = PeriodicTask.objects.get(name=BACKUP_SCHEDULE_TASK_NAME)
|
||||
old_crontab = old_task.crontab
|
||||
except PeriodicTask.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Check if using cron expression (advanced mode)
|
||||
if settings["cron_expression"]:
|
||||
# Parse cron expression: "minute hour day month weekday"
|
||||
try:
|
||||
parts = settings["cron_expression"].split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError("Cron expression must have 5 parts: minute hour day month weekday")
|
||||
|
||||
minute, hour, day_of_month, month_of_year, day_of_week = parts
|
||||
|
||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
day_of_week=day_of_week,
|
||||
day_of_month=day_of_month,
|
||||
month_of_year=month_of_year,
|
||||
timezone=CoreSettings.get_system_time_zone(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid cron expression '{settings['cron_expression']}': {e}")
|
||||
raise ValueError(f"Invalid cron expression: {e}")
|
||||
cron_expr = settings["cron_expression"]
|
||||
else:
|
||||
# Use simple frequency-based scheduling
|
||||
# Parse time
|
||||
# Build a cron expression from simple frequency settings
|
||||
hour, minute = settings["time"].split(":")
|
||||
|
||||
# Build crontab based on frequency
|
||||
system_tz = CoreSettings.get_system_time_zone()
|
||||
if settings["frequency"] == "daily":
|
||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
day_of_week="*",
|
||||
day_of_month="*",
|
||||
month_of_year="*",
|
||||
timezone=system_tz,
|
||||
)
|
||||
cron_expr = f"{minute} {hour} * * *"
|
||||
else: # weekly
|
||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
day_of_week=str(settings["day_of_week"]),
|
||||
day_of_month="*",
|
||||
month_of_year="*",
|
||||
timezone=system_tz,
|
||||
)
|
||||
cron_expr = f"{minute} {hour} * * {settings['day_of_week']}"
|
||||
|
||||
# Create or update the periodic task
|
||||
task, created = PeriodicTask.objects.update_or_create(
|
||||
name=BACKUP_SCHEDULE_TASK_NAME,
|
||||
defaults={
|
||||
"task": "apps.backups.tasks.scheduled_backup_task",
|
||||
"crontab": crontab,
|
||||
"enabled": True,
|
||||
"kwargs": json.dumps({"retention_count": settings["retention_count"]}),
|
||||
},
|
||||
create_or_update_periodic_task(
|
||||
task_name=BACKUP_SCHEDULE_TASK_NAME,
|
||||
celery_task_path="apps.backups.tasks.scheduled_backup_task",
|
||||
kwargs={"retention_count": settings["retention_count"]},
|
||||
cron_expression=cron_expr,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
# Clean up old crontab if it changed and is orphaned
|
||||
if old_crontab and old_crontab.id != crontab.id:
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
logger.info(f"{action} backup schedule: {settings['frequency']} at {settings['time']}")
|
||||
|
||||
|
||||
def _cleanup_orphaned_crontab(crontab_schedule):
|
||||
"""Delete old CrontabSchedule if no other tasks are using it."""
|
||||
if crontab_schedule is None:
|
||||
return
|
||||
|
||||
# Check if any other tasks are using this crontab
|
||||
if PeriodicTask.objects.filter(crontab=crontab_schedule).exists():
|
||||
logger.debug(f"CrontabSchedule {crontab_schedule.id} still in use, not deleting")
|
||||
return
|
||||
|
||||
logger.debug(f"Cleaning up orphaned CrontabSchedule: {crontab_schedule.id}")
|
||||
crontab_schedule.delete()
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
API endpoint that allows EPG sources to be viewed or edited.
|
||||
"""
|
||||
|
||||
queryset = EPGSource.objects.all()
|
||||
queryset = EPGSource.objects.select_related(
|
||||
"refresh_task__crontab", "refresh_task__interval"
|
||||
).all()
|
||||
serializer_class = EPGSourceSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
allow_null=True,
|
||||
validators=[validate_flexible_url]
|
||||
)
|
||||
cron_expression = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
|
||||
class Meta:
|
||||
model = EPGSource
|
||||
|
|
@ -24,6 +25,7 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
'is_active',
|
||||
'file_path',
|
||||
'refresh_interval',
|
||||
'cron_expression',
|
||||
'priority',
|
||||
'status',
|
||||
'last_message',
|
||||
|
|
@ -37,6 +39,34 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
"""Return the count of EPG data entries instead of all IDs to prevent large payloads"""
|
||||
return obj.epgs.count()
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
# Derive cron_expression from the linked PeriodicTask's crontab (single source of truth)
|
||||
# But first check if we have a transient _cron_expression (from create/update before signal runs)
|
||||
cron_expr = ''
|
||||
if hasattr(instance, '_cron_expression'):
|
||||
cron_expr = instance._cron_expression
|
||||
elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
|
||||
ct = instance.refresh_task.crontab
|
||||
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
|
||||
data['cron_expression'] = cron_expr
|
||||
return data
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
cron_expr = validated_data.pop('cron_expression', '')
|
||||
instance._cron_expression = cron_expr
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def create(self, validated_data):
|
||||
cron_expr = validated_data.pop('cron_expression', '')
|
||||
instance = EPGSource(**validated_data)
|
||||
instance._cron_expression = cron_expr
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
class ProgramDataSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ProgramData
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from django.db.models.signals import post_save, post_delete, pre_save
|
|||
from django.dispatch import receiver
|
||||
from .models import EPGSource, EPGData
|
||||
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id
|
||||
from django_celery_beat.models import PeriodicTask, IntervalSchedule
|
||||
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
|
||||
from core.utils import is_protected_path, send_websocket_update
|
||||
import json
|
||||
import logging
|
||||
|
|
@ -74,6 +74,7 @@ def create_or_update_refresh_task(sender, instance, **kwargs):
|
|||
"""
|
||||
Create or update a Celery Beat periodic task when an EPGSource is created/updated.
|
||||
Skip creating tasks for dummy EPG sources as they don't need refreshing.
|
||||
Supports both interval-based and cron-based scheduling via the shared utility.
|
||||
"""
|
||||
# Skip task creation for dummy EPGs
|
||||
if instance.source_type == 'dummy':
|
||||
|
|
@ -84,38 +85,23 @@ def create_or_update_refresh_task(sender, instance, **kwargs):
|
|||
return
|
||||
|
||||
task_name = f"epg_source-refresh-{instance.id}"
|
||||
interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=int(instance.refresh_interval),
|
||||
period=IntervalSchedule.HOURS
|
||||
should_be_enabled = instance.is_active
|
||||
|
||||
# Read cron_expression from transient attribute set by the serializer
|
||||
cron_expr = getattr(instance, "_cron_expression", "")
|
||||
|
||||
task = create_or_update_periodic_task(
|
||||
task_name=task_name,
|
||||
celery_task_path="apps.epg.tasks.refresh_epg_data",
|
||||
kwargs={"source_id": instance.id},
|
||||
interval_hours=int(instance.refresh_interval),
|
||||
cron_expression=cron_expr,
|
||||
enabled=should_be_enabled,
|
||||
)
|
||||
|
||||
task, created = PeriodicTask.objects.get_or_create(name=task_name, defaults={
|
||||
"interval": interval,
|
||||
"task": "apps.epg.tasks.refresh_epg_data",
|
||||
"kwargs": json.dumps({"source_id": instance.id}),
|
||||
"enabled": instance.refresh_interval != 0 and instance.is_active,
|
||||
})
|
||||
|
||||
update_fields = []
|
||||
if created:
|
||||
task.interval = interval
|
||||
|
||||
if task.interval != interval:
|
||||
task.interval = interval
|
||||
update_fields.append("interval")
|
||||
|
||||
# Check both refresh_interval and is_active to determine if task should be enabled
|
||||
should_be_enabled = instance.refresh_interval != 0 and instance.is_active
|
||||
if task.enabled != should_be_enabled:
|
||||
task.enabled = should_be_enabled
|
||||
update_fields.append("enabled")
|
||||
|
||||
if update_fields:
|
||||
task.save(update_fields=update_fields)
|
||||
|
||||
if instance.refresh_task != task:
|
||||
instance.refresh_task = task
|
||||
instance.save(update_fields=["refresh_task"]) # Fixed field name
|
||||
instance.save(update_fields=["refresh_task"])
|
||||
|
||||
@receiver(post_delete, sender=EPGSource)
|
||||
def delete_refresh_task(sender, instance, **kwargs):
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ import json
|
|||
class M3UAccountViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for M3U accounts"""
|
||||
|
||||
queryset = M3UAccount.objects.prefetch_related("channel_group")
|
||||
queryset = M3UAccount.objects.select_related(
|
||||
"refresh_task__crontab", "refresh_task__interval"
|
||||
).prefetch_related("channel_group")
|
||||
serializer_class = M3UAccountSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
auto_enable_new_groups_live = serializers.BooleanField(required=False, write_only=True)
|
||||
auto_enable_new_groups_vod = serializers.BooleanField(required=False, write_only=True)
|
||||
auto_enable_new_groups_series = serializers.BooleanField(required=False, write_only=True)
|
||||
cron_expression = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
|
||||
class Meta:
|
||||
model = M3UAccount
|
||||
|
|
@ -158,6 +159,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
"locked",
|
||||
"channel_groups",
|
||||
"refresh_interval",
|
||||
"cron_expression",
|
||||
"custom_properties",
|
||||
"account_type",
|
||||
"username",
|
||||
|
|
@ -188,9 +190,23 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
data["auto_enable_new_groups_live"] = custom_props.get("auto_enable_new_groups_live", True)
|
||||
data["auto_enable_new_groups_vod"] = custom_props.get("auto_enable_new_groups_vod", True)
|
||||
data["auto_enable_new_groups_series"] = custom_props.get("auto_enable_new_groups_series", True)
|
||||
|
||||
# Derive cron_expression from the linked PeriodicTask's crontab (single source of truth)
|
||||
# But first check if we have a transient _cron_expression (from create/update before signal runs)
|
||||
cron_expr = ""
|
||||
if hasattr(instance, '_cron_expression'):
|
||||
cron_expr = instance._cron_expression
|
||||
elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
|
||||
ct = instance.refresh_task.crontab
|
||||
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
|
||||
data["cron_expression"] = cron_expr
|
||||
return data
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Pop cron_expression before it reaches model fields
|
||||
cron_expr = validated_data.pop("cron_expression", "")
|
||||
instance._cron_expression = cron_expr
|
||||
|
||||
# Handle enable_vod preference and auto_enable_new_groups settings
|
||||
enable_vod = validated_data.pop("enable_vod", None)
|
||||
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", None)
|
||||
|
|
@ -244,6 +260,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
return instance
|
||||
|
||||
def create(self, validated_data):
|
||||
# Pop cron_expression — it's not a model field
|
||||
cron_expr = validated_data.pop("cron_expression", "")
|
||||
|
||||
# Handle enable_vod preference and auto_enable_new_groups settings during creation
|
||||
enable_vod = validated_data.pop("enable_vod", False)
|
||||
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", True)
|
||||
|
|
@ -260,7 +279,11 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
custom_props["auto_enable_new_groups_series"] = auto_enable_new_groups_series
|
||||
validated_data["custom_properties"] = custom_props
|
||||
|
||||
return super().create(validated_data)
|
||||
# Build instance manually so we can attach transient attr before save triggers signal
|
||||
instance = M3UAccount(**validated_data)
|
||||
instance._cron_expression = cron_expr
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def get_filters(self, obj):
|
||||
filters = obj.filters.order_by("order")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from django.db.models.signals import post_save, post_delete, pre_save
|
|||
from django.dispatch import receiver
|
||||
from .models import M3UAccount
|
||||
from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id
|
||||
from django_celery_beat.models import PeriodicTask, IntervalSchedule
|
||||
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
|
||||
import json
|
||||
import logging
|
||||
|
||||
|
|
@ -23,48 +23,26 @@ def refresh_account_on_save(sender, instance, created, **kwargs):
|
|||
def create_or_update_refresh_task(sender, instance, **kwargs):
|
||||
"""
|
||||
Create or update a Celery Beat periodic task when an M3UAccount is created/updated.
|
||||
Supports both interval-based and cron-based scheduling via the shared utility.
|
||||
"""
|
||||
task_name = f"m3u_account-refresh-{instance.id}"
|
||||
should_be_enabled = instance.is_active
|
||||
|
||||
interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=int(instance.refresh_interval),
|
||||
period=IntervalSchedule.HOURS
|
||||
# Read cron_expression from transient attribute set by the serializer
|
||||
cron_expr = getattr(instance, "_cron_expression", "")
|
||||
|
||||
task = create_or_update_periodic_task(
|
||||
task_name=task_name,
|
||||
celery_task_path="apps.m3u.tasks.refresh_single_m3u_account",
|
||||
kwargs={"account_id": instance.id},
|
||||
interval_hours=int(instance.refresh_interval),
|
||||
cron_expression=cron_expr,
|
||||
enabled=should_be_enabled,
|
||||
)
|
||||
|
||||
# Task should be enabled only if refresh_interval != 0 AND account is active
|
||||
should_be_enabled = (instance.refresh_interval != 0) and instance.is_active
|
||||
|
||||
# First check if the task already exists to avoid validation errors
|
||||
try:
|
||||
task = PeriodicTask.objects.get(name=task_name)
|
||||
# Task exists, just update it
|
||||
updated_fields = []
|
||||
|
||||
if task.enabled != should_be_enabled:
|
||||
task.enabled = should_be_enabled
|
||||
updated_fields.append("enabled")
|
||||
|
||||
if task.interval != interval:
|
||||
task.interval = interval
|
||||
updated_fields.append("interval")
|
||||
|
||||
if updated_fields:
|
||||
task.save(update_fields=updated_fields)
|
||||
|
||||
# Ensure instance has the task
|
||||
if instance.refresh_task_id != task.id:
|
||||
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
|
||||
|
||||
except PeriodicTask.DoesNotExist:
|
||||
# Create new task if it doesn't exist
|
||||
refresh_task = PeriodicTask.objects.create(
|
||||
name=task_name,
|
||||
interval=interval,
|
||||
task="apps.m3u.tasks.refresh_single_m3u_account",
|
||||
kwargs=json.dumps({"account_id": instance.id}),
|
||||
enabled=should_be_enabled,
|
||||
)
|
||||
M3UAccount.objects.filter(id=instance.id).update(refresh_task=refresh_task)
|
||||
# Ensure instance has the task linked
|
||||
if instance.refresh_task_id != task.id:
|
||||
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
|
||||
|
||||
@receiver(post_delete, sender=M3UAccount)
|
||||
def delete_refresh_task(sender, instance, **kwargs):
|
||||
|
|
|
|||
203
core/scheduling.py
Normal file
203
core/scheduling.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""
|
||||
Reusable scheduling utilities for creating/updating/deleting
|
||||
Celery Beat periodic tasks with interval or cron-based schedules.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django_celery_beat.models import CrontabSchedule, IntervalSchedule, PeriodicTask
|
||||
|
||||
from core.models import CoreSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_cron_expression(cron_expression):
|
||||
"""
|
||||
Parse a 5-part cron expression into its components.
|
||||
|
||||
Args:
|
||||
cron_expression: A string like "0 3 * * *"
|
||||
|
||||
Returns:
|
||||
dict with keys: minute, hour, day_of_month, month_of_year, day_of_week
|
||||
|
||||
Raises:
|
||||
ValueError: If the expression is not valid 5-part cron.
|
||||
"""
|
||||
parts = cron_expression.strip().split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError(
|
||||
"Cron expression must have 5 parts: minute hour day month weekday"
|
||||
)
|
||||
return {
|
||||
"minute": parts[0],
|
||||
"hour": parts[1],
|
||||
"day_of_month": parts[2],
|
||||
"month_of_year": parts[3],
|
||||
"day_of_week": parts[4],
|
||||
}
|
||||
|
||||
|
||||
def create_or_update_periodic_task(
|
||||
task_name,
|
||||
celery_task_path,
|
||||
kwargs=None,
|
||||
interval_hours=0,
|
||||
cron_expression="",
|
||||
enabled=True,
|
||||
):
|
||||
"""
|
||||
Create or update a Celery Beat PeriodicTask. Supports both interval
|
||||
(hours) and cron-based scheduling.
|
||||
|
||||
When *cron_expression* is provided and non-empty it takes precedence
|
||||
over *interval_hours*. An interval_hours of 0 (with no cron) means
|
||||
the task is disabled.
|
||||
|
||||
Args:
|
||||
task_name: Unique PeriodicTask name.
|
||||
celery_task_path: Dotted path to the Celery task function.
|
||||
kwargs: dict of keyword arguments passed to the task.
|
||||
interval_hours: Interval in hours (0 = disabled when no cron).
|
||||
cron_expression: 5-part cron string (empty = use interval).
|
||||
enabled: Whether the task should be enabled.
|
||||
|
||||
Returns:
|
||||
The PeriodicTask instance (created or updated).
|
||||
"""
|
||||
task_kwargs = json.dumps(kwargs or {})
|
||||
|
||||
# Determine effective enabled state
|
||||
use_cron = bool(cron_expression and cron_expression.strip())
|
||||
should_be_enabled = enabled and (use_cron or interval_hours > 0)
|
||||
|
||||
# Retrieve existing task (if any) to track old schedule objects
|
||||
old_interval = None
|
||||
old_crontab = None
|
||||
try:
|
||||
existing = PeriodicTask.objects.get(name=task_name)
|
||||
old_interval = existing.interval
|
||||
old_crontab = existing.crontab
|
||||
except PeriodicTask.DoesNotExist:
|
||||
existing = None
|
||||
|
||||
if use_cron:
|
||||
# ---- Cron-based schedule ----
|
||||
cron_parts = parse_cron_expression(cron_expression)
|
||||
system_tz = CoreSettings.get_system_time_zone()
|
||||
|
||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=cron_parts["minute"],
|
||||
hour=cron_parts["hour"],
|
||||
day_of_week=cron_parts["day_of_week"],
|
||||
day_of_month=cron_parts["day_of_month"],
|
||||
month_of_year=cron_parts["month_of_year"],
|
||||
timezone=system_tz,
|
||||
)
|
||||
|
||||
defaults = {
|
||||
"task": celery_task_path,
|
||||
"crontab": crontab,
|
||||
"interval": None,
|
||||
"enabled": should_be_enabled,
|
||||
"kwargs": task_kwargs,
|
||||
}
|
||||
|
||||
task, created = PeriodicTask.objects.update_or_create(
|
||||
name=task_name, defaults=defaults
|
||||
)
|
||||
|
||||
# Clean up old interval if we switched from interval → cron
|
||||
if old_interval:
|
||||
_cleanup_orphaned_interval(old_interval)
|
||||
# Clean up old crontab if it changed
|
||||
if old_crontab and old_crontab.id != crontab.id:
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
|
||||
else:
|
||||
# ---- Interval-based schedule ----
|
||||
interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=max(int(interval_hours), 1) if interval_hours else 1,
|
||||
period=IntervalSchedule.HOURS,
|
||||
)
|
||||
|
||||
defaults = {
|
||||
"task": celery_task_path,
|
||||
"interval": interval,
|
||||
"crontab": None,
|
||||
"enabled": should_be_enabled,
|
||||
"kwargs": task_kwargs,
|
||||
}
|
||||
|
||||
task, created = PeriodicTask.objects.update_or_create(
|
||||
name=task_name, defaults=defaults
|
||||
)
|
||||
|
||||
# Clean up old crontab if we switched from cron → interval
|
||||
if old_crontab:
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
# Clean up old interval if it changed
|
||||
if old_interval and old_interval.id != interval.id:
|
||||
_cleanup_orphaned_interval(old_interval)
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
mode = "cron" if use_cron else "interval"
|
||||
logger.info(f"{action} periodic task '{task_name}' ({mode}, enabled={should_be_enabled})")
|
||||
return task
|
||||
|
||||
|
||||
def delete_periodic_task(task_name):
|
||||
"""
|
||||
Delete a PeriodicTask by name and clean up orphaned schedules.
|
||||
|
||||
Args:
|
||||
task_name: The unique name of the PeriodicTask.
|
||||
|
||||
Returns:
|
||||
True if a task was found and deleted, False otherwise.
|
||||
"""
|
||||
try:
|
||||
task = PeriodicTask.objects.get(name=task_name)
|
||||
except PeriodicTask.DoesNotExist:
|
||||
logger.warning(f"No PeriodicTask found with name '{task_name}'")
|
||||
return False
|
||||
|
||||
old_interval = task.interval
|
||||
old_crontab = task.crontab
|
||||
task_id = task.id
|
||||
|
||||
task.delete()
|
||||
logger.info(f"Deleted periodic task '{task_name}' (id={task_id})")
|
||||
|
||||
if old_interval:
|
||||
_cleanup_orphaned_interval(old_interval)
|
||||
if old_crontab:
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cleanup_orphaned_interval(interval_schedule):
|
||||
"""Delete an IntervalSchedule if no PeriodicTasks reference it."""
|
||||
if interval_schedule is None:
|
||||
return
|
||||
if PeriodicTask.objects.filter(interval=interval_schedule).exists():
|
||||
return
|
||||
logger.debug(f"Cleaning up orphaned IntervalSchedule {interval_schedule.id}")
|
||||
interval_schedule.delete()
|
||||
|
||||
|
||||
def _cleanup_orphaned_crontab(crontab_schedule):
|
||||
"""Delete a CrontabSchedule if no PeriodicTasks reference it."""
|
||||
if crontab_schedule is None:
|
||||
return
|
||||
if PeriodicTask.objects.filter(crontab=crontab_schedule).exists():
|
||||
return
|
||||
logger.debug(f"Cleaning up orphaned CrontabSchedule {crontab_schedule.id}")
|
||||
crontab_schedule.delete()
|
||||
|
|
@ -33,6 +33,8 @@ import ConfirmationDialog from '../ConfirmationDialog';
|
|||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
import { CustomTable, useTable } from '../tables/CustomTable';
|
||||
import { validateCronExpression } from '../../utils/cronUtils';
|
||||
import ScheduleInput from '../forms/ScheduleInput';
|
||||
|
||||
const RowActions = ({
|
||||
row,
|
||||
|
|
@ -113,95 +115,6 @@ function getDefaultTimeZone() {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate cron expression
|
||||
function validateCronExpression(expression) {
|
||||
if (!expression || expression.trim() === '') {
|
||||
return { valid: false, error: 'Cron expression is required' };
|
||||
}
|
||||
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'Cron expression must have exactly 5 parts: minute hour day month weekday',
|
||||
};
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
|
||||
// Validate each part (allowing *, */N steps, ranges, lists, steps)
|
||||
// Supports: *, */2, 5, 1-5, 1-5/2, 1,3,5, etc.
|
||||
const cronPartRegex =
|
||||
/^(\*\/\d+|\*|\d+(-\d+)?(\/\d+)?(,\d+(-\d+)?(\/\d+)?)*)$/;
|
||||
|
||||
if (!cronPartRegex.test(minute)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid minute field (0-59, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
if (!cronPartRegex.test(hour)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid hour field (0-23, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
if (!cronPartRegex.test(dayOfMonth)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid day field (1-31, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
if (!cronPartRegex.test(month)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid month field (1-12, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
if (!cronPartRegex.test(dayOfWeek)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid weekday field (0-6, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
|
||||
// Additional range validation for numeric values
|
||||
const validateRange = (value, min, max, name) => {
|
||||
// Skip if it's * or contains special characters
|
||||
if (
|
||||
value === '*' ||
|
||||
value.includes('/') ||
|
||||
value.includes('-') ||
|
||||
value.includes(',')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < min || num > max) {
|
||||
return `${name} must be between ${min} and ${max}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const minuteError = validateRange(minute, 0, 59, 'Minute');
|
||||
if (minuteError) return { valid: false, error: minuteError };
|
||||
|
||||
const hourError = validateRange(hour, 0, 23, 'Hour');
|
||||
if (hourError) return { valid: false, error: hourError };
|
||||
|
||||
const dayError = validateRange(dayOfMonth, 1, 31, 'Day');
|
||||
if (dayError) return { valid: false, error: dayError };
|
||||
|
||||
const monthError = validateRange(month, 1, 12, 'Month');
|
||||
if (monthError) return { valid: false, error: monthError };
|
||||
|
||||
const weekdayError = validateRange(dayOfWeek, 0, 6, 'Weekday');
|
||||
if (weekdayError) return { valid: false, error: weekdayError };
|
||||
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: '0', label: 'Sunday' },
|
||||
{ value: '1', label: 'Monday' },
|
||||
|
|
@ -262,8 +175,7 @@ export default function BackupManager() {
|
|||
const [scheduleLoading, setScheduleLoading] = useState(false);
|
||||
const [scheduleSaving, setScheduleSaving] = useState(false);
|
||||
const [scheduleChanged, setScheduleChanged] = useState(false);
|
||||
const [advancedMode, setAdvancedMode] = useState(false);
|
||||
const [cronError, setCronError] = useState(null);
|
||||
const [scheduleType, setScheduleType] = useState('interval');
|
||||
|
||||
// For 12-hour display mode
|
||||
const [displayTime, setDisplayTime] = useState('3:00');
|
||||
|
|
@ -373,12 +285,8 @@ export default function BackupManager() {
|
|||
try {
|
||||
const settings = await API.getBackupSchedule();
|
||||
|
||||
// Check if using cron expression (advanced mode)
|
||||
if (settings.cron_expression) {
|
||||
setAdvancedMode(true);
|
||||
}
|
||||
|
||||
setSchedule(settings);
|
||||
setScheduleType(settings.cron_expression ? 'cron' : 'interval');
|
||||
|
||||
// Initialize 12-hour display values
|
||||
const { time, period } = to12Hour(settings.time);
|
||||
|
|
@ -398,25 +306,9 @@ export default function BackupManager() {
|
|||
loadSchedule();
|
||||
}, []);
|
||||
|
||||
// Validate cron expression when switching to advanced mode
|
||||
useEffect(() => {
|
||||
if (advancedMode && schedule.cron_expression) {
|
||||
const validation = validateCronExpression(schedule.cron_expression);
|
||||
setCronError(validation.valid ? null : validation.error);
|
||||
} else {
|
||||
setCronError(null);
|
||||
}
|
||||
}, [advancedMode, schedule.cron_expression]);
|
||||
|
||||
const handleScheduleChange = (field, value) => {
|
||||
setSchedule((prev) => ({ ...prev, [field]: value }));
|
||||
setScheduleChanged(true);
|
||||
|
||||
// Validate cron expression if in advanced mode
|
||||
if (field === 'cron_expression' && advancedMode) {
|
||||
const validation = validateCronExpression(value);
|
||||
setCronError(validation.valid ? null : validation.error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle time changes in 12-hour mode
|
||||
|
|
@ -442,9 +334,11 @@ export default function BackupManager() {
|
|||
const handleSaveSchedule = async () => {
|
||||
setScheduleSaving(true);
|
||||
try {
|
||||
const scheduleToSave = advancedMode
|
||||
? schedule
|
||||
: { ...schedule, cron_expression: '' };
|
||||
// Clear cron_expression if not in cron mode
|
||||
const scheduleToSave =
|
||||
scheduleType === 'cron'
|
||||
? schedule
|
||||
: { ...schedule, cron_expression: '' };
|
||||
|
||||
const updated = await API.updateBackupSchedule(scheduleToSave);
|
||||
setSchedule(updated);
|
||||
|
|
@ -603,207 +497,161 @@ export default function BackupManager() {
|
|||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={500}>
|
||||
Advanced (Cron Expression)
|
||||
</Text>
|
||||
<Switch
|
||||
checked={advancedMode}
|
||||
onChange={(e) => setAdvancedMode(e.currentTarget.checked)}
|
||||
label={advancedMode ? 'Enabled' : 'Disabled'}
|
||||
disabled={!schedule.enabled}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
<ScheduleInput
|
||||
scheduleType={scheduleType}
|
||||
onScheduleTypeChange={(type) => {
|
||||
setScheduleType(type);
|
||||
if (type !== 'cron') {
|
||||
handleScheduleChange('cron_expression', '');
|
||||
}
|
||||
}}
|
||||
cronValue={schedule.cron_expression}
|
||||
onCronChange={(expr) => handleScheduleChange('cron_expression', expr)}
|
||||
disabled={!schedule.enabled}
|
||||
switchToCronLabel="Use custom cron schedule"
|
||||
switchToIntervalLabel="Use simple schedule"
|
||||
>
|
||||
{/* Simple mode: frequency / time / day selectors */}
|
||||
<Stack gap="sm">
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
label="Frequency"
|
||||
value={schedule.frequency}
|
||||
onChange={(value) => handleScheduleChange('frequency', value)}
|
||||
data={[
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
]}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
{schedule.frequency === 'weekly' && (
|
||||
<Select
|
||||
label="Day"
|
||||
value={String(schedule.day_of_week)}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('day_of_week', parseInt(value, 10))
|
||||
}
|
||||
data={DAYS_OF_WEEK}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
)}
|
||||
{is12Hour ? (
|
||||
<>
|
||||
<Select
|
||||
label="Hour"
|
||||
value={displayTime ? displayTime.split(':')[0] : '12'}
|
||||
onChange={(value) => {
|
||||
const minute = displayTime
|
||||
? displayTime.split(':')[1]
|
||||
: '00';
|
||||
handleTimeChange12h(`${value}:${minute}`, null);
|
||||
}}
|
||||
data={Array.from({ length: 12 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: String(i + 1),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Minute"
|
||||
value={displayTime ? displayTime.split(':')[1] : '00'}
|
||||
onChange={(value) => {
|
||||
const hour = displayTime
|
||||
? displayTime.split(':')[0]
|
||||
: '12';
|
||||
handleTimeChange12h(`${hour}:${value}`, null);
|
||||
}}
|
||||
data={Array.from({ length: 60 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Period"
|
||||
value={timePeriod}
|
||||
onChange={(value) => handleTimeChange12h(null, value)}
|
||||
data={[
|
||||
{ value: 'AM', label: 'AM' },
|
||||
{ value: 'PM', label: 'PM' },
|
||||
]}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
label="Hour"
|
||||
value={schedule.time ? schedule.time.split(':')[0] : '00'}
|
||||
onChange={(value) => {
|
||||
const minute = schedule.time
|
||||
? schedule.time.split(':')[1]
|
||||
: '00';
|
||||
handleTimeChange24h(`${value}:${minute}`);
|
||||
}}
|
||||
data={Array.from({ length: 24 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Minute"
|
||||
value={schedule.time ? schedule.time.split(':')[1] : '00'}
|
||||
onChange={(value) => {
|
||||
const hour = schedule.time
|
||||
? schedule.time.split(':')[0]
|
||||
: '00';
|
||||
handleTimeChange24h(`${hour}:${value}`);
|
||||
}}
|
||||
data={Array.from({ length: 60 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</ScheduleInput>
|
||||
|
||||
{scheduleLoading ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<>
|
||||
{advancedMode ? (
|
||||
<>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Cron Expression"
|
||||
value={schedule.cron_expression}
|
||||
onChange={(e) =>
|
||||
handleScheduleChange(
|
||||
'cron_expression',
|
||||
e.currentTarget.value
|
||||
)
|
||||
}
|
||||
placeholder="0 3 * * *"
|
||||
description="Format: minute hour day month weekday (e.g., '0 3 * * *' = 3:00 AM daily)"
|
||||
disabled={!schedule.enabled}
|
||||
error={cronError}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
Examples: <br />• <code>0 3 * * *</code> - Every day at 3:00
|
||||
AM
|
||||
<br />• <code>0 2 * * 0</code> - Every Sunday at 2:00 AM
|
||||
<br />• <code>0 */6 * * *</code> - Every 6 hours
|
||||
<br />• <code>30 14 1 * *</code> - 1st of every month at
|
||||
2:30 PM
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group grow align="flex-end">
|
||||
<NumberInput
|
||||
label="Retention"
|
||||
description="0 = keep all"
|
||||
value={schedule.retention_count}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('retention_count', value || 0)
|
||||
}
|
||||
min={0}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveSchedule}
|
||||
loading={scheduleSaving}
|
||||
disabled={!scheduleChanged || (advancedMode && cronError)}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
label="Frequency"
|
||||
value={schedule.frequency}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('frequency', value)
|
||||
}
|
||||
data={[
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
]}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
{schedule.frequency === 'weekly' && (
|
||||
<Select
|
||||
label="Day"
|
||||
value={String(schedule.day_of_week)}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('day_of_week', parseInt(value, 10))
|
||||
}
|
||||
data={DAYS_OF_WEEK}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
)}
|
||||
{is12Hour ? (
|
||||
<>
|
||||
<Select
|
||||
label="Hour"
|
||||
value={displayTime ? displayTime.split(':')[0] : '12'}
|
||||
onChange={(value) => {
|
||||
const minute = displayTime
|
||||
? displayTime.split(':')[1]
|
||||
: '00';
|
||||
handleTimeChange12h(`${value}:${minute}`, null);
|
||||
}}
|
||||
data={Array.from({ length: 12 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: String(i + 1),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Minute"
|
||||
value={displayTime ? displayTime.split(':')[1] : '00'}
|
||||
onChange={(value) => {
|
||||
const hour = displayTime
|
||||
? displayTime.split(':')[0]
|
||||
: '12';
|
||||
handleTimeChange12h(`${hour}:${value}`, null);
|
||||
}}
|
||||
data={Array.from({ length: 60 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Period"
|
||||
value={timePeriod}
|
||||
onChange={(value) => handleTimeChange12h(null, value)}
|
||||
data={[
|
||||
{ value: 'AM', label: 'AM' },
|
||||
{ value: 'PM', label: 'PM' },
|
||||
]}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
label="Hour"
|
||||
value={
|
||||
schedule.time ? schedule.time.split(':')[0] : '00'
|
||||
}
|
||||
onChange={(value) => {
|
||||
const minute = schedule.time
|
||||
? schedule.time.split(':')[1]
|
||||
: '00';
|
||||
handleTimeChange24h(`${value}:${minute}`);
|
||||
}}
|
||||
data={Array.from({ length: 24 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Minute"
|
||||
value={
|
||||
schedule.time ? schedule.time.split(':')[1] : '00'
|
||||
}
|
||||
onChange={(value) => {
|
||||
const hour = schedule.time
|
||||
? schedule.time.split(':')[0]
|
||||
: '00';
|
||||
handleTimeChange24h(`${hour}:${value}`);
|
||||
}}
|
||||
data={Array.from({ length: 60 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group grow align="flex-end" gap="xs">
|
||||
<NumberInput
|
||||
label="Retention"
|
||||
description="0 = keep all"
|
||||
value={schedule.retention_count}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('retention_count', value || 0)
|
||||
}
|
||||
min={0}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveSchedule}
|
||||
loading={scheduleSaving}
|
||||
disabled={!scheduleChanged}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
<Group grow align="flex-end" gap="xs">
|
||||
<NumberInput
|
||||
label="Retention"
|
||||
description="0 = keep all"
|
||||
value={schedule.retention_count}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('retention_count', value || 0)
|
||||
}
|
||||
min={0}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveSchedule}
|
||||
loading={scheduleSaving}
|
||||
disabled={
|
||||
!scheduleChanged ||
|
||||
(scheduleType === 'cron' &&
|
||||
schedule.cron_expression &&
|
||||
!validateCronExpression(schedule.cron_expression).valid)
|
||||
}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Timezone info - only show in simple mode */}
|
||||
{!advancedMode && schedule.enabled && schedule.time && (
|
||||
{scheduleType !== 'cron' && schedule.enabled && schedule.time && (
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
System Timezone: {userTimezone} • Backup will run at{' '}
|
||||
{schedule.time} {userTimezone}
|
||||
|
|
|
|||
432
frontend/src/components/forms/CronBuilder.jsx
Normal file
432
frontend/src/components/forms/CronBuilder.jsx
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
/**
|
||||
* Cron Expression Builder Modal
|
||||
*
|
||||
* Provides an easy interface to build cron expressions with:
|
||||
* - Quick preset buttons for common schedules
|
||||
* - Simple hour/minute/day selectors
|
||||
* - Preview of next run times
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Select,
|
||||
NumberInput,
|
||||
Text,
|
||||
Badge,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
TextInput,
|
||||
Paper,
|
||||
Tabs,
|
||||
Code,
|
||||
} from '@mantine/core';
|
||||
import { Clock, Calendar } from 'lucide-react';
|
||||
|
||||
const PRESETS = [
|
||||
{
|
||||
label: 'Every hour',
|
||||
value: '0 * * * *',
|
||||
description: 'At the start of every hour',
|
||||
},
|
||||
{
|
||||
label: 'Every 6 hours',
|
||||
value: '0 */6 * * *',
|
||||
description: 'Every 6 hours starting at midnight',
|
||||
},
|
||||
{
|
||||
label: 'Every 12 hours',
|
||||
value: '0 */12 * * *',
|
||||
description: 'Twice daily at midnight and noon',
|
||||
},
|
||||
{
|
||||
label: 'Daily at midnight',
|
||||
value: '0 0 * * *',
|
||||
description: 'Once per day at 12:00 AM',
|
||||
},
|
||||
{
|
||||
label: 'Daily at 3 AM',
|
||||
value: '0 3 * * *',
|
||||
description: 'Once per day at 3:00 AM',
|
||||
},
|
||||
{
|
||||
label: 'Daily at noon',
|
||||
value: '0 12 * * *',
|
||||
description: 'Once per day at 12:00 PM',
|
||||
},
|
||||
{
|
||||
label: 'Weekly (Sunday midnight)',
|
||||
value: '0 0 * * 0',
|
||||
description: 'Once per week on Sunday',
|
||||
},
|
||||
{
|
||||
label: 'Weekly (Monday 3 AM)',
|
||||
value: '0 3 * * 1',
|
||||
description: 'Once per week on Monday',
|
||||
},
|
||||
{
|
||||
label: 'Monthly (1st at 2:30 AM)',
|
||||
value: '30 2 1 * *',
|
||||
description: 'First day of each month',
|
||||
},
|
||||
];
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: '*', label: 'Every day' },
|
||||
{ value: '0', label: 'Sunday' },
|
||||
{ value: '1', label: 'Monday' },
|
||||
{ value: '2', label: 'Tuesday' },
|
||||
{ value: '3', label: 'Wednesday' },
|
||||
{ value: '4', label: 'Thursday' },
|
||||
{ value: '5', label: 'Friday' },
|
||||
{ value: '6', label: 'Saturday' },
|
||||
];
|
||||
|
||||
const FREQUENCY_OPTIONS = [
|
||||
{ value: 'hourly', label: 'Hourly' },
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
];
|
||||
|
||||
export default function CronBuilder({
|
||||
opened,
|
||||
onClose,
|
||||
onApply,
|
||||
currentValue = '',
|
||||
}) {
|
||||
const [mode, setMode] = useState('simple'); // 'simple' or 'advanced'
|
||||
const [frequency, setFrequency] = useState('daily');
|
||||
const [hour, setHour] = useState(3);
|
||||
const [minute, setMinute] = useState(0);
|
||||
const [dayOfWeek, setDayOfWeek] = useState('*');
|
||||
const [dayOfMonth, setDayOfMonth] = useState(1);
|
||||
const [generatedCron, setGeneratedCron] = useState('0 3 * * *');
|
||||
const [manualCron, setManualCron] = useState('* * * * *');
|
||||
|
||||
// Initialize manualCron from currentValue when modal opens
|
||||
useEffect(() => {
|
||||
if (opened && currentValue) {
|
||||
setManualCron(currentValue);
|
||||
}
|
||||
}, [opened, currentValue]);
|
||||
|
||||
// Update generated cron when inputs change
|
||||
useEffect(() => {
|
||||
let cron = '';
|
||||
switch (frequency) {
|
||||
case 'hourly':
|
||||
cron = `${minute} * * * *`;
|
||||
break;
|
||||
case 'daily':
|
||||
cron = `${minute} ${hour} * * *`;
|
||||
break;
|
||||
case 'weekly':
|
||||
cron = `${minute} ${hour} * * ${dayOfWeek === '*' ? '0' : dayOfWeek}`;
|
||||
break;
|
||||
case 'monthly':
|
||||
cron = `${minute} ${hour} ${dayOfMonth} * *`;
|
||||
break;
|
||||
}
|
||||
setGeneratedCron(cron);
|
||||
}, [frequency, hour, minute, dayOfWeek, dayOfMonth]);
|
||||
|
||||
const handlePresetClick = (cron) => {
|
||||
setGeneratedCron(cron);
|
||||
setManualCron(cron);
|
||||
|
||||
// Parse the cron expression and update form fields
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length === 5) {
|
||||
const [min, hr, day, _month, weekday] = parts;
|
||||
|
||||
setMinute(parseInt(min) || 0);
|
||||
|
||||
// Determine frequency based on pattern
|
||||
if (hr === '*') {
|
||||
setFrequency('hourly');
|
||||
} else if (day !== '*' && day !== '1') {
|
||||
// Has specific day of month
|
||||
setFrequency('monthly');
|
||||
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
|
||||
setDayOfMonth(parseInt(day) || 1);
|
||||
} else if (weekday !== '*') {
|
||||
// Has specific day of week
|
||||
setFrequency('weekly');
|
||||
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
|
||||
setDayOfWeek(weekday);
|
||||
} else if (day === '1') {
|
||||
// Monthly on 1st
|
||||
setFrequency('monthly');
|
||||
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
|
||||
setDayOfMonth(1);
|
||||
} else {
|
||||
// Daily
|
||||
setFrequency('daily');
|
||||
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const cronToApply = mode === 'advanced' ? manualCron : generatedCron;
|
||||
onApply(cronToApply);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Cron Expression Builder"
|
||||
size="xl"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Tabs value={mode} onChange={setMode}>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="simple">Simple</Tabs.Tab>
|
||||
<Tabs.Tab value="advanced">Advanced</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="simple" pt="md">
|
||||
<Stack gap="md">
|
||||
{/* Quick Presets */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Quick Presets
|
||||
</Text>
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
{PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.value}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => handlePresetClick(preset.value)}
|
||||
style={{
|
||||
height: '75px',
|
||||
padding: '8px',
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
inner: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
label: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={500} mb={2}>
|
||||
{preset.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{preset.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="dot"
|
||||
color="gray"
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
}}
|
||||
>
|
||||
{preset.value}
|
||||
</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
<Divider label="OR Build Custom" labelPosition="center" />
|
||||
|
||||
{/* Custom Builder */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Custom Schedule
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Select
|
||||
label="Frequency"
|
||||
data={FREQUENCY_OPTIONS}
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
leftSection={<Calendar size={16} />}
|
||||
/>
|
||||
|
||||
{frequency !== 'hourly' && (
|
||||
<NumberInput
|
||||
label="Hour (0-23)"
|
||||
value={hour}
|
||||
onChange={setHour}
|
||||
min={0}
|
||||
max={23}
|
||||
leftSection={<Clock size={16} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
label="Minute (0-59)"
|
||||
value={minute}
|
||||
onChange={setMinute}
|
||||
min={0}
|
||||
max={59}
|
||||
leftSection={<Clock size={16} />}
|
||||
/>
|
||||
|
||||
{frequency === 'weekly' && (
|
||||
<Select
|
||||
label="Day of Week"
|
||||
data={DAYS_OF_WEEK}
|
||||
value={dayOfWeek}
|
||||
onChange={setDayOfWeek}
|
||||
/>
|
||||
)}
|
||||
|
||||
{frequency === 'monthly' && (
|
||||
<NumberInput
|
||||
label="Day of Month (1-31)"
|
||||
value={dayOfMonth}
|
||||
onChange={setDayOfMonth}
|
||||
min={1}
|
||||
max={31}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="advanced" pt="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
Build advanced cron expressions with comma-separated values
|
||||
(e.g., <Code>2,4,16</Code>), ranges (e.g., <Code>9-17</Code>),
|
||||
or steps (e.g., <Code>*/15</Code>).
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput
|
||||
label="Minute (0-59)"
|
||||
placeholder="*, 0, */15, 0,15,30,45"
|
||||
value={manualCron.split(' ')[0] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[0] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Hour (0-23)"
|
||||
placeholder="*, 0, 9-17, */6, 2,4,16"
|
||||
value={manualCron.split(' ')[1] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[1] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Day of Month (1-31)"
|
||||
placeholder="*, 1, 1-15, */2, 1,15"
|
||||
value={manualCron.split(' ')[2] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[2] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Month (1-12)"
|
||||
placeholder="*, 1, 1-6, */3, 6,12"
|
||||
value={manualCron.split(' ')[3] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[3] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Day of Week (0-6, Sun-Sat)"
|
||||
placeholder="*, 0, 1-5, 0,6"
|
||||
value={manualCron.split(' ')[4] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[4] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Examples: <Code>0 4,10,16 * * *</Code> at 4 AM, 10 AM, and 4 PM
|
||||
• <Code>0 9-17 * * 1-5</Code> hourly 9 AM-5 PM Mon-Fri
|
||||
• <Code>*/15 * * * *</Code> every 15 minutes
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* Generated Expression */}
|
||||
<Paper withBorder p="md" bg="dark.6">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Expression:
|
||||
</Text>
|
||||
<Badge size="lg" variant="filled" color="blue">
|
||||
{mode === 'advanced' ? manualCron : generatedCron}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Actions */}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleApply}>Apply Expression</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,9 +16,11 @@ import {
|
|||
} from '@mantine/core';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import ScheduleInput from './ScheduleInput';
|
||||
|
||||
const EPG = ({ epg = null, isOpen, onClose }) => {
|
||||
const [sourceType, setSourceType] = useState('xmltv');
|
||||
const [scheduleType, setScheduleType] = useState('interval');
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
|
|
@ -29,6 +31,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
api_key: '',
|
||||
is_active: true,
|
||||
refresh_interval: 24,
|
||||
cron_expression: '',
|
||||
priority: 0,
|
||||
},
|
||||
|
||||
|
|
@ -41,6 +44,17 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
|
||||
// Determine which schedule type is active based on field values
|
||||
const hasCronExpression =
|
||||
values.cron_expression && values.cron_expression.trim() !== '';
|
||||
|
||||
// Clear the field that isn't active based on actual field values
|
||||
if (hasCronExpression) {
|
||||
values.refresh_interval = 0;
|
||||
} else {
|
||||
values.cron_expression = '';
|
||||
}
|
||||
|
||||
if (epg?.id) {
|
||||
// Validate that we have a valid EPG object before updating
|
||||
if (!epg || typeof epg !== 'object' || !epg.id) {
|
||||
|
|
@ -70,13 +84,21 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
api_key: epg.api_key,
|
||||
is_active: epg.is_active,
|
||||
refresh_interval: epg.refresh_interval,
|
||||
cron_expression: epg.cron_expression || '',
|
||||
priority: epg.priority ?? 0,
|
||||
};
|
||||
form.setValues(values);
|
||||
setSourceType(epg.source_type);
|
||||
// Determine schedule type from existing data - check both fields
|
||||
setScheduleType(
|
||||
epg.cron_expression && epg.cron_expression.trim() !== ''
|
||||
? 'cron'
|
||||
: 'interval'
|
||||
);
|
||||
} else {
|
||||
form.reset();
|
||||
setSourceType('xmltv');
|
||||
setScheduleType('interval');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [epg]);
|
||||
|
|
@ -92,127 +114,136 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="EPG Source" size={700}>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
{/* Left Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
description="Unique identifier for this EPG source"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
|
||||
<NativeSelect
|
||||
id="source_type"
|
||||
name="source_type"
|
||||
label="Source Type"
|
||||
description="Format of the EPG data source"
|
||||
{...form.getInputProps('source_type')}
|
||||
key={form.key('source_type')}
|
||||
data={[
|
||||
{
|
||||
label: 'XMLTV',
|
||||
value: 'xmltv',
|
||||
},
|
||||
{
|
||||
label: 'Schedules Direct',
|
||||
value: 'schedules_direct',
|
||||
},
|
||||
]}
|
||||
onChange={(event) =>
|
||||
handleSourceTypeChange(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Refresh Interval (hours)"
|
||||
description="How often to refresh EPG data (0 to disable)"
|
||||
{...form.getInputProps('refresh_interval')}
|
||||
key={form.key('refresh_interval')}
|
||||
min={0}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
{/* Right Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="url"
|
||||
name="url"
|
||||
label="URL"
|
||||
description="Direct URL to the XMLTV file or API endpoint"
|
||||
{...form.getInputProps('url')}
|
||||
key={form.key('url')}
|
||||
/>
|
||||
|
||||
{sourceType === 'schedules_direct' && (
|
||||
<>
|
||||
<Modal opened={isOpen} onClose={onClose} title="EPG Source" size={700}>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
{/* Left Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
description="API key for services that require authentication"
|
||||
{...form.getInputProps('api_key')}
|
||||
key={form.key('api_key')}
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
description="Unique identifier for this EPG source"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={999}
|
||||
label="Priority"
|
||||
description="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel."
|
||||
{...form.getInputProps('priority')}
|
||||
key={form.key('priority')}
|
||||
/>
|
||||
<NativeSelect
|
||||
id="source_type"
|
||||
name="source_type"
|
||||
label="Source Type"
|
||||
description="Format of the EPG data source"
|
||||
{...form.getInputProps('source_type')}
|
||||
key={form.key('source_type')}
|
||||
data={[
|
||||
{
|
||||
label: 'XMLTV',
|
||||
value: 'xmltv',
|
||||
},
|
||||
{
|
||||
label: 'Schedules Direct',
|
||||
value: 'schedules_direct',
|
||||
},
|
||||
]}
|
||||
onChange={(event) =>
|
||||
handleSourceTypeChange(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Put checkbox at the same level as Refresh Interval */}
|
||||
<Box style={{ marginTop: 0 }}>
|
||||
<Text size="sm" fw={500} mb={3}>
|
||||
Status
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={12}>
|
||||
When enabled, this EPG source will auto update.
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '30px',
|
||||
marginTop: '-4px',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
label="Enable this EPG source"
|
||||
{...form.getInputProps('is_active', { type: 'checkbox' })}
|
||||
key={form.key('is_active')}
|
||||
<ScheduleInput
|
||||
scheduleType={scheduleType}
|
||||
onScheduleTypeChange={setScheduleType}
|
||||
intervalValue={form.getValues().refresh_interval}
|
||||
onIntervalChange={(v) =>
|
||||
form.setFieldValue('refresh_interval', v)
|
||||
}
|
||||
cronValue={form.getValues().cron_expression}
|
||||
onCronChange={(expr) =>
|
||||
form.setFieldValue('cron_expression', expr)
|
||||
}
|
||||
intervalLabel="Refresh Interval (hours)"
|
||||
intervalDescription="How often to refresh EPG data (0 to disable)"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
{/* Right Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="url"
|
||||
name="url"
|
||||
label="URL"
|
||||
description="Direct URL to the XMLTV file or API endpoint"
|
||||
{...form.getInputProps('url')}
|
||||
key={form.key('url')}
|
||||
/>
|
||||
|
||||
{sourceType === 'schedules_direct' && (
|
||||
<TextInput
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
description="API key for services that require authentication"
|
||||
{...form.getInputProps('api_key')}
|
||||
key={form.key('api_key')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={999}
|
||||
label="Priority"
|
||||
description="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel."
|
||||
{...form.getInputProps('priority')}
|
||||
key={form.key('priority')}
|
||||
/>
|
||||
|
||||
{/* Put checkbox at the same level as Refresh Interval */}
|
||||
<Box style={{ marginTop: 0 }}>
|
||||
<Text size="sm" fw={500} mb={3}>
|
||||
Status
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={12}>
|
||||
When enabled, this EPG source will auto update.
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '30px',
|
||||
marginTop: '-4px',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
label="Enable this EPG source"
|
||||
{...form.getInputProps('is_active', { type: 'checkbox' })}
|
||||
key={form.key('is_active')}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{/* Full Width Section */}
|
||||
<Box mt="md">
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="end" mt="xl">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="filled" disabled={form.submitting}>
|
||||
{epg?.id ? 'Update' : 'Create'} EPG Source
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Full Width Section */}
|
||||
<Box mt="md">
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="end" mt="xl">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="filled" disabled={form.submitting}>
|
||||
{epg?.id ? 'Update' : 'Create'} EPG Source
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { isNotEmpty, useForm } from '@mantine/form';
|
|||
import useEPGsStore from '../../store/epgs';
|
||||
import useVODStore from '../../store/useVODStore';
|
||||
import M3UFilters from './M3UFilters';
|
||||
import ScheduleInput from './ScheduleInput';
|
||||
|
||||
const M3U = ({
|
||||
m3uAccount = null,
|
||||
|
|
@ -49,6 +50,7 @@ const M3U = ({
|
|||
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
||||
const [loadingText, setLoadingText] = useState('');
|
||||
const [showCredentialFields, setShowCredentialFields] = useState(false);
|
||||
const [scheduleType, setScheduleType] = useState('interval');
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
|
|
@ -59,6 +61,7 @@ const M3U = ({
|
|||
is_active: true,
|
||||
max_streams: 0,
|
||||
refresh_interval: 24,
|
||||
cron_expression: '',
|
||||
account_type: 'XC',
|
||||
create_epg: false,
|
||||
username: '',
|
||||
|
|
@ -71,12 +74,10 @@ const M3U = ({
|
|||
validate: {
|
||||
name: isNotEmpty('Please select a name'),
|
||||
user_agent: isNotEmpty('Please select a user-agent'),
|
||||
refresh_interval: isNotEmpty('Please specify a refresh interval'),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log(m3uAccount);
|
||||
if (m3uAccount) {
|
||||
setPlaylist(m3uAccount);
|
||||
form.setValues({
|
||||
|
|
@ -86,6 +87,7 @@ const M3U = ({
|
|||
user_agent: m3uAccount.user_agent ? `${m3uAccount.user_agent}` : '0',
|
||||
is_active: m3uAccount.is_active,
|
||||
refresh_interval: m3uAccount.refresh_interval,
|
||||
cron_expression: m3uAccount.cron_expression || '',
|
||||
account_type: m3uAccount.account_type,
|
||||
username: m3uAccount.username ?? '',
|
||||
password: '',
|
||||
|
|
@ -101,6 +103,13 @@ const M3U = ({
|
|||
enable_vod: m3uAccount.enable_vod || false,
|
||||
});
|
||||
|
||||
// Determine schedule type from existing data
|
||||
setScheduleType(
|
||||
m3uAccount.cron_expression && m3uAccount.cron_expression.trim() !== ''
|
||||
? 'cron'
|
||||
: 'interval'
|
||||
);
|
||||
|
||||
if (m3uAccount.account_type == 'XC') {
|
||||
setShowCredentialFields(true);
|
||||
} else {
|
||||
|
|
@ -109,6 +118,7 @@ const M3U = ({
|
|||
} else {
|
||||
setPlaylist(null);
|
||||
form.reset();
|
||||
setScheduleType('interval');
|
||||
}
|
||||
}, [m3uAccount]);
|
||||
|
||||
|
|
@ -121,6 +131,17 @@ const M3U = ({
|
|||
const onSubmit = async () => {
|
||||
const { create_epg, ...values } = form.getValues();
|
||||
|
||||
// Determine which schedule type is active based on field values
|
||||
const hasCronExpression =
|
||||
values.cron_expression && values.cron_expression.trim() !== '';
|
||||
|
||||
// Clear the field that isn't active based on actual field values
|
||||
if (hasCronExpression) {
|
||||
values.refresh_interval = 0;
|
||||
} else {
|
||||
values.cron_expression = '';
|
||||
}
|
||||
|
||||
if (values.account_type == 'XC' && values.password == '') {
|
||||
// If account XC and no password input, assuming no password change
|
||||
// from previously stored value.
|
||||
|
|
@ -377,17 +398,25 @@ const M3U = ({
|
|||
)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Refresh Interval (hours)"
|
||||
description={
|
||||
<ScheduleInput
|
||||
scheduleType={scheduleType}
|
||||
onScheduleTypeChange={setScheduleType}
|
||||
intervalValue={form.getValues().refresh_interval}
|
||||
onIntervalChange={(v) =>
|
||||
form.setFieldValue('refresh_interval', v)
|
||||
}
|
||||
cronValue={form.getValues().cron_expression}
|
||||
onCronChange={(expr) =>
|
||||
form.setFieldValue('cron_expression', expr)
|
||||
}
|
||||
intervalLabel="Refresh Interval (hours)"
|
||||
intervalDescription={
|
||||
<>
|
||||
How often to automatically refresh M3U data
|
||||
<br />
|
||||
(0 to disable automatic refreshes)
|
||||
</>
|
||||
}
|
||||
{...form.getInputProps('refresh_interval')}
|
||||
key={form.key('refresh_interval')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
|
|
|
|||
229
frontend/src/components/forms/ScheduleInput.jsx
Normal file
229
frontend/src/components/forms/ScheduleInput.jsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Reusable Schedule Input
|
||||
*
|
||||
* Shows the active scheduling mode with a subtle text link to switch.
|
||||
* Interval mode is the default; a small "Use cron schedule" link beneath
|
||||
* toggles to cron mode, and vice-versa.
|
||||
*
|
||||
* For M3U / EPG (default interval NumberInput):
|
||||
* <ScheduleInput
|
||||
* scheduleType={scheduleType}
|
||||
* onScheduleTypeChange={setScheduleType}
|
||||
* intervalValue={form.getValues().refresh_interval}
|
||||
* onIntervalChange={(v) => form.setFieldValue('refresh_interval', v)}
|
||||
* cronValue={form.getValues().cron_expression}
|
||||
* onCronChange={(v) => form.setFieldValue('cron_expression', v)}
|
||||
* />
|
||||
*
|
||||
* For Backups (custom simple-mode UI via children):
|
||||
* <ScheduleInput
|
||||
* scheduleType={scheduleType}
|
||||
* onScheduleTypeChange={setScheduleType}
|
||||
* cronValue={schedule.cron_expression}
|
||||
* onCronChange={(v) => handleScheduleChange('cron_expression', v)}
|
||||
* switchToCronLabel="Use custom cron schedule"
|
||||
* switchToIntervalLabel="Use simple schedule"
|
||||
* >
|
||||
* ...frequency / time / day selectors...
|
||||
* </ScheduleInput>
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Anchor,
|
||||
Stack,
|
||||
Text,
|
||||
Code,
|
||||
Popover,
|
||||
ActionIcon,
|
||||
Group,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
} from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
import { validateCronExpression } from '../../utils/cronUtils';
|
||||
import CronBuilder from './CronBuilder';
|
||||
|
||||
export default function ScheduleInput({
|
||||
// Schedule type
|
||||
scheduleType = 'interval',
|
||||
onScheduleTypeChange,
|
||||
|
||||
// Cron
|
||||
cronValue = '',
|
||||
onCronChange,
|
||||
|
||||
// Default interval input (used when children not provided)
|
||||
intervalValue = 0,
|
||||
onIntervalChange,
|
||||
intervalLabel = 'Refresh Interval (hours)',
|
||||
intervalDescription = 'How often to refresh (0 to disable)',
|
||||
min = 0,
|
||||
|
||||
// Custom simple-mode content (replaces the default NumberInput)
|
||||
children,
|
||||
|
||||
// Link text for toggling
|
||||
switchToCronLabel = 'Use cron schedule',
|
||||
switchToIntervalLabel = 'Use interval schedule',
|
||||
|
||||
disabled = false,
|
||||
}) {
|
||||
const [cronError, setCronError] = useState(null);
|
||||
const [builderOpened, setBuilderOpened] = useState(false);
|
||||
|
||||
// Validate cron whenever it changes
|
||||
useEffect(() => {
|
||||
if (scheduleType === 'cron' && cronValue) {
|
||||
const v = validateCronExpression(cronValue);
|
||||
setCronError(v.valid ? null : v.error);
|
||||
} else {
|
||||
setCronError(null);
|
||||
}
|
||||
}, [scheduleType, cronValue]);
|
||||
|
||||
const switchToCron = (e) => {
|
||||
e.preventDefault();
|
||||
onScheduleTypeChange('cron');
|
||||
};
|
||||
|
||||
const switchToInterval = (e) => {
|
||||
e.preventDefault();
|
||||
onScheduleTypeChange('interval');
|
||||
onCronChange('');
|
||||
setCronError(null);
|
||||
};
|
||||
|
||||
const handleCronChange = (val) => {
|
||||
onCronChange(val);
|
||||
if (val) {
|
||||
const v = validateCronExpression(val);
|
||||
setCronError(v.valid ? null : v.error);
|
||||
} else {
|
||||
setCronError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuilderApply = (cron) => {
|
||||
handleCronChange(cron);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{scheduleType === 'cron' ? (
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
Cron Expression
|
||||
<Popover width={320} position="top" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<ActionIcon variant="subtle" size="xs" color="gray">
|
||||
<Info size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="sm">
|
||||
<Text size="xs" fw={600} mb="xs" c="dimmed">
|
||||
COMMON EXAMPLES
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
Every day at 3 AM:
|
||||
</Text>
|
||||
<Code size="xs">0 3 * * *</Code>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
At 4 AM, 10 AM, 4 PM:
|
||||
</Text>
|
||||
<Code size="xs">0 4,10,16 * * *</Code>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
Sundays at 2 AM:
|
||||
</Text>
|
||||
<Code size="xs">0 2 * * 0</Code>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
1st of month at 2:30 PM:
|
||||
</Text>
|
||||
<Code size="xs">30 14 1 * *</Code>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
}
|
||||
placeholder="0 3 * * *"
|
||||
description="minute hour day month weekday"
|
||||
value={cronValue}
|
||||
onChange={(e) => handleCronChange(e.currentTarget.value)}
|
||||
error={cronError}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{!disabled && (
|
||||
<Group gap="sm">
|
||||
<Anchor size="xs" onClick={switchToInterval}>
|
||||
{switchToIntervalLabel}
|
||||
</Anchor>
|
||||
<Anchor size="xs" onClick={() => setBuilderOpened(true)}>
|
||||
Open Cron Builder
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
<CronBuilder
|
||||
opened={builderOpened}
|
||||
onClose={() => setBuilderOpened(false)}
|
||||
onApply={handleBuilderApply}
|
||||
currentValue={cronValue}
|
||||
/>
|
||||
</Stack>
|
||||
) : children ? (
|
||||
<Stack gap="xs">
|
||||
{children}
|
||||
{!disabled && (
|
||||
<Anchor size="xs" onClick={switchToCron}>
|
||||
{switchToCronLabel}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<NumberInput
|
||||
label={intervalLabel}
|
||||
description={intervalDescription}
|
||||
value={intervalValue}
|
||||
onChange={onIntervalChange}
|
||||
min={min}
|
||||
disabled={disabled}
|
||||
suffix=" hours"
|
||||
/>
|
||||
{!disabled && (
|
||||
<Anchor size="xs" onClick={switchToCron}>
|
||||
{switchToCronLabel}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
63
frontend/src/utils/cronUtils.js
Normal file
63
frontend/src/utils/cronUtils.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Cron expression validation utility.
|
||||
*
|
||||
* Shared across CronModal, BackupManager, and any other component
|
||||
* that needs to validate 5-part cron expressions.
|
||||
*/
|
||||
|
||||
export function validateCronExpression(expression) {
|
||||
if (!expression || expression.trim() === '') {
|
||||
return { valid: false, error: 'Cron expression is required' };
|
||||
}
|
||||
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'Cron expression must have exactly 5 parts: minute hour day month weekday',
|
||||
};
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
|
||||
const cronPartRegex =
|
||||
/^(\*\/\d+|\*|\d+(-\d+)?(\/\d+)?(,\d+(-\d+)?(\/\d+)?)*)$/;
|
||||
|
||||
const fields = [
|
||||
{ value: minute, label: 'minute', min: 0, max: 59 },
|
||||
{ value: hour, label: 'hour', min: 0, max: 23 },
|
||||
{ value: dayOfMonth, label: 'day', min: 1, max: 31 },
|
||||
{ value: month, label: 'month', min: 1, max: 12 },
|
||||
{ value: dayOfWeek, label: 'weekday', min: 0, max: 6 },
|
||||
];
|
||||
|
||||
for (const { value, label, min, max } of fields) {
|
||||
if (!cronPartRegex.test(value)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid ${label} field (${min}-${max}, *, or cron syntax)`,
|
||||
};
|
||||
}
|
||||
|
||||
// Extra numeric-range check for plain numbers
|
||||
if (
|
||||
!(
|
||||
value === '*' ||
|
||||
value.includes('/') ||
|
||||
value.includes('-') ||
|
||||
value.includes(',')
|
||||
)
|
||||
) {
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < min || num > max) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${label.charAt(0).toUpperCase() + label.slice(1)} must be between ${min} and ${max}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue