diff --git a/backend/api/management/commands/backup_db.py b/backend/api/management/commands/backup_db.py index 087bda3..55f262e 100644 --- a/backend/api/management/commands/backup_db.py +++ b/backend/api/management/commands/backup_db.py @@ -11,6 +11,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--output', type=str, help='Specific output path for the backup file') parser.add_argument('--retention', type=int, default=7, help='Number of days to keep old backups') + parser.add_argument('--full', action='store_true', help='Create a full archive including media') def handle(self, *args, **options): db_conf = settings.DATABASES['default'] @@ -22,29 +23,53 @@ class Command(BaseCommand): backup_dir = os.path.join(settings.BASE_DIR, 'backups') os.makedirs(backup_dir, exist_ok=True) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + # 1. Database Backup if options['output']: - backup_path = options['output'] + db_backup_path = options['output'] else: - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - backup_path = os.path.join(backup_dir, f'db_backup_{timestamp}.sqlite3') + db_backup_path = os.path.join(backup_dir, f'db_backup_{timestamp}.sqlite3') - self.stdout.write(f"Starting backup of {db_path}...") + self.stdout.write(f"Cloning database {db_path}...") try: - # Use SQLite's online backup API for a consistent copy src = sqlite3.connect(db_path) - dst = sqlite3.connect(backup_path) + dst = sqlite3.connect(db_backup_path) with dst: src.backup(dst) dst.close() src.close() - self.stdout.write(self.style.SUCCESS(f"Successfully backed up database to {backup_path}")) + self.stdout.write(self.style.SUCCESS(f"DB backup created: {db_backup_path}")) except Exception as e: - self.stdout.write(self.style.ERROR(f"Database backup failed: {e}")) + self.stdout.write(self.style.ERROR(f"DB backup failed: {e}")) return - # 2. Cleanup Old Backups + # 2. Full Archive (Optional) + if options['full']: + import tarfile + archive_name = f"full_backup_{timestamp}.tar.gz" + archive_path = os.path.join(backup_dir, archive_name) + self.stdout.write(f"Creating full archive at {archive_path}...") + + try: + with tarfile.open(archive_path, "w:gz") as tar: + # Add database + tar.add(db_backup_path, arcname="db.sqlite3") + # Add media + media_dir = str(settings.MEDIA_ROOT) + if os.path.exists(media_dir): + tar.add(media_dir, arcname="media") + + # Remove the raw sqlite backup after bundling it into the archive + if not options['output']: + os.remove(db_backup_path) + + self.stdout.write(self.style.SUCCESS(f"Full archive created successfully.")) + except Exception as e: + self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}")) + + # 3. Cleanup Old Backups retention_days = options['retention'] cutoff = datetime.now() - timedelta(days=retention_days) diff --git a/backend/api/management/commands/worker.py b/backend/api/management/commands/worker.py index 1b9cc03..0e06a03 100644 --- a/backend/api/management/commands/worker.py +++ b/backend/api/management/commands/worker.py @@ -39,10 +39,18 @@ class Command(BaseCommand): self.stdout.write(f"Executing Job: {job.get_name_display()}...") job.status = 'running' job.last_run = timezone.now() + job.last_output = "" # Reset output for new run job.save() # Capture management command output - out = StringIO() + class LiveStream(StringIO): + def write(self, s): + super().write(s) + # Periodically update the DB with current output + job.last_output = self.getvalue() + job.save(update_fields=['last_output']) + + out = LiveStream() error = "" try: @@ -51,7 +59,7 @@ class Command(BaseCommand): elif job.name == 'cleanup_duplicates': call_command('cleanup_duplicates', stdout=out) elif job.name == 'backup_db': - call_command('backup_db', stdout=out) + call_command('backup_db', '--full', stdout=out) elif job.name == 'seed_legacy': call_command('seed_legacy_flavors', stdout=out) diff --git a/backend/api/views.py b/backend/api/views.py index 67743c0..b523b06 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -912,13 +912,19 @@ class AdminViewSet(viewsets.ViewSet): try: job = Job.objects.get(pk=pk) interval = request.data.get('interval_hours') + next_run = request.data.get('next_run') + if interval is not None: job.interval_hours = int(interval) - # If we just enabled it, set next_run - if job.interval_hours > 0 and not job.next_run: - from django.utils import timezone - job.next_run = timezone.now() - job.save() + + if next_run is not None: + from django.utils.dateparse import parse_datetime + job.next_run = parse_datetime(next_run) + elif interval is not None and job.interval_hours > 0 and not job.next_run: + from django.utils import timezone + job.next_run = timezone.now() + + job.save() return Response(JobSerializer(job).data) except Job.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index aa67c5b..fc54fff 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -37,6 +37,11 @@ python manage.py seed_legacy_flavors echo "Syncing banner configurations..." python manage.py seed_banners +if [ "$#" -gt 0 ]; then + echo "Executing custom command: $@" + exec "$@" +fi + # Start the server using Gunicorn echo "Starting Gunicorn..." exec gunicorn holy_backend.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120 diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index f71469a..d259066 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -100,8 +100,8 @@ const AdminDashboard: React.FC = () => { const [selectedBanner, setSelectedBanner] = useState(null); const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false); - const fetchData = async () => { - setLoading(true); + const fetchData = async (silent = false) => { + if (!silent) setLoading(true); try { const [statsRes, usersRes, bannersRes, jobsRes, configRes] = await Promise.all([ api.get('admin-custom/stats/'), @@ -123,9 +123,18 @@ const AdminDashboard: React.FC = () => { console.error('Data fetch error:', err); } - setLoading(false); + if (!silent) setLoading(false); }; + // Poll for job updates if any are running or pending + useEffect(() => { + const anyRunning = jobs.some(j => j.status === 'running' || j.status === 'pending'); + if (anyRunning && currentTab === 3) { + const interval = setInterval(() => fetchData(true), 3000); + return () => clearInterval(interval); + } + }, [jobs, currentTab]); + const handleUpdateConfig = async (key: string, value: any) => { try { const res = await api.patch('admin-custom/config/', { [key]: value }); @@ -138,17 +147,18 @@ const AdminDashboard: React.FC = () => { const handleTriggerJob = async (id: number) => { try { await api.post(`admin-custom/${id}/trigger_job/`); - alert('Job queued successfully'); - fetchData(); + fetchData(true); } catch (err) { alert('Failed to trigger job'); } }; - const handleUpdateJobSchedule = async (id: number, interval: number) => { + const handleUpdateJobSchedule = async (id: number, interval: number, nextRun?: string) => { try { - await api.patch(`admin-custom/${id}/update_job_schedule/`, { interval_hours: interval }); - fetchData(); + const data: any = { interval_hours: interval }; + if (nextRun) data.next_run = nextRun; + await api.patch(`admin-custom/${id}/update_job_schedule/`, data); + fetchData(true); } catch (err) { alert('Failed to update schedule'); } @@ -417,7 +427,7 @@ const AdminDashboard: React.FC = () => { @@ -485,9 +495,10 @@ const AdminDashboard: React.FC = () => { {job.name_display} @@ -498,7 +509,13 @@ const AdminDashboard: React.FC = () => { Next Run - {job.next_run ? formatDate(job.next_run) : (job.interval_hours > 0 ? 'Pending' : 'Disabled')} + + {job.next_run ? formatDate(job.next_run) : (job.interval_hours > 0 ? 'Queued...' : 'Disabled')} + + Interval (Hours)