/** * Cron Page * Manage scheduled tasks */ import { useEffect, useState, useCallback, type ReactNode, type SelectHTMLAttributes } from 'react'; import { Plus, Clock, Play, Trash2, RefreshCw, X, Calendar, AlertCircle, CheckCircle2, XCircle, MessageSquare, Loader2, Timer, History, Pause, ChevronDown, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { hostApiFetch } from '@/lib/host-api'; import { useCronStore } from '@/stores/cron'; import { useGatewayStore } from '@/stores/gateway'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { formatRelativeTime, cn } from '@/lib/utils'; import { toast } from 'sonner'; import type { CronJob, CronJobCreateInput, ScheduleType } from '@/types/cron'; import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; // Common cron schedule presets const schedulePresets: { key: string; value: string; type: ScheduleType }[] = [ { key: 'everyMinute', value: '* * * * *', type: 'interval' }, { key: 'every5Min', value: '*/5 * * * *', type: 'interval' }, { key: 'every15Min', value: '*/15 * * * *', type: 'interval' }, { key: 'everyHour', value: '0 * * * *', type: 'interval' }, { key: 'daily9am', value: '0 9 * * *', type: 'daily' }, { key: 'daily6pm', value: '0 18 * * *', type: 'daily' }, { key: 'weeklyMon', value: '0 9 * * 1', type: 'weekly' }, { key: 'monthly1st', value: '0 9 1 * *', type: 'monthly' }, ]; // Parse cron schedule to human-readable format // Handles both plain cron strings and Gateway CronSchedule objects: // { kind: "cron", expr: "...", tz?: "..." } // { kind: "every", everyMs: number } // { kind: "at", at: "..." } function parseCronSchedule(schedule: unknown, t: TFunction<'cron'>): string { // Handle Gateway CronSchedule object format if (schedule && typeof schedule === 'object') { const s = schedule as { kind?: string; expr?: string; tz?: string; everyMs?: number; at?: string }; if (s.kind === 'cron' && typeof s.expr === 'string') { return parseCronExpr(s.expr, t); } if (s.kind === 'every' && typeof s.everyMs === 'number') { const ms = s.everyMs; if (ms < 60_000) return t('schedule.everySeconds', { count: Math.round(ms / 1000) }); if (ms < 3_600_000) return t('schedule.everyMinutes', { count: Math.round(ms / 60_000) }); if (ms < 86_400_000) return t('schedule.everyHours', { count: Math.round(ms / 3_600_000) }); return t('schedule.everyDays', { count: Math.round(ms / 86_400_000) }); } if (s.kind === 'at' && typeof s.at === 'string') { try { return t('schedule.onceAt', { time: new Date(s.at).toLocaleString() }); } catch { return t('schedule.onceAt', { time: s.at }); } } return String(schedule); } // Handle plain cron string if (typeof schedule === 'string') { return parseCronExpr(schedule, t); } return String(schedule ?? t('schedule.unknown')); } // Parse a plain cron expression string to human-readable text function parseCronExpr(cron: string, t: TFunction<'cron'>): string { const preset = schedulePresets.find((p) => p.value === cron); if (preset) return t(`presets.${preset.key}` as const); const parts = cron.split(' '); if (parts.length !== 5) return cron; const [minute, hour, dayOfMonth, , dayOfWeek] = parts; if (minute === '*' && hour === '*') return t('presets.everyMinute'); if (minute.startsWith('*/')) return t('schedule.everyMinutes', { count: Number(minute.slice(2)) }); if (hour === '*' && minute === '0') return t('presets.everyHour'); if (dayOfWeek !== '*' && dayOfMonth === '*') { return t('schedule.weeklyAt', { day: dayOfWeek, time: `${hour}:${minute.padStart(2, '0')}` }); } if (dayOfMonth !== '*') { return t('schedule.monthlyAtDay', { day: dayOfMonth, time: `${hour}:${minute.padStart(2, '0')}` }); } if (hour !== '*') { return t('schedule.dailyAt', { time: `${hour}:${minute.padStart(2, '0')}` }); } return cron; } function estimateNextRun(scheduleExpr: string): string | null { const now = new Date(); const next = new Date(now.getTime()); if (scheduleExpr === '* * * * *') { next.setSeconds(0, 0); next.setMinutes(next.getMinutes() + 1); return next.toLocaleString(); } if (scheduleExpr === '*/5 * * * *') { const delta = 5 - (next.getMinutes() % 5 || 5); next.setSeconds(0, 0); next.setMinutes(next.getMinutes() + delta); return next.toLocaleString(); } if (scheduleExpr === '*/15 * * * *') { const delta = 15 - (next.getMinutes() % 15 || 15); next.setSeconds(0, 0); next.setMinutes(next.getMinutes() + delta); return next.toLocaleString(); } if (scheduleExpr === '0 * * * *') { next.setMinutes(0, 0, 0); next.setHours(next.getHours() + 1); return next.toLocaleString(); } if (scheduleExpr === '0 9 * * *' || scheduleExpr === '0 18 * * *') { const targetHour = scheduleExpr === '0 9 * * *' ? 9 : 18; next.setSeconds(0, 0); next.setHours(targetHour, 0, 0, 0); if (next <= now) next.setDate(next.getDate() + 1); return next.toLocaleString(); } if (scheduleExpr === '0 9 * * 1') { next.setSeconds(0, 0); next.setHours(9, 0, 0, 0); const day = next.getDay(); const daysUntilMonday = day === 1 ? 7 : (8 - day) % 7; next.setDate(next.getDate() + daysUntilMonday); return next.toLocaleString(); } if (scheduleExpr === '0 9 1 * *') { next.setSeconds(0, 0); next.setDate(1); next.setHours(9, 0, 0, 0); if (next <= now) next.setMonth(next.getMonth() + 1); return next.toLocaleString(); } return null; } interface DeliveryChannelAccount { accountId: string; name: string; isDefault: boolean; } interface DeliveryChannelGroup { channelType: string; defaultAccountId: string; accounts: DeliveryChannelAccount[]; } interface ChannelTargetOption { value: string; label: string; kind: 'user' | 'group' | 'channel'; } function isKnownChannelType(value: string): value is ChannelType { return value in CHANNEL_NAMES; } function getChannelDisplayName(value: string): string { return isKnownChannelType(value) ? CHANNEL_NAMES[value] : value; } function getDeliveryAccountDisplayName(account: DeliveryChannelAccount, t: TFunction): string { return account.accountId === 'default' && account.name === account.accountId ? t('channels:account.mainAccount') : account.name; } const TESTED_CRON_DELIVERY_CHANNELS = new Set(['feishu', 'telegram', 'qqbot', 'wecom', 'wechat']); function isSupportedCronDeliveryChannel(channelType: string): boolean { return TESTED_CRON_DELIVERY_CHANNELS.has(channelType); } interface SelectFieldProps extends SelectHTMLAttributes { children: ReactNode; } function SelectField({ className, children, ...props }: SelectFieldProps) { return (
); } // Create/Edit Task Dialog interface TaskDialogProps { job?: CronJob; configuredChannels: DeliveryChannelGroup[]; onClose: () => void; onSave: (input: CronJobCreateInput) => Promise; } function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProps) { const { t } = useTranslation('cron'); const [saving, setSaving] = useState(false); const [name, setName] = useState(job?.name || ''); const [message, setMessage] = useState(job?.message || ''); // Extract cron expression string from CronSchedule object or use as-is if string const initialSchedule = (() => { const s = job?.schedule; if (!s) return '0 9 * * *'; if (typeof s === 'string') return s; if (typeof s === 'object' && 'expr' in s && typeof (s as { expr: string }).expr === 'string') { return (s as { expr: string }).expr; } return '0 9 * * *'; })(); const [schedule, setSchedule] = useState(initialSchedule); const [customSchedule, setCustomSchedule] = useState(''); const [useCustom, setUseCustom] = useState(false); const [enabled, setEnabled] = useState(job?.enabled ?? true); const [deliveryMode, setDeliveryMode] = useState<'none' | 'announce'>(job?.delivery?.mode === 'announce' ? 'announce' : 'none'); const [deliveryChannel, setDeliveryChannel] = useState(job?.delivery?.channel || ''); const [deliveryTarget, setDeliveryTarget] = useState(job?.delivery?.to || ''); const [selectedDeliveryAccountId, setSelectedDeliveryAccountId] = useState(job?.delivery?.accountId || ''); const [channelTargetOptions, setChannelTargetOptions] = useState([]); const [loadingChannelTargets, setLoadingChannelTargets] = useState(false); const schedulePreview = estimateNextRun(useCustom ? customSchedule : schedule); const selectableChannels = configuredChannels.filter((group) => isSupportedCronDeliveryChannel(group.channelType)); const availableChannels = selectableChannels.some((group) => group.channelType === deliveryChannel) ? selectableChannels : ( deliveryChannel && isSupportedCronDeliveryChannel(deliveryChannel) ? [...selectableChannels, configuredChannels.find((group) => group.channelType === deliveryChannel) || { channelType: deliveryChannel, defaultAccountId: 'default', accounts: [] }] : selectableChannels ); const effectiveDeliveryChannel = deliveryChannel || (deliveryMode === 'announce' ? (availableChannels[0]?.channelType || '') : ''); const unsupportedDeliveryChannel = !!effectiveDeliveryChannel && !isSupportedCronDeliveryChannel(effectiveDeliveryChannel); const selectedChannel = availableChannels.find((group) => group.channelType === effectiveDeliveryChannel); const deliveryAccountOptions = (selectedChannel?.accounts ?? []).map((account) => ({ accountId: account.accountId, displayName: getDeliveryAccountDisplayName(account, t), })); const hasCurrentDeliveryTarget = !!deliveryTarget; const currentDeliveryTargetOption = hasCurrentDeliveryTarget ? { value: deliveryTarget, label: `${t('dialog.currentTarget')} (${deliveryTarget})`, kind: 'user' as const, } : null; const effectiveDeliveryAccountId = selectedDeliveryAccountId || selectedChannel?.defaultAccountId || deliveryAccountOptions[0]?.accountId || ''; const showsAccountSelector = (selectedChannel?.accounts.length ?? 0) > 0; const selectedResolvedAccountId = effectiveDeliveryAccountId || undefined; const availableTargetOptions = currentDeliveryTargetOption ? [currentDeliveryTargetOption, ...channelTargetOptions.filter((option) => option.value !== deliveryTarget)] : channelTargetOptions; useEffect(() => { if (deliveryMode !== 'announce') { setSelectedDeliveryAccountId(''); return; } if (!selectedDeliveryAccountId && selectedChannel?.defaultAccountId) { setSelectedDeliveryAccountId(selectedChannel.defaultAccountId); } }, [deliveryMode, selectedChannel?.defaultAccountId, selectedDeliveryAccountId]); useEffect(() => { if (deliveryMode !== 'announce' || !effectiveDeliveryChannel || unsupportedDeliveryChannel) { setChannelTargetOptions([]); setLoadingChannelTargets(false); return; } if (showsAccountSelector && !selectedResolvedAccountId) { setChannelTargetOptions([]); setLoadingChannelTargets(false); return; } let cancelled = false; setLoadingChannelTargets(true); const params = new URLSearchParams({ channelType: effectiveDeliveryChannel }); if (selectedResolvedAccountId) { params.set('accountId', selectedResolvedAccountId); } void hostApiFetch<{ success: boolean; targets?: ChannelTargetOption[]; error?: string }>( `/api/channels/targets?${params.toString()}`, ).then((result) => { if (cancelled) return; if (!result.success) { throw new Error(result.error || 'Failed to load channel targets'); } setChannelTargetOptions(result.targets || []); }).catch((error) => { if (!cancelled) { console.warn('Failed to load channel targets:', error); setChannelTargetOptions([]); } }).finally(() => { if (!cancelled) { setLoadingChannelTargets(false); } }); return () => { cancelled = true; }; }, [deliveryMode, effectiveDeliveryChannel, selectedResolvedAccountId, showsAccountSelector, unsupportedDeliveryChannel]); const handleSubmit = async () => { if (!name.trim()) { toast.error(t('toast.nameRequired')); return; } if (!message.trim()) { toast.error(t('toast.messageRequired')); return; } const finalSchedule = useCustom ? customSchedule : schedule; if (!finalSchedule.trim()) { toast.error(t('toast.scheduleRequired')); return; } setSaving(true); try { const finalDelivery = deliveryMode === 'announce' ? { mode: 'announce' as const, channel: effectiveDeliveryChannel.trim(), ...(selectedResolvedAccountId ? { accountId: effectiveDeliveryAccountId } : {}), to: deliveryTarget.trim(), } : { mode: 'none' as const }; if (finalDelivery.mode === 'announce') { if (!finalDelivery.channel) { toast.error(t('toast.channelRequired')); return; } if (!isSupportedCronDeliveryChannel(finalDelivery.channel)) { toast.error(t('toast.deliveryChannelUnsupported', { channel: getChannelDisplayName(finalDelivery.channel) })); return; } if (!finalDelivery.to) { toast.error(t('toast.deliveryTargetRequired')); return; } } await onSave({ name: name.trim(), message: message.trim(), schedule: finalSchedule, delivery: finalDelivery, enabled, }); onClose(); toast.success(job ? t('toast.updated') : t('toast.created')); } catch (err) { toast.error(String(err)); } finally { setSaving(false); } }; return (
e.stopPropagation()}>
{job ? t('dialog.editTitle') : t('dialog.createTitle')} {t('dialog.description')}
{/* Name */}
setName(e.target.value)} className="h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary shadow-sm transition-all text-foreground placeholder:text-foreground/40" />
{/* Message */}