mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-24 11:36:37 +08:00
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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+611
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+238
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user