Files
tkmind_go/ui/desktop/src/components/schedule/SchedulesView.tsx
T

588 lines
21 KiB
TypeScript

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 (
<Card
className="py-2 px-4 mb-2 bg-background-primary border-none hover:bg-background-secondary cursor-pointer transition-all duration-150"
onClick={() => onNavigateToDetail(job.id)}
>
<div className="flex justify-between items-start gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base truncate max-w-[50vw]" title={job.id}>
{job.id}
</h3>
{job.currentlyRunning && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
{intl.formatMessage(i18n.running)}
</span>
)}
{job.paused && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
<Pause className="w-3 h-3 mr-1" />
{intl.formatMessage(i18n.paused)}
</span>
)}
</div>
<p className="text-text-secondary text-sm mb-2 line-clamp-2" title={readableCron}>
{readableCron}
</p>
<div className="flex items-center text-xs text-text-secondary">
<span>{intl.formatMessage(i18n.lastRun, { date: formattedLastRun })}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{!job.currentlyRunning && (
<>
<Button
onClick={(e) => {
e.stopPropagation();
onEdit(job);
}}
disabled={actionInProgress}
variant="outline"
size="sm"
className="h-8"
>
<Edit className="w-4 h-4 mr-1" />
{intl.formatMessage(i18n.edit)}
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
if (job.paused) {
onUnpause(job.id);
} else {
onPause(job.id);
}
}}
disabled={actionInProgress}
variant="outline"
size="sm"
className="h-8"
>
{job.paused ? (
<>
<Play className="w-4 h-4 mr-1" />
{intl.formatMessage(i18n.resume)}
</>
) : (
<>
<Pause className="w-4 h-4 mr-1" />
{intl.formatMessage(i18n.pause)}
</>
)}
</Button>
</>
)}
{job.currentlyRunning && (
<>
<Button
onClick={(e) => {
e.stopPropagation();
onInspect(job.id);
}}
disabled={actionInProgress}
variant="outline"
size="sm"
className="h-8"
>
<Eye className="w-4 h-4 mr-1" />
{intl.formatMessage(i18n.inspect)}
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
onKill(job.id);
}}
disabled={actionInProgress}
variant="outline"
size="sm"
className="h-8"
>
<Square className="w-4 h-4 mr-1" />
{intl.formatMessage(i18n.kill)}
</Button>
</>
)}
<Button
onClick={(e) => {
e.stopPropagation();
onDelete(job.id);
}}
disabled={actionInProgress}
variant="ghost"
size="sm"
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<TrashIcon className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
};
const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
const intl = useIntl();
const location = useLocation();
const [schedules, setSchedules] = useState<ScheduledJobDto[]>([]);
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 [isModalOpen, setIsModalOpen] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<ScheduledJobDto | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [pendingDeepLink, setPendingDeepLink] = useState<string | null>(null);
const [actionsInProgress, setActionsInProgress] = useState<Set<string>>(new Set());
const [viewingScheduleId, setViewingScheduleId] = useState<string | null>(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 (
<ScheduleDetailView
scheduleId={viewingScheduleId}
onNavigateBack={() => setViewingScheduleId(null)}
/>
);
}
return (
<>
<MainPanelLayout>
<div className="flex-1 flex flex-col min-h-0">
<div className="bg-background-primary px-8 pb-8 pt-16">
<div className="flex flex-col page-transition">
<div className="flex justify-between items-center mb-1">
<h1 className="text-4xl font-light">{intl.formatMessage(i18n.scheduler)}</h1>
<div className="flex gap-2">
<Button
onClick={handleRefresh}
disabled={isRefreshing || isLoading}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? intl.formatMessage(i18n.refreshing) : intl.formatMessage(i18n.refresh)}
</Button>
<Button
onClick={() => {
setSubmitApiError(null);
setIsModalOpen(true);
}}
size="sm"
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
{intl.formatMessage(i18n.createSchedule)}
</Button>
</div>
</div>
<p className="text-sm text-text-secondary mb-1">
{intl.formatMessage(i18n.description)}
</p>
</div>
</div>
<div className="flex-1 min-h-0 relative px-8">
<ScrollArea className="h-full">
<div className="h-full relative">
{apiError && (
<div className="mb-4 p-4 bg-background-danger border border-border-danger rounded-md">
<p className="text-text-danger text-sm">{intl.formatMessage(i18n.errorPrefix, { error: apiError })}</p>
</div>
)}
{isLoading && schedules.length === 0 && (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2"></div>
</div>
)}
{!isLoading && !apiError && schedules.length === 0 && (
<div className="flex flex-col pt-4 pb-12">
<CircleDotDashed className="h-5 w-5 text-text-secondary mb-3.5" />
<p className="text-base text-text-secondary font-light mb-2">
{intl.formatMessage(i18n.noSchedules)}
</p>
</div>
)}
{!isLoading && schedules.length > 0 && (
<div className="space-y-2 pb-8">
{schedules.map((job) => (
<ScheduleCard
key={job.id}
job={job}
onNavigateToDetail={handleNavigateToDetail}
onEdit={(schedule) => {
setEditingSchedule(schedule);
setSubmitApiError(null);
setIsModalOpen(true);
}}
onPause={handlePauseSchedule}
onUnpause={handleUnpauseSchedule}
onKill={handleKillRunningJob}
onInspect={handleInspectRunningJob}
onDelete={handleDeleteSchedule}
actionInProgress={actionsInProgress.has(job.id) || isSubmitting}
/>
))}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
</MainPanelLayout>
<ScheduleModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setEditingSchedule(null);
setSubmitApiError(null);
setPendingDeepLink(null);
}}
onSubmit={handleModalSubmit}
schedule={editingSchedule}
isLoadingExternally={isSubmitting}
apiErrorExternally={submitApiError}
initialDeepLink={pendingDeepLink}
/>
</>
);
};
export default SchedulesView;