diff --git a/sim/app/api/scheduled/[id]/route.ts b/sim/app/api/scheduled/[id]/route.ts new file mode 100644 index 0000000000..c46616d21a --- /dev/null +++ b/sim/app/api/scheduled/[id]/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server' +import crypto from 'crypto' +import { and, eq } from 'drizzle-orm' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { workflow, workflowSchedule } from '@/db/schema' + +const logger = createLogger('ScheduleDeleteAPI') + +export const dynamic = 'force-dynamic' + +/** + * Delete a schedule + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const { id } = await params + logger.debug(`[${requestId}] Deleting schedule with ID: ${id}`) + + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized schedule deletion attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Find the schedule and check ownership + const schedules = await db + .select({ + schedule: workflowSchedule, + workflow: { + id: workflow.id, + userId: workflow.userId, + }, + }) + .from(workflowSchedule) + .innerJoin(workflow, eq(workflowSchedule.workflowId, workflow.id)) + .where(eq(workflowSchedule.id, id)) + .limit(1) + + if (schedules.length === 0) { + logger.warn(`[${requestId}] Schedule not found: ${id}`) + return NextResponse.json({ error: 'Schedule not found' }, { status: 404 }) + } + + if (schedules[0].workflow.userId !== session.user.id) { + logger.warn(`[${requestId}] Unauthorized schedule deletion attempt for schedule: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + + // Delete the schedule + await db.delete(workflowSchedule).where(eq(workflowSchedule.id, id)) + + logger.info(`[${requestId}] Successfully deleted schedule: ${id}`) + return NextResponse.json({ success: true }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error deleting schedule`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/sim/app/api/scheduled/execute/route.ts b/sim/app/api/scheduled/execute/route.ts index e75cc8fbad..28a62f453c 100644 --- a/sim/app/api/scheduled/execute/route.ts +++ b/sim/app/api/scheduled/execute/route.ts @@ -13,6 +13,9 @@ import { environment, workflow, workflowSchedule } from '@/db/schema' import { Executor } from '@/executor' import { Serializer } from '@/serializer' +// Add dynamic export to prevent caching +export const dynamic = 'force-dynamic' + const logger = createLogger('ScheduledExecuteAPI') interface SubBlockValue { @@ -144,16 +147,12 @@ function calculateNextRunTime( // Define the schema for environment variables const EnvVarsSchema = z.record(z.string()) -export const config = { - runtime: 'nodejs', - schedule: '*/1 * * * *', -} - // Keep track of running executions to prevent overlap const runningExecutions = new Set() // Add GET handler for cron job export async function GET(req: NextRequest) { + logger.info(`Scheduled execution triggered at ${new Date().toISOString()}`) const requestId = crypto.randomUUID().slice(0, 8) const now = new Date() diff --git a/sim/app/api/scheduled/route.ts b/sim/app/api/scheduled/route.ts new file mode 100644 index 0000000000..0279b00228 --- /dev/null +++ b/sim/app/api/scheduled/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server' +import crypto from 'crypto' +import { eq } from 'drizzle-orm' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { workflowSchedule } from '@/db/schema' + +const logger = createLogger('Scheduled API') + +/** + * Get schedule information for a workflow + */ +export async function GET(req: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + const url = new URL(req.url) + const workflowId = url.searchParams.get('workflowId') + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized schedule query attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (!workflowId) { + return NextResponse.json({ error: 'Missing workflowId parameter' }, { status: 400 }) + } + + logger.info(`[${requestId}] Getting schedule for workflow ${workflowId}`) + + // Find the schedule for this workflow + const schedule = await db + .select() + .from(workflowSchedule) + .where(eq(workflowSchedule.workflowId, workflowId)) + .limit(1) + + if (schedule.length === 0) { + return NextResponse.json({ schedule: null }) + } + + return NextResponse.json({ schedule: schedule[0] }) + } catch (error) { + logger.error(`[${requestId}] Error retrieving workflow schedule`, error) + return NextResponse.json({ error: 'Failed to retrieve workflow schedule' }, { status: 500 }) + } +} diff --git a/sim/app/w/[id]/components/workflow-block/components/action-bar/schedule-status.tsx b/sim/app/w/[id]/components/workflow-block/components/action-bar/schedule-status.tsx new file mode 100644 index 0000000000..8b005787a9 --- /dev/null +++ b/sim/app/w/[id]/components/workflow-block/components/action-bar/schedule-status.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import { Calendar } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { createLogger } from '@/lib/logs/console-logger' +import { formatDateTime } from '@/lib/utils' + +const logger = createLogger('ScheduleStatus') + +interface ScheduleStatusProps { + blockId: string +} + +export function ScheduleStatus({ blockId }: ScheduleStatusProps) { + const [scheduleId, setScheduleId] = useState(null) + const [nextRunAt, setNextRunAt] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const params = useParams() + const workflowId = params.id as string + + // Check if schedule exists in the database + useEffect(() => { + const checkSchedule = async () => { + setIsLoading(true) + try { + // Check if there's a schedule for this workflow + const response = await fetch(`/api/scheduled?workflowId=${workflowId}`) + if (response.ok) { + const data = await response.json() + if (data.schedule) { + setScheduleId(data.schedule.id) + setNextRunAt(data.schedule.nextRunAt) + } else { + setScheduleId(null) + setNextRunAt(null) + } + } + } catch (error) { + logger.error('Error checking schedule:', { error }) + } finally { + setIsLoading(false) + } + } + + checkSchedule() + }, [workflowId]) + + if (isLoading || !scheduleId || !nextRunAt) { + return null + } + + return ( + + + + + Active + + + +
+
+ Schedule Active +
+
Next run: {formatDateTime(new Date(nextRunAt))}
+
+
+
+ ) +} diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx new file mode 100644 index 0000000000..c0411a7add --- /dev/null +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx @@ -0,0 +1,611 @@ +import { useEffect, useState } from 'react' +import { format } from 'date-fns' +import { Calendar, Trash2 } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Calendar as CalendarComponent } from '@/components/ui/calendar' +import { + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { createLogger } from '@/lib/logs/console-logger' +import { UnsavedChangesDialog } from '../../../components/webhook/components/ui/confirmation' +import { useSubBlockValue } from '../../../hooks/use-sub-block-value' +import { TimeInput } from '../../time-input' + +const logger = createLogger('ScheduleModal') + +interface ScheduleModalProps { + isOpen: boolean + onClose: () => void + workflowId: string + blockId: string + onSave: () => Promise + onDelete?: () => Promise + scheduleId?: string | null +} + +export function ScheduleModal({ + isOpen, + onClose, + workflowId, + blockId, + onSave, + onDelete, + scheduleId, +}: ScheduleModalProps) { + // States for schedule configuration + const [scheduleType, setScheduleType] = useSubBlockValue(blockId, 'scheduleType') + const [scheduleStartAt, setScheduleStartAt] = useSubBlockValue(blockId, 'scheduleStartAt') + const [scheduleTime, setScheduleTime] = useSubBlockValue(blockId, 'scheduleTime') + const [minutesInterval, setMinutesInterval] = useSubBlockValue(blockId, 'minutesInterval') + const [hourlyMinute, setHourlyMinute] = useSubBlockValue(blockId, 'hourlyMinute') + const [dailyTime, setDailyTime] = useSubBlockValue(blockId, 'dailyTime') + const [weeklyDay, setWeeklyDay] = useSubBlockValue(blockId, 'weeklyDay') + const [weeklyDayTime, setWeeklyDayTime] = useSubBlockValue(blockId, 'weeklyDayTime') + const [monthlyDay, setMonthlyDay] = useSubBlockValue(blockId, 'monthlyDay') + const [monthlyTime, setMonthlyTime] = useSubBlockValue(blockId, 'monthlyTime') + const [cronExpression, setCronExpression] = useSubBlockValue(blockId, 'cronExpression') + const [timezone, setTimezone] = useSubBlockValue(blockId, 'timezone') + + // UI states + const [isSaving, setIsSaving] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [hasChanges, setHasChanges] = useState(false) + const [showUnsavedChangesConfirm, setShowUnsavedChangesConfirm] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + // Simpler approach - we'll use this to store the initial values when the modal opens + const [initialValues, setInitialValues] = useState>({}) + + // Initialize initial values when the modal opens + useEffect(() => { + if (isOpen) { + // Capture all current values when modal opens + const currentValues = { + scheduleType: scheduleType || 'daily', + scheduleStartAt: scheduleStartAt || '', + scheduleTime: scheduleTime || '', + minutesInterval: minutesInterval || '', + hourlyMinute: hourlyMinute || '', + dailyTime: dailyTime || '', + weeklyDay: weeklyDay || 'MON', + weeklyDayTime: weeklyDayTime || '', + monthlyDay: monthlyDay || '', + monthlyTime: monthlyTime || '', + timezone: timezone || 'UTC', + cronExpression: cronExpression || '', + } + + setInitialValues(currentValues) + setHasChanges(false) + setErrorMessage(null) + } + }, [isOpen]) + + // Track changes - simplified approach + useEffect(() => { + if (!isOpen) return + + const currentValues = { + scheduleType: scheduleType || 'daily', + scheduleStartAt: scheduleStartAt || '', + scheduleTime: scheduleTime || '', + minutesInterval: minutesInterval || '', + hourlyMinute: hourlyMinute || '', + dailyTime: dailyTime || '', + weeklyDay: weeklyDay || 'MON', + weeklyDayTime: weeklyDayTime || '', + monthlyDay: monthlyDay || '', + monthlyTime: monthlyTime || '', + timezone: timezone || 'UTC', + cronExpression: cronExpression || '', + } + + // Simple JSON comparison to detect any changes + const valuesChanged = JSON.stringify(initialValues) !== JSON.stringify(currentValues) + + // For new schedules, consider them changed if any value is set based on schedule type + if (!scheduleId) { + let hasRequiredFields = false + + switch (currentValues.scheduleType) { + case 'minutes': + hasRequiredFields = !!currentValues.minutesInterval + break + case 'hourly': + hasRequiredFields = currentValues.hourlyMinute !== '' + break + case 'daily': + hasRequiredFields = !!currentValues.dailyTime + break + case 'weekly': + hasRequiredFields = !!currentValues.weeklyDay && !!currentValues.weeklyDayTime + break + case 'monthly': + hasRequiredFields = !!currentValues.monthlyDay && !!currentValues.monthlyTime + break + case 'custom': + hasRequiredFields = !!currentValues.cronExpression + break + } + + setHasChanges(valuesChanged || hasRequiredFields) + } else { + setHasChanges(valuesChanged) + } + }, [ + isOpen, + scheduleId, + scheduleType, + scheduleStartAt, + scheduleTime, + minutesInterval, + hourlyMinute, + dailyTime, + weeklyDay, + weeklyDayTime, + monthlyDay, + monthlyTime, + timezone, + cronExpression, + initialValues, + ]) + + // Handle modal close + const handleClose = () => { + if (hasChanges) { + setShowUnsavedChangesConfirm(true) + } else { + onClose() + } + } + + // Handle confirming close despite unsaved changes + const handleConfirmClose = () => { + // Revert form values to initial values + if (hasChanges) { + setScheduleType(initialValues.scheduleType) + setScheduleStartAt(initialValues.scheduleStartAt) + setScheduleTime(initialValues.scheduleTime) + setMinutesInterval(initialValues.minutesInterval) + setHourlyMinute(initialValues.hourlyMinute) + setDailyTime(initialValues.dailyTime) + setWeeklyDay(initialValues.weeklyDay) + setWeeklyDayTime(initialValues.weeklyDayTime) + setMonthlyDay(initialValues.monthlyDay) + setMonthlyTime(initialValues.monthlyTime) + setTimezone(initialValues.timezone) + setCronExpression(initialValues.cronExpression) + } + + setShowUnsavedChangesConfirm(false) + onClose() + } + + // Handle canceling the close + const handleCancelClose = () => { + setShowUnsavedChangesConfirm(false) + } + + // Handle saving the schedule + const handleSave = async () => { + setErrorMessage(null) + setIsSaving(true) + + try { + // Validate inputs based on schedule type + if (scheduleType === 'minutes' && !minutesInterval) { + setErrorMessage('Please enter minutes interval') + setIsSaving(false) + return + } + + if (scheduleType === 'hourly' && hourlyMinute === '') { + setErrorMessage('Please enter minute of the hour') + setIsSaving(false) + return + } + + if (scheduleType === 'daily' && !dailyTime) { + setErrorMessage('Please enter time of day') + setIsSaving(false) + return + } + + if (scheduleType === 'weekly' && !weeklyDayTime) { + setErrorMessage('Please enter time of day') + setIsSaving(false) + return + } + + if (scheduleType === 'monthly' && (!monthlyDay || !monthlyTime)) { + setErrorMessage('Please enter day of month and time') + setIsSaving(false) + return + } + + if (scheduleType === 'custom' && !cronExpression) { + setErrorMessage('Please enter a cron expression') + setIsSaving(false) + return + } + + const success = await onSave() + + if (success) { + // Update initial values to match current state + const updatedValues = { + scheduleType: scheduleType || 'daily', + scheduleStartAt: scheduleStartAt || '', + scheduleTime: scheduleTime || '', + minutesInterval: minutesInterval || '', + hourlyMinute: hourlyMinute || '', + dailyTime: dailyTime || '', + weeklyDay: weeklyDay || 'MON', + weeklyDayTime: weeklyDayTime || '', + monthlyDay: monthlyDay || '', + monthlyTime: monthlyTime || '', + timezone: timezone || 'UTC', + cronExpression: cronExpression || '', + } + setInitialValues(updatedValues) + setHasChanges(false) + onClose() + } + } catch (error) { + logger.error('Error saving schedule:', { error }) + setErrorMessage('Failed to save schedule') + } finally { + setIsSaving(false) + } + } + + // Handle deleting the schedule + const handleDelete = async () => { + if (!onDelete) return + + setIsDeleting(true) + try { + const success = await onDelete() + + if (success) { + setShowDeleteConfirm(false) + onClose() + } + } catch (error) { + logger.error('Error deleting schedule:', { error }) + setErrorMessage('Failed to delete schedule') + } finally { + setIsDeleting(false) + } + } + + // Open delete confirmation dialog + const openDeleteConfirm = () => { + setShowDeleteConfirm(true) + } + + // Helper to format a date for display + const formatDate = (date: string) => { + try { + return date ? format(new Date(date), 'PPP') : 'Select date' + } catch (e) { + return 'Select date' + } + } + + return ( + <> + + +
+ +
+ Schedule Configuration + + Configure when your workflow should run automatically + +
+
+
+ + {errorMessage && ( + + {errorMessage} + + )} + +
+ {/* Common date and time fields */} +
+
+ + + + + + + setScheduleStartAt(date ? date.toISOString() : '')} + initialFocus + /> + + +
+ +
+ + +
+
+ + {/* Frequency selector */} +
+ + +
+ + {/* Minutes schedule options */} + {scheduleType === 'minutes' && ( +
+ + setMinutesInterval(e.target.value)} + placeholder="15" + type="number" + min="1" + /> +
+ )} + + {/* Hourly schedule options */} + {scheduleType === 'hourly' && ( +
+ + setHourlyMinute(e.target.value)} + placeholder="0" + type="number" + min="0" + max="59" + /> +

+ Specify which minute of each hour the workflow should run (0-59) +

+
+ )} + + {/* Daily schedule options */} + {scheduleType === 'daily' && ( +
+ + +
+ )} + + {/* Weekly schedule options */} + {scheduleType === 'weekly' && ( + <> +
+ + +
+ +
+ + +
+ + )} + + {/* Monthly schedule options */} + {scheduleType === 'monthly' && ( + <> +
+ + setMonthlyDay(e.target.value)} + placeholder="1" + type="number" + min="1" + max="31" + /> +

+ Specify which day of the month the workflow should run (1-31) +

+
+ +
+ + +
+ + )} + + {/* Custom cron options */} + {scheduleType === 'custom' && ( +
+ + setCronExpression(e.target.value)} + placeholder="*/15 * * * *" + /> +

+ Use standard cron format (e.g., "*/15 * * * *" for every 15 minutes) +

+
+ )} + + {/* Timezone configuration */} +
+ + +
+
+ + +
+ {scheduleId && onDelete && ( + + )} +
+
+ + +
+
+
+ + + + + + + Delete Schedule + + Are you sure you want to delete this schedule? This action cannot be undone. + + + + setShowDeleteConfirm(false)}> + Cancel + + + {isDeleting ? 'Deleting...' : 'Delete Schedule'} + + + + + + ) +} diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx new file mode 100644 index 0000000000..3524d6ee1e --- /dev/null +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx @@ -0,0 +1,238 @@ +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import { Calendar, ExternalLink } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Dialog } from '@/components/ui/dialog' +import { createLogger } from '@/lib/logs/console-logger' +import { formatDateTime } from '@/lib/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useSubBlockValue } from '../../hooks/use-sub-block-value' +import { ScheduleModal } from './components/schedule-modal' + +const logger = createLogger('ScheduleConfig') + +interface ScheduleConfigProps { + blockId: string + subBlockId?: string + isConnecting: boolean +} + +export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleConfigProps) { + const [error, setError] = useState(null) + const [scheduleId, setScheduleId] = useState(null) + const [nextRunAt, setNextRunAt] = useState(null) + const [lastRanAt, setLastRanAt] = useState(null) + const [cronExpression, setCronExpression] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [isModalOpen, setIsModalOpen] = useState(false) + + const params = useParams() + const workflowId = params.id as string + + // Get workflow state from store + const blocks = useWorkflowStore((state) => state.blocks) + const edges = useWorkflowStore((state) => state.edges) + const loops = useWorkflowStore((state) => state.loops) + + // Get the schedule type from the block state + const [scheduleType] = useSubBlockValue(blockId, 'scheduleType') + + // Check if schedule exists in the database + useEffect(() => { + const checkSchedule = async () => { + setIsLoading(true) + try { + // Check if there's a schedule for this workflow + const response = await fetch(`/api/scheduled?workflowId=${workflowId}`) + if (response.ok) { + const data = await response.json() + if (data.schedule) { + setScheduleId(data.schedule.id) + setNextRunAt(data.schedule.nextRunAt) + setLastRanAt(data.schedule.lastRanAt) + setCronExpression(data.schedule.cronExpression) + } else { + setScheduleId(null) + setNextRunAt(null) + setLastRanAt(null) + setCronExpression(null) + } + } + } catch (error) { + logger.error('Error checking schedule:', { error }) + setError('Failed to check schedule status') + } finally { + setIsLoading(false) + } + } + + checkSchedule() + }, [workflowId, scheduleType, isModalOpen]) // Re-check when modal closes + + // Format the schedule information for display + const getScheduleInfo = () => { + if (!scheduleId || !nextRunAt) return null + + let scheduleTiming = 'Unknown schedule' + + if (cronExpression) { + // Simple cron expression descriptions + if (cronExpression === '* * * * *') { + scheduleTiming = 'Every minute' + } else if (cronExpression.match(/^\*\/\d+ \* \* \* \*$/)) { + const minutes = cronExpression.split(' ')[0].split('/')[1] + scheduleTiming = `Every ${minutes} minutes` + } else { + scheduleTiming = `Cron: ${cronExpression}` + } + } else if (scheduleType) { + scheduleTiming = `${scheduleType.charAt(0).toUpperCase() + scheduleType.slice(1)}` + } + + return ( +
+
+ {scheduleTiming} +
+
Next run: {formatDateTime(new Date(nextRunAt))}
+ {lastRanAt &&
Last run: {formatDateTime(new Date(lastRanAt))}
} +
+ ) + } + + const handleOpenModal = () => { + setIsModalOpen(true) + } + + const handleCloseModal = () => { + setIsModalOpen(false) + } + + const handleSaveSchedule = async (): Promise => { + setIsSaving(true) + try { + // Send the complete workflow state to be saved/updated + const response = await fetch(`/api/scheduled/schedule`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workflowId, + state: { + blocks, + edges, + loops, + }, + }), + }) + + if (!response.ok) { + const data = await response.json() + setError(data.error || 'Failed to save schedule') + return false + } + + return true + } catch (error) { + logger.error('Error saving schedule:', { error }) + setError('Failed to save schedule') + return false + } finally { + setIsSaving(false) + } + } + + const handleDeleteSchedule = async (): Promise => { + if (!scheduleId) return false + + setIsDeleting(true) + try { + const response = await fetch(`/api/scheduled/${scheduleId}`, { + method: 'DELETE', + }) + + if (!response.ok) { + const data = await response.json() + setError(data.error || 'Failed to delete schedule') + return false + } + + // Clear schedule state + setScheduleId(null) + setNextRunAt(null) + setLastRanAt(null) + setCronExpression(null) + + return true + } catch (error) { + logger.error('Error deleting schedule:', { error }) + setError('Failed to delete schedule') + return false + } finally { + setIsDeleting(false) + } + } + + // Check if the schedule is active + const isScheduleActive = !!scheduleId && !!nextRunAt + + return ( +
+ {error &&
{error}
} + + {isLoading ? ( + + ) : isScheduleActive ? ( +
+
+ + + Schedule Active + + + Active + +
+ {getScheduleInfo()} +
+ ) : ( + + )} + + + + + + +
+ ) +} diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx index c3145231ff..363d7b324d 100644 --- a/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx @@ -13,6 +13,7 @@ import { EvalInput } from './components/eval-input' import { FileSelectorInput } from './components/file-selector/file-selector-input' import { FolderSelectorInput } from './components/folder-selector/components/folder-selector-input' import { LongInput } from './components/long-input' +import { ScheduleConfig } from './components/schedule/schedule-config' import { ShortInput } from './components/short-input' import { SliderInput } from './components/slider-input' import { Switch } from './components/switch' @@ -128,6 +129,10 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) { return ( ) + case 'schedule-config': + return ( + + ) case 'oauth-input': return ( ) { Disabled )} + {type === 'starter' && } {config.longDescription && ( diff --git a/sim/blocks/blocks/starter.ts b/sim/blocks/blocks/starter.ts index 5f411ceb0f..00734d6183 100644 --- a/sim/blocks/blocks/starter.ts +++ b/sim/blocks/blocks/starter.ts @@ -54,23 +54,15 @@ export const StarterBlock: BlockConfig = { layout: 'full', condition: { field: 'startWorkflow', value: 'webhook' }, }, - // Common schedule fields for all frequency types + // Schedule configuration status display { - id: 'scheduleStartAt', - title: 'Start At', - type: 'date-input', - layout: 'half', - placeholder: 'Select day', + id: 'scheduleConfig', + title: 'Schedule Status', + type: 'schedule-config', + layout: 'full', condition: { field: 'startWorkflow', value: 'schedule' }, }, - { - id: 'scheduleTime', - title: 'Time', - type: 'time-input', - layout: 'half', - condition: { field: 'startWorkflow', value: 'schedule' }, - }, - // Frequency configuration + // Hidden fields for schedule configuration (used by the modal only) { id: 'scheduleType', title: 'Frequency', @@ -85,46 +77,83 @@ export const StarterBlock: BlockConfig = { { label: 'Custom Cron', id: 'custom' }, ], value: () => 'daily', + hidden: true, + condition: { field: 'startWorkflow', value: 'schedule' }, + }, + { + id: 'scheduleStartAt', + type: 'date-input', + hidden: true, + condition: { field: 'startWorkflow', value: 'schedule' }, + }, + { + id: 'scheduleTime', + type: 'time-input', + hidden: true, condition: { field: 'startWorkflow', value: 'schedule' }, }, - // Minutes schedule options { id: 'minutesInterval', - title: 'Run Every', type: 'short-input', - layout: 'full', - placeholder: '15 minutes', - condition: { - field: 'scheduleType', - value: 'minutes', - and: { - field: 'startWorkflow', - value: 'schedule', - }, - }, + hidden: true, + condition: { field: 'startWorkflow', value: 'schedule' }, + }, + { + id: 'hourlyMinute', + type: 'short-input', + hidden: true, + condition: { field: 'startWorkflow', value: 'schedule' }, + }, + { + id: 'dailyTime', + type: 'short-input', + hidden: true, + condition: { field: 'startWorkflow', value: 'schedule' }, + }, + { + id: 'weeklyDay', + type: 'dropdown', + hidden: true, + options: [ + { label: 'Monday', id: 'MON' }, + { label: 'Tuesday', id: 'TUE' }, + { label: 'Wednesday', id: 'WED' }, + { label: 'Thursday', id: 'THU' }, + { label: 'Friday', id: 'FRI' }, + { label: 'Saturday', id: 'SAT' }, + { label: 'Sunday', id: 'SUN' }, + ], + value: () => 'MON', + condition: { field: 'startWorkflow', value: 'schedule' }, + }, + { + id: 'weeklyDayTime', + type: 'short-input', + hidden: true, + condition: { field: 'startWorkflow', value: 'schedule' }, + }, + { + id: 'monthlyDay', + type: 'short-input', + hidden: true, + condition: { field: 'startWorkflow', value: 'schedule' }, + }, + { + id: 'monthlyTime', + type: 'short-input', + hidden: true, + condition: { field: 'startWorkflow', value: 'schedule' }, }, - // Custom cron options { id: 'cronExpression', - title: 'Cron Expression', type: 'short-input', - layout: 'full', - placeholder: '*/15 * * * *', - condition: { - field: 'scheduleType', - value: 'custom', - and: { - field: 'startWorkflow', - value: 'schedule', - }, - }, + hidden: true, + condition: { field: 'startWorkflow', value: 'schedule' }, }, - // Timezone configuration (for all schedule types) { id: 'timezone', - title: 'Timezone', type: 'dropdown', - layout: 'full', + hidden: true, options: [ { label: 'UTC', id: 'UTC' }, { label: 'US Eastern (UTC-4)', id: 'America/New_York' }, diff --git a/sim/blocks/types.ts b/sim/blocks/types.ts index 3edd1191fa..33e332dbd7 100644 --- a/sim/blocks/types.ts +++ b/sim/blocks/types.ts @@ -27,6 +27,7 @@ export type SubBlockType = | 'time-input' // Time input | 'oauth-input' // OAuth credential selector | 'webhook-config' // Webhook configuration + | 'schedule-config' // Schedule status and information | 'file-selector' // File selector for Google Drive, etc. | 'folder-selector' // Folder selector for Gmail, etc. diff --git a/sim/lib/utils.ts b/sim/lib/utils.ts index 8355b3317c..e5a657a2c7 100644 --- a/sim/lib/utils.ts +++ b/sim/lib/utils.ts @@ -134,3 +134,71 @@ export async function validateApiKey( if (!apiKey || !storedApiKey) return false return apiKey === storedApiKey } + +/** + * Format a date into a human-readable format + * @param date - The date to format + * @returns A formatted date string in the format "MMM D, YYYY h:mm A" + */ +export function formatDateTime(date: Date): string { + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) +} + +/** + * Format a date into a short format + * @param date - The date to format + * @returns A formatted date string in the format "MMM D, YYYY" + */ +export function formatDate(date: Date): string { + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +/** + * Format a time into a short format + * @param date - The date to format + * @returns A formatted time string in the format "h:mm A" + */ +export function formatTime(date: Date): string { + return date.toLocaleString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) +} + +/** + * Format a duration in milliseconds to a human-readable format + * @param durationMs - The duration in milliseconds + * @returns A formatted duration string + */ +export function formatDuration(durationMs: number): string { + if (durationMs < 1000) { + return `${durationMs}ms` + } + + const seconds = Math.floor(durationMs / 1000) + if (seconds < 60) { + return `${seconds}s` + } + + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + if (minutes < 60) { + return `${minutes}m ${remainingSeconds}s` + } + + const hours = Math.floor(minutes / 60) + const remainingMinutes = minutes % 60 + return `${hours}h ${remainingMinutes}m` +} diff --git a/sim/stores/workflows/sync.ts b/sim/stores/workflows/sync.ts index 5360613173..22eca6f40a 100644 --- a/sim/stores/workflows/sync.ts +++ b/sim/stores/workflows/sync.ts @@ -18,9 +18,6 @@ let loadingFromDBToken: string | null = null let loadingFromDBStartTime = 0 const LOADING_TIMEOUT = 3000 // 3 seconds maximum loading time -// Track workflows that had scheduling enabled in previous syncs -const scheduledWorkflows = new Set() - /** * Checks if the system is currently in the process of loading data from the database * Includes safety timeout to prevent permanent blocking of syncs @@ -39,67 +36,6 @@ export function isActivelyLoadingFromDB(): boolean { return true } -/** - * Checks if a workflow has scheduling enabled - * @param blocks The workflow blocks - * @returns true if scheduling is enabled, false otherwise - */ -function hasSchedulingEnabled(blocks: Record): boolean { - // Find the starter block - const starterBlock = Object.values(blocks).find((block) => block.type === 'starter') - if (!starterBlock) return false - - // Check if the startWorkflow value is 'schedule' - const startWorkflow = starterBlock.subBlocks.startWorkflow?.value - return startWorkflow === 'schedule' -} - -/** - * Updates or cancels the schedule for a workflow based on its current configuration - * @param workflowId The workflow ID - * @param state The workflow state - * @returns A promise that resolves when the schedule update is complete - */ -async function updateWorkflowSchedule(workflowId: string, state: any): Promise { - try { - const isScheduleEnabled = hasSchedulingEnabled(state.blocks) - - // Always call the schedule API to either update or cancel the schedule - // The API will handle the logic to create, update, or delete the schedule - const response = await fetch(API_ENDPOINTS.SCHEDULE, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId, - state, - }), - }) - - if (!response.ok) { - logger.error( - `Failed to ${isScheduleEnabled ? 'update' : 'cancel'} schedule for workflow ${workflowId}:`, - response.statusText - ) - return - } - - const result = await response.json() - - // Update our tracking of scheduled workflows - if (isScheduleEnabled) { - scheduledWorkflows.add(workflowId) - logger.info(`Schedule updated for workflow ${workflowId}:`, result) - } else { - scheduledWorkflows.delete(workflowId) - logger.info(`Schedule cancelled for workflow ${workflowId}:`, result) - } - } catch (error) { - logger.error(`Error managing schedule for workflow ${workflowId}:`, { - error, - }) - } -} - /** * Fetches workflows from the database and updates the local stores * This function handles backwards syncing on initialization @@ -207,11 +143,6 @@ export async function fetchWorkflowsFromDB(): Promise { if (id === activeWorkflowId) { useWorkflowStore.setState(workflowState) } - - // 7. Track if this workflow has scheduling enabled - if (hasSchedulingEnabled(workflowState.blocks)) { - scheduledWorkflows.add(id) - } }) // 8. Update registry store with all workflows @@ -296,35 +227,5 @@ export const workflowSync = createSingletonSyncManager('workflow-sync', () => ({ syncOnExit: true, onSyncSuccess: async (data) => { logger.info('Workflows synced to DB successfully') - - // After successful sync to DB, update schedules for all workflows - try { - const workflowsData = getAllWorkflowsWithValues() - const currentWorkflowIds = new Set(Object.keys(workflowsData)) - - // Process each workflow to update its schedule if needed - const schedulePromises = Object.entries(workflowsData).map(async ([id, workflow]) => { - const isCurrentlyScheduled = hasSchedulingEnabled(workflow.state.blocks) - const wasScheduledBefore = scheduledWorkflows.has(id) - - // Only update schedule if the scheduling status has changed or it's currently scheduled - // This ensures we update schedules when they change and cancel them when they're disabled - if (isCurrentlyScheduled || wasScheduledBefore) { - await updateWorkflowSchedule(id, workflow.state) - } - }) - - // Wait for all schedule updates to complete - await Promise.all(schedulePromises) - - // Clean up tracking for workflows that no longer exist - for (const id of scheduledWorkflows) { - if (!currentWorkflowIds.has(id)) { - scheduledWorkflows.delete(id) - } - } - } catch (error) { - logger.error('Error updating workflow schedules:', { error }) - } }, })) diff --git a/vercel.json b/sim/vercel.json similarity index 100% rename from vercel.json rename to sim/vercel.json