/** * Component: Admin Jobs Management Page * Documentation: documentation/backend/services/scheduler.md */ 'use client'; import { useState, useEffect } from 'react'; import Link from 'next/link'; import { authenticatedFetcher, fetchJSON } from '@/lib/utils/api'; import { ToastProvider, useToast } from '@/components/ui/Toast'; import { cronToHuman, SCHEDULE_PRESETS, customScheduleToCron, cronToCustomSchedule, isValidCron, type CustomSchedule, } from '@/lib/utils/cron'; interface ScheduledJob { id: string; name: string; type: string; schedule: string; enabled: boolean; lastRun: string | null; nextRun: string | null; } function AdminJobsPageContent() { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [triggering, setTriggering] = useState(null); const [confirmDialog, setConfirmDialog] = useState<{ isOpen: boolean; jobId: string; jobName: string; }>({ isOpen: false, jobId: '', jobName: '' }); const [editDialog, setEditDialog] = useState<{ isOpen: boolean; job: ScheduledJob | null; }>({ isOpen: false, job: null }); const [editForm, setEditForm] = useState({ schedule: '', enabled: true }); const [scheduleMode, setScheduleMode] = useState<'preset' | 'custom' | 'advanced'>('preset'); const [selectedPreset, setSelectedPreset] = useState(''); const [customSchedule, setCustomSchedule] = useState({ type: 'hours', interval: 1 }); const [saving, setSaving] = useState(false); const toast = useToast(); useEffect(() => { fetchJobs(); }, []); const fetchJobs = async () => { try { setLoading(true); const response = await authenticatedFetcher('/api/admin/jobs'); setJobs(response.jobs); setError(null); } catch (err) { setError('Failed to load scheduled jobs'); console.error(err); } finally { setLoading(false); } }; const showConfirmDialog = (jobId: string, jobName: string) => { setConfirmDialog({ isOpen: true, jobId, jobName }); }; const hideConfirmDialog = () => { setConfirmDialog({ isOpen: false, jobId: '', jobName: '' }); }; const showEditDialog = (job: ScheduledJob) => { setEditForm({ schedule: job.schedule, enabled: job.enabled }); const preset = SCHEDULE_PRESETS.find(p => p.cron === job.schedule); if (preset) { setScheduleMode('preset'); setSelectedPreset(preset.cron); } else { const parsed = cronToCustomSchedule(job.schedule); if (parsed.type === 'custom') { setScheduleMode('advanced'); } else { setScheduleMode('custom'); setCustomSchedule(parsed); } } setEditDialog({ isOpen: true, job }); }; const hideEditDialog = () => { setEditDialog({ isOpen: false, job: null }); }; const triggerJob = async () => { const { jobId, jobName } = confirmDialog; hideConfirmDialog(); try { setTriggering(jobId); await fetchJSON(`/api/admin/jobs/${jobId}/trigger`, { method: 'POST', }); toast.success(`Job "${jobName}" triggered successfully`); fetchJobs(); } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Failed to trigger job'; toast.error(errorMsg); console.error(err); } finally { setTriggering(null); } }; const saveJobSchedule = async () => { if (!editDialog.job) return; let finalCron: string; if (scheduleMode === 'preset') { finalCron = selectedPreset; } else if (scheduleMode === 'custom') { finalCron = customScheduleToCron(customSchedule); } else { finalCron = editForm.schedule; } if (!isValidCron(finalCron)) { toast.error('Invalid cron expression. Please check your schedule.'); return; } try { setSaving(true); await fetchJSON(`/api/admin/jobs/${editDialog.job.id}`, { method: 'PUT', body: JSON.stringify({ schedule: finalCron, enabled: editForm.enabled, }), }); toast.success(`Job "${editDialog.job.name}" updated successfully`); hideEditDialog(); fetchJobs(); } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Failed to update job'; toast.error(errorMsg); console.error(err); } finally { setSaving(false); } }; if (loading) { return (
); } return (
{/* Header — stacks on mobile, row on sm+ */}

Scheduled Jobs

Manage recurring tasks and automated jobs

Back to Dashboard
{error && (

{error}

)} {/* Jobs — Card layout on mobile, Table on sm+ */}
{jobs.map((job) => (
{/* Card header */}
{job.name}
{job.type}
{job.enabled ? 'Enabled' : 'Disabled'}
{/* Card body */}
Schedule
{cronToHuman(job.schedule)}
{job.schedule}
Last Run
{job.lastRun ? new Date(job.lastRun).toLocaleString() : 'Never'}
{/* Card actions */}
))} {jobs.length === 0 && (

No scheduled jobs found

)}
{/* Jobs Table — hidden on mobile, visible on sm+ */}
{jobs.map((job) => ( ))}
Name Schedule Last Run Status Actions
{job.name}
{job.type}
{cronToHuman(job.schedule)}
{job.schedule}
{job.lastRun ? new Date(job.lastRun).toLocaleString() : 'Never'}
{job.enabled ? 'Enabled' : 'Disabled'}
{jobs.length === 0 && (

No scheduled jobs found

)}
{/* Info Box */}

About Scheduled Jobs

  • Library Scan: Automatically scans your media library for new audiobooks
  • Audible Data Refresh: Caches popular and new release audiobooks from Audible
  • • Trigger jobs manually using the "Trigger Now" button
  • • Schedule format follows cron syntax (minute hour day month weekday)
{/* Confirmation Dialog */} {confirmDialog.isOpen && (

Confirm Job Trigger

Are you sure you want to trigger "{confirmDialog.jobName}" now?

)} {/* Edit Job Dialog */} {editDialog.isOpen && editDialog.job && (
{/* Dialog header */}

Edit Job Schedule

{/* Job Name */}
{/* Schedule Mode Tabs — grid on mobile to avoid overflow */}
{(['preset', 'custom', 'advanced'] as const).map((mode) => ( ))}
{/* Preset Mode */} {scheduleMode === 'preset' && (
{SCHEDULE_PRESETS.map((preset) => ( ))}
)} {/* Custom Mode */} {scheduleMode === 'custom' && (
{(customSchedule.type === 'minutes' || customSchedule.type === 'hours') && (
setCustomSchedule({ ...customSchedule, interval: parseInt(e.target.value, 10) })} className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm" />

Run every {customSchedule.interval || 1} {customSchedule.type}

)} {(customSchedule.type === 'daily' || customSchedule.type === 'weekly' || customSchedule.type === 'monthly') && (
setCustomSchedule({ ...customSchedule, time: { hour: parseInt(e.target.value, 10), minute: customSchedule.time?.minute || 0 }, }) } className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm" />
setCustomSchedule({ ...customSchedule, time: { hour: customSchedule.time?.hour || 0, minute: parseInt(e.target.value, 10) }, }) } className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm" />
)} {customSchedule.type === 'weekly' && (
)} {customSchedule.type === 'monthly' && (
setCustomSchedule({ ...customSchedule, dayOfMonth: parseInt(e.target.value, 10) })} className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm" />
)}
Preview: {cronToHuman(customScheduleToCron(customSchedule))}
{customScheduleToCron(customSchedule)}
)} {/* Advanced Mode */} {scheduleMode === 'advanced' && (
setEditForm({ ...editForm, schedule: e.target.value })} placeholder="0 */6 * * *" className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm" />

Format: minute hour day month weekday

*/15 * * * * = Every 15 minutes
0 */6 * * * = Every 6 hours
0 0 * * * = Daily at midnight
0 0 * * 0 = Weekly on Sunday
{editForm.schedule && (
Preview: {cronToHuman(editForm.schedule)}
)}
)}
{/* Enabled toggle */}
setEditForm({ ...editForm, enabled: e.target.checked })} className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0" />
{/* Dialog footer */}
)}
); } export default function AdminJobsPage() { return ( ); }