import React, { useState, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import type { ScheduledJobDto } from '@aaif/goose-sdk'; import { acpListSchedules, acpCreateSchedule, acpDeleteSchedule, acpPauseSchedule, acpUnpauseSchedule, acpUpdateSchedule, acpKillRunningJob, acpInspectRunningJob, } from '../../acp/schedules'; import { ScrollArea } from '../ui/scroll-area'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { TrashIcon } from '../icons/TrashIcon'; import { Plus, RefreshCw, Pause, Play, Edit, Square, Eye, CircleDotDashed } from 'lucide-react'; import { NewSchedulePayload, ScheduleModal } from './ScheduleModal'; import ScheduleDetailView from './ScheduleDetailView'; import { toastError, toastSuccess } from '../../toasts'; import cronstrue from 'cronstrue'; import { formatToLocalDateWithTimezone } from '../../utils/date'; import { errorMessage } from '../../utils/conversionUtils'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; import { ViewOptions } from '../../utils/navigationUtils'; import { trackScheduleCreated, trackScheduleDeleted, getErrorType } from '../../utils/analytics'; import { defineMessages, useIntl } from '../../i18n'; const i18n = defineMessages({ running: { id: 'schedulesView.running', defaultMessage: 'Running' }, paused: { id: 'schedulesView.paused', defaultMessage: 'Paused' }, lastRun: { id: 'schedulesView.lastRun', defaultMessage: 'Last run: {date}' }, edit: { id: 'schedulesView.edit', defaultMessage: 'Edit' }, resume: { id: 'schedulesView.resume', defaultMessage: 'Resume' }, pause: { id: 'schedulesView.pause', defaultMessage: 'Pause' }, inspect: { id: 'schedulesView.inspect', defaultMessage: 'Inspect' }, kill: { id: 'schedulesView.kill', defaultMessage: 'Kill' }, scheduler: { id: 'schedulesView.scheduler', defaultMessage: 'Scheduler' }, refreshing: { id: 'schedulesView.refreshing', defaultMessage: 'Refreshing...' }, refresh: { id: 'schedulesView.refresh', defaultMessage: 'Refresh' }, createSchedule: { id: 'schedulesView.createSchedule', defaultMessage: 'Create Schedule' }, description: { id: 'schedulesView.description', defaultMessage: 'Create and manage scheduled tasks to run recipes automatically at specified times.' }, errorPrefix: { id: 'schedulesView.errorPrefix', defaultMessage: 'Error: {error}' }, noSchedules: { id: 'schedulesView.noSchedules', defaultMessage: 'No schedules yet' }, scheduleUpdated: { id: 'schedulesView.scheduleUpdated', defaultMessage: 'Schedule Updated' }, scheduleUpdatedMsg: { id: 'schedulesView.scheduleUpdatedMsg', defaultMessage: 'Successfully updated schedule "{id}"' }, confirmDelete: { id: 'schedulesView.confirmDelete', defaultMessage: 'Remove schedule "{id}"? The recipe will be kept.', }, schedulePaused: { id: 'schedulesView.schedulePaused', defaultMessage: 'Schedule Paused' }, schedulePausedMsg: { id: 'schedulesView.schedulePausedMsg', defaultMessage: 'Successfully paused schedule "{id}"' }, pauseError: { id: 'schedulesView.pauseError', defaultMessage: 'Pause Schedule Error' }, scheduleUnpaused: { id: 'schedulesView.scheduleUnpaused', defaultMessage: 'Schedule Unpaused' }, scheduleUnpausedMsg: { id: 'schedulesView.scheduleUnpausedMsg', defaultMessage: 'Successfully unpaused schedule "{id}"' }, unpauseError: { id: 'schedulesView.unpauseError', defaultMessage: 'Unpause Schedule Error' }, jobKilled: { id: 'schedulesView.jobKilled', defaultMessage: 'Job Killed' }, killError: { id: 'schedulesView.killError', defaultMessage: 'Kill Job Error' }, jobInspection: { id: 'schedulesView.jobInspection', defaultMessage: 'Job Inspection' }, inspectNoInfo: { id: 'schedulesView.inspectNoInfo', defaultMessage: 'No detailed information available for this job' }, inspectError: { id: 'schedulesView.inspectError', defaultMessage: 'Inspect Job Error' }, }); interface SchedulesViewProps { onClose?: () => void; } const ScheduleCard: React.FC<{ job: ScheduledJobDto; onNavigateToDetail: (id: string) => void; onEdit: (job: ScheduledJobDto) => void; onPause: (id: string) => void; onUnpause: (id: string) => void; onKill: (id: string) => void; onInspect: (id: string) => void; onDelete: (id: string) => void; actionInProgress: boolean; }> = ({ job, onNavigateToDetail, onEdit, onPause, onUnpause, onKill, onInspect, onDelete, actionInProgress, }) => { const intl = useIntl(); let readableCron: string; try { readableCron = cronstrue.toString(job.cron); } catch { readableCron = job.cron; } const formattedLastRun = formatToLocalDateWithTimezone(job.lastRun); return ( onNavigateToDetail(job.id)} >

{job.id}

{job.currentlyRunning && ( {intl.formatMessage(i18n.running)} )} {job.paused && ( {intl.formatMessage(i18n.paused)} )}

{readableCron}

{intl.formatMessage(i18n.lastRun, { date: formattedLastRun })}
{!job.currentlyRunning && ( <> )} {job.currentlyRunning && ( <> )}
); }; const SchedulesView: React.FC = ({ onClose: _onClose }) => { const intl = useIntl(); const location = useLocation(); const [schedules, setSchedules] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [apiError, setApiError] = useState(null); const [submitApiError, setSubmitApiError] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [editingSchedule, setEditingSchedule] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); const [pendingDeepLink, setPendingDeepLink] = useState(null); const [actionsInProgress, setActionsInProgress] = useState>(new Set()); const [viewingScheduleId, setViewingScheduleId] = useState(null); const fetchSchedules = async () => { setIsLoading(true); setApiError(null); try { const fetchedSchedules = await acpListSchedules(); setSchedules(fetchedSchedules); } catch (error) { console.error('Failed to fetch schedules:', error); setApiError(errorMessage(error, 'An unknown error occurred while fetching schedules.')); } finally { setIsLoading(false); } }; useEffect(() => { if (viewingScheduleId === null) { fetchSchedules(); const locationState = location.state as ViewOptions | null; if (locationState?.pendingScheduleDeepLink) { setPendingDeepLink(locationState.pendingScheduleDeepLink); setIsModalOpen(true); window.history.replaceState({}, document.title); } } }, [viewingScheduleId, location.state]); useEffect(() => { if (viewingScheduleId !== null || actionsInProgress.size > 0) return; const intervalId = setInterval(() => { if (viewingScheduleId === null && !isRefreshing && !isLoading && !isSubmitting) { fetchSchedules(); } }, 15000); return () => clearInterval(intervalId); }, [viewingScheduleId, isRefreshing, isLoading, isSubmitting, actionsInProgress.size]); const handleRefresh = async () => { setIsRefreshing(true); try { await fetchSchedules(); } finally { setIsRefreshing(false); } }; const handleModalSubmit = async (payload: NewSchedulePayload | string) => { setIsSubmitting(true); setSubmitApiError(null); try { if (editingSchedule) { await acpUpdateSchedule(editingSchedule.id, payload as string); toastSuccess({ title: intl.formatMessage(i18n.scheduleUpdated), msg: intl.formatMessage(i18n.scheduleUpdatedMsg, { id: editingSchedule.id }), }); } else { const newPayload = payload as NewSchedulePayload; await acpCreateSchedule(newPayload); const sourceType = pendingDeepLink ? 'deeplink' : 'file'; trackScheduleCreated(sourceType, true); } await fetchSchedules(); setIsModalOpen(false); setEditingSchedule(null); } catch (error) { console.error('Failed to save schedule:', error); const errorMsg = errorMessage(error, 'Unknown error saving schedule.'); setSubmitApiError(errorMsg); if (!editingSchedule) { const sourceType = pendingDeepLink ? 'deeplink' : 'file'; trackScheduleCreated(sourceType, false, getErrorType(error)); } } finally { setIsSubmitting(false); } }; const handleDeleteSchedule = async (id: string) => { if (!window.confirm(intl.formatMessage(i18n.confirmDelete, { id }))) return; setActionsInProgress((prev) => new Set(prev).add(id)); if (viewingScheduleId === id) setViewingScheduleId(null); setApiError(null); try { await acpDeleteSchedule(id); trackScheduleDeleted(true); await fetchSchedules(); } catch (error) { console.error(`Failed to remove schedule "${id}":`, error); const errorMsg = errorMessage(error, `Unknown error removing schedule "${id}".`); setApiError(errorMsg); trackScheduleDeleted(false, getErrorType(error)); } finally { setActionsInProgress((prev) => { const newSet = new Set(prev); newSet.delete(id); return newSet; }); } }; const handlePauseSchedule = async (id: string) => { setActionsInProgress((prev) => new Set(prev).add(id)); setApiError(null); try { await acpPauseSchedule(id); toastSuccess({ title: intl.formatMessage(i18n.schedulePaused), msg: intl.formatMessage(i18n.schedulePausedMsg, { id }), }); await fetchSchedules(); } catch (error) { console.error(`Failed to pause schedule "${id}":`, error); const errorMsg = errorMessage(error, `Unknown error pausing "${id}".`); setApiError(errorMsg); toastError({ title: intl.formatMessage(i18n.pauseError), msg: errorMsg, }); } finally { setActionsInProgress((prev) => { const newSet = new Set(prev); newSet.delete(id); return newSet; }); } }; const handleUnpauseSchedule = async (id: string) => { setActionsInProgress((prev) => new Set(prev).add(id)); setApiError(null); try { await acpUnpauseSchedule(id); toastSuccess({ title: intl.formatMessage(i18n.scheduleUnpaused), msg: intl.formatMessage(i18n.scheduleUnpausedMsg, { id }), }); await fetchSchedules(); } catch (error) { console.error(`Failed to unpause schedule "${id}":`, error); const errorMsg = errorMessage(error, `Unknown error unpausing "${id}".`); setApiError(errorMsg); toastError({ title: intl.formatMessage(i18n.unpauseError), msg: errorMsg, }); } finally { setActionsInProgress((prev) => { const newSet = new Set(prev); newSet.delete(id); return newSet; }); } }; const handleKillRunningJob = async (id: string) => { setActionsInProgress((prev) => new Set(prev).add(id)); setApiError(null); try { const result = await acpKillRunningJob(id); toastSuccess({ title: intl.formatMessage(i18n.jobKilled), msg: result.message, }); await fetchSchedules(); } catch (error) { console.error(`Failed to kill running job "${id}":`, error); const errorMsg = errorMessage(error, `Unknown error killing job "${id}".`); setApiError(errorMsg); toastError({ title: intl.formatMessage(i18n.killError), msg: errorMsg, }); } finally { setActionsInProgress((prev) => { const newSet = new Set(prev); newSet.delete(id); return newSet; }); } }; const handleInspectRunningJob = async (id: string) => { setActionsInProgress((prev) => new Set(prev).add(id)); setApiError(null); try { const result = await acpInspectRunningJob(id); if (result.sessionId) { const duration = result.runningDurationSeconds ? `${Math.floor(result.runningDurationSeconds / 60)}m ${result.runningDurationSeconds % 60}s` : 'Unknown'; toastSuccess({ title: intl.formatMessage(i18n.jobInspection), msg: `Session: ${result.sessionId}\nRunning for: ${duration}`, }); } else { toastSuccess({ title: intl.formatMessage(i18n.jobInspection), msg: intl.formatMessage(i18n.inspectNoInfo), }); } } catch (error) { console.error(`Failed to inspect running job "${id}":`, error); const errorMsg = errorMessage(error, `Unknown error inspecting job "${id}".`); setApiError(errorMsg); toastError({ title: intl.formatMessage(i18n.inspectError), msg: errorMsg, }); } finally { setActionsInProgress((prev) => { const newSet = new Set(prev); newSet.delete(id); return newSet; }); } }; const handleNavigateToDetail = (id: string) => { setViewingScheduleId(id); }; if (viewingScheduleId) { return ( setViewingScheduleId(null)} /> ); } return ( <>

{intl.formatMessage(i18n.scheduler)}

{intl.formatMessage(i18n.description)}

{apiError && (

{intl.formatMessage(i18n.errorPrefix, { error: apiError })}

)} {isLoading && schedules.length === 0 && (
)} {!isLoading && !apiError && schedules.length === 0 && (

{intl.formatMessage(i18n.noSchedules)}

)} {!isLoading && schedules.length > 0 && (
{schedules.map((job) => ( { setEditingSchedule(schedule); setSubmitApiError(null); setIsModalOpen(true); }} onPause={handlePauseSchedule} onUnpause={handleUnpauseSchedule} onKill={handleKillRunningJob} onInspect={handleInspectRunningJob} onDelete={handleDeleteSchedule} actionInProgress={actionsInProgress.has(job.id) || isSubmitting} /> ))}
)}
{ setIsModalOpen(false); setEditingSchedule(null); setSubmitApiError(null); setPendingDeepLink(null); }} onSubmit={handleModalSubmit} schedule={editingSchedule} isLoadingExternally={isSubmitting} apiErrorExternally={submitApiError} initialDeepLink={pendingDeepLink} /> ); }; export default SchedulesView;