Files
ReadMeABook/src/app/admin/jobs/page.tsx
T
kikootwo d70f6c9957 Add Deluge integration; revamp admin Jobs & Logs UI
Introduce Deluge download client service and tests, remove obsolete rdtclient service, and update qbittorrent integration/tests and download-client interfaces/manager. Large UI refactor for admin pages: Jobs and Logs were redesigned to be responsive (mobile card views + desktop tables), improved headers, dialogs, controls, and better status/detail rendering. Also updated DownloadClient components (card, management, modal), organize-files processor, audible-series integration, and related unit tests to align with integration changes. Minor UX and accessibility tweaks, cron handling/validation adjustments, and a few formatting/cleanup fixes throughout.
2026-02-20 20:44:26 -05:00

740 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<ScheduledJob[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [triggering, setTriggering] = useState<string | null>(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<string>('');
const [customSchedule, setCustomSchedule] = useState<CustomSchedule>({ 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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{/* Header — stacks on mobile, row on sm+ */}
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
Scheduled Jobs
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Manage recurring tasks and automated jobs
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-800 dark:text-red-200 text-sm">{error}</p>
</div>
)}
{/* Jobs — Card layout on mobile, Table on sm+ */}
<div className="space-y-3 sm:hidden">
{jobs.map((job) => (
<div
key={job.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Card header */}
<div className="px-4 py-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
{job.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{job.type}
</div>
</div>
<span
className={`flex-shrink-0 mt-0.5 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full ${
job.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{job.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
{/* Card body */}
<div className="px-4 pb-3 space-y-2 border-t border-gray-100 dark:border-gray-700/60 pt-3">
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Schedule
</div>
<div className="text-sm text-gray-900 dark:text-gray-100">
{cronToHuman(job.schedule)}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
{job.schedule}
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Last Run
</div>
<div className="text-sm text-gray-700 dark:text-gray-300">
{job.lastRun ? new Date(job.lastRun).toLocaleString() : 'Never'}
</div>
</div>
</div>
{/* Card actions */}
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/60 flex gap-2">
<button
onClick={() => showEditDialog(job)}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
<button
onClick={() => showConfirmDialog(job.id, job.name)}
disabled={triggering === job.id}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40 text-blue-700 dark:text-blue-400 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{triggering === job.id ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
Running...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Trigger
</>
)}
</button>
</div>
</div>
))}
{jobs.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No scheduled jobs found</p>
</div>
)}
</div>
{/* Jobs Table — hidden on mobile, visible on sm+ */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Schedule
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Last Run
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{jobs.map((job) => (
<tr key={job.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{job.name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{job.type}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 dark:text-gray-100">
{cronToHuman(job.schedule)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono mt-1">
{job.schedule}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500 dark:text-gray-400">
{job.lastRun ? new Date(job.lastRun).toLocaleString() : 'Never'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
job.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{job.enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end gap-2">
<button
onClick={() => showEditDialog(job)}
className="inline-flex items-center gap-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
title="Edit schedule"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit</span>
</button>
<button
onClick={() => showConfirmDialog(job.id, job.name)}
disabled={triggering === job.id}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{triggering === job.id ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<span>Running...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Trigger Now</span>
</>
)}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{jobs.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No scheduled jobs found</p>
</div>
)}
</div>
{/* Info Box */}
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
About Scheduled Jobs
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> <strong>Library Scan:</strong> Automatically scans your media library for new audiobooks</li>
<li> <strong>Audible Data Refresh:</strong> Caches popular and new release audiobooks from Audible</li>
<li> Trigger jobs manually using the &quot;Trigger Now&quot; button</li>
<li> Schedule format follows cron syntax (minute hour day month weekday)</li>
</ul>
</div>
{/* Confirmation Dialog */}
{confirmDialog.isOpen && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl sm:rounded-lg shadow-xl w-full max-w-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Confirm Job Trigger
</h3>
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6">
Are you sure you want to trigger &quot;{confirmDialog.jobName}&quot; now?
</p>
<div className="flex gap-3">
<button
onClick={hideConfirmDialog}
className="flex-1 px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium"
>
Cancel
</button>
<button
onClick={triggerJob}
className="flex-1 px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium"
>
Trigger Job
</button>
</div>
</div>
</div>
)}
{/* Edit Job Dialog */}
{editDialog.isOpen && editDialog.job && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] overflow-y-auto">
{/* Dialog header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 px-5 py-4 border-b border-gray-200 dark:border-gray-700 rounded-t-2xl">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Edit Job Schedule
</h3>
<button
onClick={hideEditDialog}
className="p-2 -mr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Close dialog"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="px-5 py-5 space-y-5">
{/* Job Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Job Name
</label>
<input
type="text"
value={editDialog.job.name}
disabled
className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-600 rounded-lg cursor-not-allowed text-sm"
/>
</div>
{/* Schedule Mode Tabs — grid on mobile to avoid overflow */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Schedule Type
</label>
<div className="grid grid-cols-3 gap-1 p-1 bg-gray-100 dark:bg-gray-700/60 rounded-xl mb-4">
{(['preset', 'custom', 'advanced'] as const).map((mode) => (
<button
key={mode}
onClick={() => setScheduleMode(mode)}
className={`px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
scheduleMode === mode
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
}`}
>
{mode === 'preset' ? 'Common' : mode === 'custom' ? 'Custom' : 'Advanced'}
</button>
))}
</div>
{/* Preset Mode */}
{scheduleMode === 'preset' && (
<div className="space-y-2">
{SCHEDULE_PRESETS.map((preset) => (
<label
key={preset.cron}
className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
<input
type="radio"
name="preset"
value={preset.cron}
checked={selectedPreset === preset.cron}
onChange={(e) => setSelectedPreset(e.target.value)}
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{preset.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{preset.description}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
{preset.cron}
</div>
</div>
</label>
))}
</div>
)}
{/* Custom Mode */}
{scheduleMode === 'custom' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Frequency
</label>
<select
value={customSchedule.type}
onChange={(e) => setCustomSchedule({ ...customSchedule, type: e.target.value as CustomSchedule['type'] })}
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"
>
<option value="minutes">Every X minutes</option>
<option value="hours">Every X hours</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
{(customSchedule.type === 'minutes' || customSchedule.type === 'hours') && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Interval
</label>
<input
type="number"
min="1"
max={customSchedule.type === 'minutes' ? 59 : 23}
value={customSchedule.interval || 1}
onChange={(e) => 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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Run every {customSchedule.interval || 1} {customSchedule.type}
</p>
</div>
)}
{(customSchedule.type === 'daily' || customSchedule.type === 'weekly' || customSchedule.type === 'monthly') && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Hour (023)
</label>
<input
type="number"
min="0"
max="23"
value={customSchedule.time?.hour || 0}
onChange={(e) =>
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Minute (059)
</label>
<input
type="number"
min="0"
max="59"
value={customSchedule.time?.minute || 0}
onChange={(e) =>
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"
/>
</div>
</div>
)}
{customSchedule.type === 'weekly' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Day of Week
</label>
<select
value={customSchedule.dayOfWeek || 0}
onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfWeek: 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"
>
<option value="0">Sunday</option>
<option value="1">Monday</option>
<option value="2">Tuesday</option>
<option value="3">Wednesday</option>
<option value="4">Thursday</option>
<option value="5">Friday</option>
<option value="6">Saturday</option>
</select>
</div>
)}
{customSchedule.type === 'monthly' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Day of Month (131)
</label>
<input
type="number"
min="1"
max="31"
value={customSchedule.dayOfMonth || 1}
onChange={(e) => 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"
/>
</div>
)}
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="text-sm font-medium text-blue-900 dark:text-blue-200">
Preview: {cronToHuman(customScheduleToCron(customSchedule))}
</div>
<div className="text-xs text-blue-700 dark:text-blue-300 font-mono mt-1">
{customScheduleToCron(customSchedule)}
</div>
</div>
</div>
)}
{/* Advanced Mode */}
{scheduleMode === 'advanced' && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Cron Expression
</label>
<input
type="text"
value={editForm.schedule}
onChange={(e) => 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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Format: minute hour day month weekday
</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1 font-mono">
<div>*/15 * * * * = Every 15 minutes</div>
<div>0 */6 * * * = Every 6 hours</div>
<div>0 0 * * * = Daily at midnight</div>
<div>0 0 * * 0 = Weekly on Sunday</div>
</div>
</div>
{editForm.schedule && (
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="text-sm font-medium text-blue-900 dark:text-blue-200">
Preview: {cronToHuman(editForm.schedule)}
</div>
</div>
)}
</div>
)}
</div>
{/* Enabled toggle */}
<div className="flex items-center gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<input
type="checkbox"
id="enabled"
checked={editForm.enabled}
onChange={(e) => 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"
/>
<label htmlFor="enabled" className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
Enable this job
</label>
</div>
</div>
{/* Dialog footer */}
<div className="sticky bottom-0 bg-white dark:bg-gray-800 px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
<button
onClick={hideEditDialog}
disabled={saving}
className="flex-1 sm:flex-none px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={saveJobSchedule}
disabled={saving}
className="flex-1 sm:flex-none px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export default function AdminJobsPage() {
return (
<ToastProvider>
<AdminJobsPageContent />
</ToastProvider>
);
}