update styling for schedules to show visual indication that schedule is active, fix cron job to execute scheduled workflows not running (#147)

This commit is contained in:
Waleed Latif
2025-03-22 18:11:55 -07:00
parent 44963d8a29
commit a847295635
13 changed files with 1188 additions and 145 deletions
+65
View File
@@ -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 })
}
}
+4 -5
View File
@@ -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<string>()
// 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()
+48
View File
@@ -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 })
}
}
@@ -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<string | null>(null)
const [nextRunAt, setNextRunAt] = useState<string | null>(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 (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="ml-auto bg-green-100 text-green-700 border-green-200
dark:bg-green-900 dark:text-green-300 dark:border-green-800
px-2 py-0.5 flex items-center"
>
<Calendar className="h-3.5 w-3.5 mr-1" />
<span className="text-xs">Active</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="right">
<div className="text-xs">
<div>
<strong>Schedule Active</strong>
</div>
<div>Next run: {formatDateTime(new Date(nextRunAt))}</div>
</div>
</TooltipContent>
</Tooltip>
)
}
@@ -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<boolean>
onDelete?: () => Promise<boolean>
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<string | null>(null)
// Simpler approach - we'll use this to store the initial values when the modal opens
const [initialValues, setInitialValues] = useState<Record<string, any>>({})
// 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 (
<>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<div className="flex items-center">
<Calendar className="h-5 w-5 mr-3 text-blue-500" />
<div>
<DialogTitle>Schedule Configuration</DialogTitle>
<DialogDescription>
Configure when your workflow should run automatically
</DialogDescription>
</div>
</div>
</DialogHeader>
{errorMessage && (
<Alert variant="destructive" className="my-2">
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 py-4">
{/* Common date and time fields */}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<label htmlFor="scheduleStartAt" className="text-sm font-medium">
Start At
</label>
<Popover>
<PopoverTrigger asChild>
<Button
id="scheduleStartAt"
variant="outline"
className="justify-start text-left font-normal"
>
<Calendar className="mr-2 h-4 w-4" />
{formatDate(scheduleStartAt || '')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="single"
selected={scheduleStartAt ? new Date(scheduleStartAt) : undefined}
onSelect={(date) => setScheduleStartAt(date ? date.toISOString() : '')}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="grid gap-2">
<label htmlFor="scheduleTime" className="text-sm font-medium">
Time
</label>
<TimeInput blockId={blockId} subBlockId="scheduleTime" placeholder="Select time" />
</div>
</div>
{/* Frequency selector */}
<div className="grid gap-2">
<label htmlFor="scheduleType" className="text-sm font-medium">
Frequency
</label>
<Select
value={scheduleType || 'daily'}
onValueChange={(value) => setScheduleType(value)}
>
<SelectTrigger>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Every X Minutes</SelectItem>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="custom">Custom Cron</SelectItem>
</SelectContent>
</Select>
</div>
{/* Minutes schedule options */}
{scheduleType === 'minutes' && (
<div className="grid gap-2">
<label htmlFor="minutesInterval" className="text-sm font-medium">
Run Every (minutes)
</label>
<Input
id="minutesInterval"
value={minutesInterval || ''}
onChange={(e) => setMinutesInterval(e.target.value)}
placeholder="15"
type="number"
min="1"
/>
</div>
)}
{/* Hourly schedule options */}
{scheduleType === 'hourly' && (
<div className="grid gap-2">
<label htmlFor="hourlyMinute" className="text-sm font-medium">
Minute of the Hour
</label>
<Input
id="hourlyMinute"
value={hourlyMinute || ''}
onChange={(e) => setHourlyMinute(e.target.value)}
placeholder="0"
type="number"
min="0"
max="59"
/>
<p className="text-xs text-muted-foreground">
Specify which minute of each hour the workflow should run (0-59)
</p>
</div>
)}
{/* Daily schedule options */}
{scheduleType === 'daily' && (
<div className="grid gap-2">
<label htmlFor="dailyTime" className="text-sm font-medium">
Time of Day
</label>
<TimeInput blockId={blockId} subBlockId="dailyTime" placeholder="Select time" />
</div>
)}
{/* Weekly schedule options */}
{scheduleType === 'weekly' && (
<>
<div className="grid gap-2">
<label htmlFor="weeklyDay" className="text-sm font-medium">
Day of Week
</label>
<Select value={weeklyDay || 'MON'} onValueChange={(value) => setWeeklyDay(value)}>
<SelectTrigger>
<SelectValue placeholder="Select day" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MON">Monday</SelectItem>
<SelectItem value="TUE">Tuesday</SelectItem>
<SelectItem value="WED">Wednesday</SelectItem>
<SelectItem value="THU">Thursday</SelectItem>
<SelectItem value="FRI">Friday</SelectItem>
<SelectItem value="SAT">Saturday</SelectItem>
<SelectItem value="SUN">Sunday</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<label htmlFor="weeklyDayTime" className="text-sm font-medium">
Time of Day
</label>
<TimeInput blockId={blockId} subBlockId="weeklyDayTime" placeholder="Select time" />
</div>
</>
)}
{/* Monthly schedule options */}
{scheduleType === 'monthly' && (
<>
<div className="grid gap-2">
<label htmlFor="monthlyDay" className="text-sm font-medium">
Day of Month
</label>
<Input
id="monthlyDay"
value={monthlyDay || ''}
onChange={(e) => setMonthlyDay(e.target.value)}
placeholder="1"
type="number"
min="1"
max="31"
/>
<p className="text-xs text-muted-foreground">
Specify which day of the month the workflow should run (1-31)
</p>
</div>
<div className="grid gap-2">
<label htmlFor="monthlyTime" className="text-sm font-medium">
Time of Day
</label>
<TimeInput blockId={blockId} subBlockId="monthlyTime" placeholder="Select time" />
</div>
</>
)}
{/* Custom cron options */}
{scheduleType === 'custom' && (
<div className="grid gap-2">
<label htmlFor="cronExpression" className="text-sm font-medium">
Cron Expression
</label>
<Input
id="cronExpression"
value={cronExpression || ''}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="*/15 * * * *"
/>
<p className="text-xs text-muted-foreground mt-1">
Use standard cron format (e.g., "*/15 * * * *" for every 15 minutes)
</p>
</div>
)}
{/* Timezone configuration */}
<div className="grid gap-2">
<label htmlFor="timezone" className="text-sm font-medium">
Timezone
</label>
<Select value={timezone || 'UTC'} onValueChange={(value) => setTimezone(value)}>
<SelectTrigger>
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
<SelectContent>
<SelectItem value="UTC">UTC</SelectItem>
<SelectItem value="America/New_York">US Eastern (UTC-4)</SelectItem>
<SelectItem value="America/Chicago">US Central (UTC-5)</SelectItem>
<SelectItem value="America/Denver">US Mountain (UTC-6)</SelectItem>
<SelectItem value="America/Los_Angeles">US Pacific (UTC-7)</SelectItem>
<SelectItem value="Europe/London">London (UTC+1)</SelectItem>
<SelectItem value="Europe/Paris">Paris (UTC+2)</SelectItem>
<SelectItem value="Asia/Singapore">Singapore (UTC+8)</SelectItem>
<SelectItem value="Asia/Tokyo">Tokyo (UTC+9)</SelectItem>
<SelectItem value="Australia/Sydney">Sydney (UTC+10)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="flex justify-between">
<div>
{scheduleId && onDelete && (
<Button
type="button"
variant="destructive"
onClick={openDeleteConfirm}
disabled={isDeleting || isSaving}
>
<Trash2 className="h-4 w-4 mr-2" />
{isDeleting ? 'Deleting...' : 'Delete Schedule'}
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || isSaving}
className={hasChanges ? 'bg-primary hover:bg-primary/90' : ''}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</DialogFooter>
</DialogContent>
<UnsavedChangesDialog
open={showUnsavedChangesConfirm}
setOpen={setShowUnsavedChangesConfirm}
onCancel={handleCancelClose}
onConfirm={handleConfirmClose}
/>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Schedule</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this schedule? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowDeleteConfirm(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">
{isDeleting ? 'Deleting...' : 'Delete Schedule'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -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<string | null>(null)
const [scheduleId, setScheduleId] = useState<string | null>(null)
const [nextRunAt, setNextRunAt] = useState<string | null>(null)
const [lastRanAt, setLastRanAt] = useState<string | null>(null)
const [cronExpression, setCronExpression] = useState<string | null>(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 (
<div className="text-xs text-muted-foreground">
<div className="flex items-center">
<span className="font-medium">{scheduleTiming}</span>
</div>
<div>Next run: {formatDateTime(new Date(nextRunAt))}</div>
{lastRanAt && <div>Last run: {formatDateTime(new Date(lastRanAt))}</div>}
</div>
)
}
const handleOpenModal = () => {
setIsModalOpen(true)
}
const handleCloseModal = () => {
setIsModalOpen(false)
}
const handleSaveSchedule = async (): Promise<boolean> => {
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<boolean> => {
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 (
<div className="mt-2 space-y-2">
{error && <div className="text-sm text-red-500 dark:text-red-400 mb-2">{error}</div>}
{isLoading ? (
<Button variant="outline" size="sm" className="w-full" disabled={true}>
Checking schedule...
</Button>
) : isScheduleActive ? (
<div className="bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 rounded-md p-2 relative">
<div className="flex items-center mb-1">
<Calendar className="h-4 w-4 mr-2 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-green-700 dark:text-green-400">
Schedule Active
</span>
<Badge
variant="outline"
className="ml-auto bg-green-100 text-green-700 border-green-200 dark:bg-green-900 dark:text-green-300 dark:border-green-800"
>
Active
</Badge>
</div>
{getScheduleInfo()}
</div>
) : (
<Button variant="outline" size="sm" className="w-full" disabled={isConnecting}>
<Calendar className="h-4 w-4 mr-2" />
No schedule configured
</Button>
)}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleOpenModal}
disabled={isConnecting || isSaving || isDeleting}
>
<ExternalLink className="h-4 w-4 mr-2" />
{isSaving ? 'Saving...' : isDeleting ? 'Deleting...' : 'Configure Schedule'}
</Button>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<ScheduleModal
isOpen={isModalOpen}
onClose={handleCloseModal}
workflowId={workflowId}
blockId={blockId}
onSave={handleSaveSchedule}
onDelete={handleDeleteSchedule}
scheduleId={scheduleId}
/>
</Dialog>
</div>
)
}
@@ -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 (
<WebhookConfig blockId={blockId} subBlockId={config.id} isConnecting={isConnecting} />
)
case 'schedule-config':
return (
<ScheduleConfig blockId={blockId} subBlockId={config.id} isConnecting={isConnecting} />
)
case 'oauth-input':
return (
<CredentialSelector
@@ -12,6 +12,7 @@ import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { ActionBar } from './components/action-bar/action-bar'
import { ScheduleStatus } from './components/action-bar/schedule-status'
import { ConnectionBlocks } from './components/connection-blocks/connection-blocks'
import { SubBlock } from './components/sub-block/sub-block'
@@ -266,6 +267,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
Disabled
</Badge>
)}
{type === 'starter' && <ScheduleStatus blockId={id} />}
{config.longDescription && (
<Tooltip>
<TooltipTrigger asChild>
+70 -41
View File
@@ -54,23 +54,15 @@ export const StarterBlock: BlockConfig<StarterBlockOutput> = {
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<StarterBlockOutput> = {
{ 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' },
+1
View File
@@ -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.
+68
View File
@@ -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`
}
-99
View File
@@ -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<string>()
/**
* 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<string, BlockState>): 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<void> {
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<void> {
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 })
}
},
}))
View File