590 lines
22 KiB
TypeScript
590 lines
22 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
listSchedules,
|
|
createSchedule,
|
|
deleteSchedule,
|
|
pauseSchedule,
|
|
unpauseSchedule,
|
|
updateSchedule,
|
|
killRunningJob,
|
|
inspectRunningJob,
|
|
ScheduledJob,
|
|
} from '../../schedule';
|
|
import BackButton from '../ui/BackButton';
|
|
import { ScrollArea } from '../ui/scroll-area';
|
|
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
|
import { Card } from '../ui/card';
|
|
import { Button } from '../ui/button';
|
|
import { TrashIcon } from '../icons/TrashIcon';
|
|
import { Plus, RefreshCw, Pause, Play, Edit, Square, Eye } from 'lucide-react';
|
|
import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal';
|
|
import { EditScheduleModal } from './EditScheduleModal';
|
|
import ScheduleDetailView from './ScheduleDetailView';
|
|
import { toastError, toastSuccess } from '../../toasts';
|
|
import cronstrue from 'cronstrue';
|
|
|
|
interface SchedulesViewProps {
|
|
onClose: () => void;
|
|
}
|
|
|
|
const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
|
const [schedules, setSchedules] = useState<ScheduledJob[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [apiError, setApiError] = useState<string | null>(null);
|
|
const [submitApiError, setSubmitApiError] = useState<string | null>(null);
|
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
const [editingSchedule, setEditingSchedule] = useState<ScheduledJob | null>(null);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
|
|
// Individual loading states for each action to prevent double-clicks
|
|
const [pausingScheduleIds, setPausingScheduleIds] = useState<Set<string>>(new Set());
|
|
const [deletingScheduleIds, setDeletingScheduleIds] = useState<Set<string>>(new Set());
|
|
const [killingScheduleIds, setKillingScheduleIds] = useState<Set<string>>(new Set());
|
|
const [inspectingScheduleIds, setInspectingScheduleIds] = useState<Set<string>>(new Set());
|
|
|
|
const [viewingScheduleId, setViewingScheduleId] = useState<string | null>(null);
|
|
|
|
const fetchSchedules = async () => {
|
|
setIsLoading(true);
|
|
setApiError(null);
|
|
try {
|
|
const fetchedSchedules = await listSchedules();
|
|
setSchedules(fetchedSchedules);
|
|
} catch (error) {
|
|
console.error('Failed to fetch schedules:', error);
|
|
setApiError(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'An unknown error occurred while fetching schedules.'
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (viewingScheduleId === null) {
|
|
fetchSchedules();
|
|
|
|
// Check for pending deep link from recipe editor
|
|
const pendingDeepLink = localStorage.getItem('pendingScheduleDeepLink');
|
|
if (pendingDeepLink) {
|
|
localStorage.removeItem('pendingScheduleDeepLink');
|
|
setIsCreateModalOpen(true);
|
|
// The CreateScheduleModal will handle the deep link
|
|
}
|
|
}
|
|
}, [viewingScheduleId]);
|
|
|
|
// Add a periodic refresh for schedules list to keep the running status up to date
|
|
useEffect(() => {
|
|
if (viewingScheduleId !== null) return;
|
|
|
|
// Set up periodic refresh every 10 seconds
|
|
const intervalId = setInterval(() => {
|
|
if (viewingScheduleId === null && !isRefreshing && !isLoading) {
|
|
fetchSchedules();
|
|
}
|
|
}, 10000);
|
|
|
|
// Clean up on unmount
|
|
return () => {
|
|
clearInterval(intervalId);
|
|
};
|
|
}, [viewingScheduleId, isRefreshing, isLoading]);
|
|
|
|
const handleOpenCreateModal = () => {
|
|
setSubmitApiError(null);
|
|
setIsCreateModalOpen(true);
|
|
};
|
|
|
|
const handleRefresh = async () => {
|
|
setIsRefreshing(true);
|
|
try {
|
|
await fetchSchedules();
|
|
} finally {
|
|
setIsRefreshing(false);
|
|
}
|
|
};
|
|
|
|
const handleCloseCreateModal = () => {
|
|
setIsCreateModalOpen(false);
|
|
setSubmitApiError(null);
|
|
};
|
|
|
|
const handleOpenEditModal = (schedule: ScheduledJob) => {
|
|
setEditingSchedule(schedule);
|
|
setSubmitApiError(null);
|
|
setIsEditModalOpen(true);
|
|
};
|
|
|
|
const handleCloseEditModal = () => {
|
|
setIsEditModalOpen(false);
|
|
setEditingSchedule(null);
|
|
setSubmitApiError(null);
|
|
};
|
|
|
|
const handleCreateScheduleSubmit = async (payload: NewSchedulePayload) => {
|
|
setIsSubmitting(true);
|
|
setSubmitApiError(null);
|
|
try {
|
|
await createSchedule(payload);
|
|
await fetchSchedules();
|
|
setIsCreateModalOpen(false);
|
|
} catch (error) {
|
|
console.error('Failed to create schedule:', error);
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : 'Unknown error creating schedule.';
|
|
setSubmitApiError(errorMessage);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleEditScheduleSubmit = async (cron: string) => {
|
|
if (!editingSchedule) return;
|
|
|
|
setIsSubmitting(true);
|
|
setSubmitApiError(null);
|
|
try {
|
|
await updateSchedule(editingSchedule.id, cron);
|
|
toastSuccess({
|
|
title: 'Schedule Updated',
|
|
msg: `Successfully updated schedule "${editingSchedule.id}"`,
|
|
});
|
|
await fetchSchedules();
|
|
setIsEditModalOpen(false);
|
|
setEditingSchedule(null);
|
|
} catch (error) {
|
|
console.error('Failed to update schedule:', error);
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : 'Unknown error updating schedule.';
|
|
setSubmitApiError(errorMessage);
|
|
toastError({
|
|
title: 'Update Schedule Error',
|
|
msg: errorMessage,
|
|
});
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteSchedule = async (idToDelete: string) => {
|
|
if (!window.confirm(`Are you sure you want to delete schedule "${idToDelete}"?`)) return;
|
|
|
|
// Immediately add to deleting set to disable button
|
|
setDeletingScheduleIds((prev) => new Set(prev).add(idToDelete));
|
|
|
|
if (viewingScheduleId === idToDelete) {
|
|
setViewingScheduleId(null);
|
|
}
|
|
setApiError(null);
|
|
try {
|
|
await deleteSchedule(idToDelete);
|
|
await fetchSchedules();
|
|
} catch (error) {
|
|
console.error(`Failed to delete schedule "${idToDelete}":`, error);
|
|
setApiError(
|
|
error instanceof Error ? error.message : `Unknown error deleting "${idToDelete}".`
|
|
);
|
|
} finally {
|
|
// Remove from deleting set
|
|
setDeletingScheduleIds((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(idToDelete);
|
|
return newSet;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handlePauseSchedule = async (idToPause: string) => {
|
|
// Immediately add to pausing set to disable button
|
|
setPausingScheduleIds((prev) => new Set(prev).add(idToPause));
|
|
|
|
setApiError(null);
|
|
try {
|
|
await pauseSchedule(idToPause);
|
|
toastSuccess({
|
|
title: 'Schedule Paused',
|
|
msg: `Successfully paused schedule "${idToPause}"`,
|
|
});
|
|
await fetchSchedules();
|
|
} catch (error) {
|
|
console.error(`Failed to pause schedule "${idToPause}":`, error);
|
|
const errorMsg =
|
|
error instanceof Error ? error.message : `Unknown error pausing "${idToPause}".`;
|
|
setApiError(errorMsg);
|
|
toastError({
|
|
title: 'Pause Schedule Error',
|
|
msg: errorMsg,
|
|
});
|
|
} finally {
|
|
// Remove from pausing set
|
|
setPausingScheduleIds((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(idToPause);
|
|
return newSet;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleUnpauseSchedule = async (idToUnpause: string) => {
|
|
// Immediately add to pausing set to disable button
|
|
setPausingScheduleIds((prev) => new Set(prev).add(idToUnpause));
|
|
|
|
setApiError(null);
|
|
try {
|
|
await unpauseSchedule(idToUnpause);
|
|
toastSuccess({
|
|
title: 'Schedule Unpaused',
|
|
msg: `Successfully unpaused schedule "${idToUnpause}"`,
|
|
});
|
|
await fetchSchedules();
|
|
} catch (error) {
|
|
console.error(`Failed to unpause schedule "${idToUnpause}":`, error);
|
|
const errorMsg =
|
|
error instanceof Error ? error.message : `Unknown error unpausing "${idToUnpause}".`;
|
|
setApiError(errorMsg);
|
|
toastError({
|
|
title: 'Unpause Schedule Error',
|
|
msg: errorMsg,
|
|
});
|
|
} finally {
|
|
// Remove from pausing set
|
|
setPausingScheduleIds((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(idToUnpause);
|
|
return newSet;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleKillRunningJob = async (scheduleId: string) => {
|
|
// Immediately add to killing set to disable button
|
|
setKillingScheduleIds((prev) => new Set(prev).add(scheduleId));
|
|
|
|
setApiError(null);
|
|
try {
|
|
const result = await killRunningJob(scheduleId);
|
|
toastSuccess({
|
|
title: 'Job Killed',
|
|
msg: result.message,
|
|
});
|
|
await fetchSchedules();
|
|
} catch (error) {
|
|
console.error(`Failed to kill running job "${scheduleId}":`, error);
|
|
const errorMsg =
|
|
error instanceof Error ? error.message : `Unknown error killing job "${scheduleId}".`;
|
|
setApiError(errorMsg);
|
|
toastError({
|
|
title: 'Kill Job Error',
|
|
msg: errorMsg,
|
|
});
|
|
} finally {
|
|
// Remove from killing set
|
|
setKillingScheduleIds((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(scheduleId);
|
|
return newSet;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleInspectRunningJob = async (scheduleId: string) => {
|
|
// Immediately add to inspecting set to disable button
|
|
setInspectingScheduleIds((prev) => new Set(prev).add(scheduleId));
|
|
|
|
setApiError(null);
|
|
try {
|
|
const result = await inspectRunningJob(scheduleId);
|
|
if (result.sessionId) {
|
|
const duration = result.runningDurationSeconds
|
|
? `${Math.floor(result.runningDurationSeconds / 60)}m ${result.runningDurationSeconds % 60}s`
|
|
: 'Unknown';
|
|
toastSuccess({
|
|
title: 'Job Inspection',
|
|
msg: `Session: ${result.sessionId}\nRunning for: ${duration}`,
|
|
});
|
|
} else {
|
|
toastSuccess({
|
|
title: 'Job Inspection',
|
|
msg: 'No detailed information available for this job',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to inspect running job "${scheduleId}":`, error);
|
|
const errorMsg =
|
|
error instanceof Error ? error.message : `Unknown error inspecting job "${scheduleId}".`;
|
|
setApiError(errorMsg);
|
|
toastError({
|
|
title: 'Inspect Job Error',
|
|
msg: errorMsg,
|
|
});
|
|
} finally {
|
|
// Remove from inspecting set
|
|
setInspectingScheduleIds((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(scheduleId);
|
|
return newSet;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleNavigateToScheduleDetail = (scheduleId: string) => {
|
|
setViewingScheduleId(scheduleId);
|
|
};
|
|
|
|
const handleNavigateBackFromDetail = () => {
|
|
setViewingScheduleId(null);
|
|
};
|
|
|
|
const getReadableCron = (cronString: string) => {
|
|
try {
|
|
return cronstrue.toString(cronString);
|
|
} catch (e) {
|
|
console.warn(`Could not parse cron string "${cronString}":`, e);
|
|
return cronString;
|
|
}
|
|
};
|
|
|
|
if (viewingScheduleId) {
|
|
return (
|
|
<ScheduleDetailView
|
|
scheduleId={viewingScheduleId}
|
|
onNavigateBack={handleNavigateBackFromDetail}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-screen w-full flex flex-col bg-app text-textStandard">
|
|
<MoreMenuLayout showMenu={false} />
|
|
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0">
|
|
<BackButton onClick={onClose} />
|
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white mt-2">
|
|
Schedules Management
|
|
</h1>
|
|
</div>
|
|
|
|
<ScrollArea className="flex-grow">
|
|
<div className="p-8">
|
|
<div className="flex flex-col md:flex-row gap-2 mb-8">
|
|
<Button
|
|
onClick={handleOpenCreateModal}
|
|
className="w-full md:w-auto flex items-center gap-2 justify-center text-white dark:text-black bg-bgAppInverse hover:bg-bgStandardInverse [&>svg]:!size-4"
|
|
>
|
|
<Plus className="h-4 w-4" /> Create New Schedule
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={handleRefresh}
|
|
disabled={isRefreshing || isLoading}
|
|
variant="outline"
|
|
className="w-full md:w-auto flex items-center gap-2 justify-center"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
|
{isRefreshing ? 'Refreshing...' : 'Refresh'}
|
|
</Button>
|
|
</div>
|
|
|
|
{apiError && (
|
|
<p className="text-red-500 dark:text-red-400 text-sm p-4 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md">
|
|
Error: {apiError}
|
|
</p>
|
|
)}
|
|
|
|
<section>
|
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
|
Existing Schedules
|
|
</h2>
|
|
{isLoading && schedules.length === 0 && (
|
|
<p className="text-gray-500 dark:text-gray-400">Loading schedules...</p>
|
|
)}
|
|
{!isLoading && !apiError && schedules.length === 0 && (
|
|
<p className="text-gray-500 dark:text-gray-400 text-center py-4">
|
|
No schedules found. Create one to get started!
|
|
</p>
|
|
)}
|
|
|
|
{!isLoading && schedules.length > 0 && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{schedules.map((job) => (
|
|
<Card
|
|
key={job.id}
|
|
className="p-4 bg-white dark:bg-gray-800 shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
|
onClick={() => handleNavigateToScheduleDetail(job.id)}
|
|
>
|
|
<div className="flex justify-between items-start">
|
|
<div className="flex-grow mr-2 overflow-hidden">
|
|
<h3
|
|
className="text-base font-semibold text-gray-900 dark:text-white truncate"
|
|
title={job.id}
|
|
>
|
|
{job.id}
|
|
</h3>
|
|
<p
|
|
className="text-xs text-gray-500 dark:text-gray-400 mt-1 break-all"
|
|
title={job.source}
|
|
>
|
|
Source: {job.source}
|
|
</p>
|
|
<p
|
|
className="text-xs text-gray-500 dark:text-gray-400 mt-1"
|
|
title={getReadableCron(job.cron)}
|
|
>
|
|
Schedule: {getReadableCron(job.cron)}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
Last Run:{' '}
|
|
{job.last_run ? new Date(job.last_run).toLocaleString() : 'Never'}
|
|
</p>
|
|
{job.currently_running && (
|
|
<p className="text-xs text-green-500 dark:text-green-400 mt-1 font-semibold flex items-center">
|
|
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span>
|
|
Currently Running
|
|
</p>
|
|
)}
|
|
{job.paused && (
|
|
<p className="text-xs text-orange-500 dark:text-orange-400 mt-1 font-semibold flex items-center">
|
|
<Pause className="w-3 h-3 mr-1" />
|
|
Paused
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex-shrink-0 flex items-center gap-1">
|
|
{!job.currently_running && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleOpenEditModal(job);
|
|
}}
|
|
className="text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-blue-100/50 dark:hover:bg-blue-900/30"
|
|
title={`Edit schedule ${job.id}`}
|
|
disabled={
|
|
pausingScheduleIds.has(job.id) ||
|
|
deletingScheduleIds.has(job.id) ||
|
|
isSubmitting
|
|
}
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (job.paused) {
|
|
handleUnpauseSchedule(job.id);
|
|
} else {
|
|
handlePauseSchedule(job.id);
|
|
}
|
|
}}
|
|
className={`${
|
|
job.paused
|
|
? 'text-green-500 dark:text-green-400 hover:text-green-600 dark:hover:text-green-300 hover:bg-green-100/50 dark:hover:bg-green-900/30'
|
|
: 'text-orange-500 dark:text-orange-400 hover:text-orange-600 dark:hover:text-orange-300 hover:bg-orange-100/50 dark:hover:bg-orange-900/30'
|
|
}`}
|
|
title={
|
|
job.paused
|
|
? `Unpause schedule ${job.id}`
|
|
: `Pause schedule ${job.id}`
|
|
}
|
|
disabled={
|
|
pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id)
|
|
}
|
|
>
|
|
{job.paused ? (
|
|
<Play className="w-4 h-4" />
|
|
) : (
|
|
<Pause className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{job.currently_running && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleInspectRunningJob(job.id);
|
|
}}
|
|
className="text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-blue-100/50 dark:hover:bg-blue-900/30"
|
|
title={`Inspect running job ${job.id}`}
|
|
disabled={
|
|
inspectingScheduleIds.has(job.id) || killingScheduleIds.has(job.id)
|
|
}
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleKillRunningJob(job.id);
|
|
}}
|
|
className="text-red-500 dark:text-red-400 hover:text-red-600 dark:hover:text-red-300 hover:bg-red-100/50 dark:hover:bg-red-900/30"
|
|
title={`Kill running job ${job.id}`}
|
|
disabled={
|
|
killingScheduleIds.has(job.id) || inspectingScheduleIds.has(job.id)
|
|
}
|
|
>
|
|
<Square className="w-4 h-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteSchedule(job.id);
|
|
}}
|
|
className="text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-900/30"
|
|
title={`Delete schedule ${job.id}`}
|
|
disabled={
|
|
pausingScheduleIds.has(job.id) ||
|
|
deletingScheduleIds.has(job.id) ||
|
|
killingScheduleIds.has(job.id) ||
|
|
inspectingScheduleIds.has(job.id)
|
|
}
|
|
>
|
|
<TrashIcon className="w-5 h-5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</ScrollArea>
|
|
<CreateScheduleModal
|
|
isOpen={isCreateModalOpen}
|
|
onClose={handleCloseCreateModal}
|
|
onSubmit={handleCreateScheduleSubmit}
|
|
isLoadingExternally={isSubmitting}
|
|
apiErrorExternally={submitApiError}
|
|
/>
|
|
<EditScheduleModal
|
|
isOpen={isEditModalOpen}
|
|
onClose={handleCloseEditModal}
|
|
onSubmit={handleEditScheduleSubmit}
|
|
schedule={editingSchedule}
|
|
isLoadingExternally={isSubmitting}
|
|
apiErrorExternally={submitApiError}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SchedulesView;
|