improvement: schedule, files

This commit is contained in:
Emir Karabeg
2025-04-05 06:38:39 -07:00
parent b765b436be
commit 965b06664d
16 changed files with 564 additions and 432 deletions

View File

@@ -8,6 +8,10 @@ import { workflowSchedule } from '@/db/schema'
const logger = createLogger('ScheduledAPI')
// Track recent requests to reduce redundant logging
const recentRequests = new Map<string, number>();
const LOGGING_THROTTLE_MS = 5000; // 5 seconds between logging for the same workflow
/**
* Get schedule information for a workflow
*/
@@ -15,6 +19,12 @@ 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')
const mode = url.searchParams.get('mode')
// Skip processing if mode is provided and not 'schedule'
if (mode && mode !== 'schedule') {
return NextResponse.json({ schedule: null })
}
try {
const session = await getSession()
@@ -27,7 +37,15 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Missing workflowId parameter' }, { status: 400 })
}
logger.info(`[${requestId}] Getting schedule for workflow ${workflowId}`)
// Check if we should log this request (throttle logging for repeat requests)
const now = Date.now();
const lastLog = recentRequests.get(workflowId) || 0;
const shouldLog = now - lastLog > LOGGING_THROTTLE_MS;
if (shouldLog) {
logger.info(`[${requestId}] Getting schedule for workflow ${workflowId}`)
recentRequests.set(workflowId, now);
}
// Find the schedule for this workflow
const schedule = await db
@@ -36,11 +54,15 @@ export async function GET(req: NextRequest) {
.where(eq(workflowSchedule.workflowId, workflowId))
.limit(1)
// Set cache control headers to reduce repeated API calls
const headers = new Headers();
headers.set('Cache-Control', 'max-age=30'); // Cache for 30 seconds
if (schedule.length === 0) {
return NextResponse.json({ schedule: null })
return NextResponse.json({ schedule: null }, { headers })
}
return NextResponse.json({ schedule: schedule[0] })
return NextResponse.json({ schedule: schedule[0] }, { headers })
} catch (error) {
logger.error(`[${requestId}] Error retrieving workflow schedule`, error)
return NextResponse.json({ error: 'Failed to retrieve workflow schedule' }, { status: 500 })

View File

@@ -54,17 +54,54 @@ export async function POST(req: NextRequest) {
}
const startWorkflow = getSubBlockValue(starterBlock, 'startWorkflow')
const scheduleType = getSubBlockValue(starterBlock, 'scheduleType')
// If the workflow is not scheduled, delete any existing schedule
if (startWorkflow !== 'schedule') {
logger.info(`[${requestId}] Removing schedule for workflow ${workflowId}`)
// Check if there's a valid schedule configuration
const hasScheduleConfig = (() => {
const getValue = (id: string): string => {
const value = getSubBlockValue(starterBlock, id);
return value && value.trim() !== '' ? value : '';
};
if (scheduleType === 'minutes' && getValue('minutesInterval')) {
return true;
}
if (scheduleType === 'hourly' && getValue('hourlyMinute') !== '') {
return true;
}
if (scheduleType === 'daily' && getValue('dailyTime')) {
return true;
}
if (scheduleType === 'weekly' && getValue('weeklyDay') &&
getValue('weeklyDayTime')) {
return true;
}
if (scheduleType === 'monthly' && getValue('monthlyDay') &&
getValue('monthlyTime')) {
return true;
}
if (scheduleType === 'custom' && getValue('cronExpression')) {
return true;
}
return false;
})();
// If the workflow is not configured for scheduling, delete any existing schedule
if (startWorkflow !== 'schedule' && !hasScheduleConfig) {
logger.info(`[${requestId}] Removing schedule for workflow ${workflowId} - no valid configuration found`)
await db.delete(workflowSchedule).where(eq(workflowSchedule.workflowId, workflowId))
return NextResponse.json({ message: 'Schedule removed' })
}
// If we're here, we either have startWorkflow === 'schedule' or hasScheduleConfig is true
if (startWorkflow !== 'schedule') {
logger.info(`[${requestId}] Setting workflow to scheduled mode based on schedule configuration`)
// The UI should handle this, but as a fallback we'll assume the user intended to schedule
// the workflow even if startWorkflow wasn't set properly
}
// Get schedule configuration from starter block
const scheduleType = getSubBlockValue(starterBlock, 'scheduleType')
logger.debug(`[${requestId}] Schedule type for workflow ${workflowId}: ${scheduleType}`)
// Calculate cron expression based on schedule type

View File

@@ -451,6 +451,11 @@ export function MarketplaceModal({ open, onOpenChange }: MarketplaceModalProps)
disabled={isUnpublishing}
className="gap-2"
>
{isUnpublishing ? (
<div className="h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent mr-2" />
) : (
<Trash className="h-4 w-4 mr-2" />
)}
{isUnpublishing ? 'Unpublishing...' : 'Unpublish'}
</Button>
</div>

View File

@@ -1,76 +0,0 @@
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/schedules?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>
)
}

View File

@@ -309,7 +309,6 @@ export function FileUpload({
useSubBlockStore.getState().setValue(blockId, subBlockId, null)
}
addNotification('console', `${file.name} was deleted successfully`, activeWorkflowId)
useWorkflowStore.getState().triggerUpdate()
} catch (error) {
addNotification(
@@ -387,19 +386,6 @@ export function FileUpload({
}
}
// Show a single consolidated notification about the deletions
if (deletionResults.success > 0) {
if (fileCount === 1) {
addNotification('console', `File was deleted successfully`, activeWorkflowId)
} else {
addNotification(
'console',
`${deletionResults.success} of ${fileCount} files were deleted successfully`,
activeWorkflowId
)
}
}
// Show error notification if any deletions failed
if (deletionResults.failures.length > 0) {
if (deletionResults.failures.length === 1) {
@@ -427,10 +413,10 @@ export function FileUpload({
return (
<div
key={file.path}
className="flex items-center justify-between p-2 rounded border border-border bg-secondary/30"
className="flex items-center justify-between px-3 py-2 rounded border border-border bg-background"
>
<div className="flex-1 truncate pr-2">
<div className="font-medium text-sm truncate">{file.name}</div>
<div className="font-normal text-sm truncate">{file.name}</div>
<div className="text-xs text-muted-foreground">{formatFileSize(file.size)}</div>
</div>
<Button
@@ -442,7 +428,7 @@ export function FileUpload({
disabled={isDeleting}
>
{isDeleting ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<div className="h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent" />
) : (
<X className="h-4 w-4" />
)}
@@ -456,14 +442,14 @@ export function FileUpload({
return (
<div
key={file.id}
className="flex items-center justify-between p-2 rounded border border-border bg-secondary/30"
className="flex items-center justify-between px-3 py-2 rounded border border-border bg-background"
>
<div className="flex-1 truncate pr-2">
<div className="font-medium text-sm truncate">{file.name}</div>
<div className="font-normal text-sm truncate">{file.name}</div>
<div className="text-xs text-muted-foreground">{formatFileSize(file.size)}</div>
</div>
<div className="flex items-center justify-center h-8 w-8 shrink-0">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<div className="h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent" />
</div>
</div>
)
@@ -486,10 +472,10 @@ export function FileUpload({
data-testid="file-input-element"
/>
<div>
<div className="bg-background">
{/* File list with consistent spacing */}
{(hasFiles || isUploading) && (
<div className="space-y-2">
<div className="space-y-2 mb-2">
{/* Only show files that aren't currently uploading */}
{filesArray.map((file) => {
// Don't show files that have duplicates in the uploading list
@@ -514,12 +500,12 @@ export function FileUpload({
{/* Action buttons */}
{(hasFiles || isUploading) && (
<div className="flex space-x-2 mt-2">
<div className="flex space-x-2">
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
className="flex-1 h-10 text-sm font-normal"
onClick={handleRemoveAllFiles}
disabled={isUploading}
>
@@ -530,7 +516,7 @@ export function FileUpload({
type="button"
variant="outline"
size="sm"
className="flex-1"
className="flex-1 h-10 text-sm font-normal"
onClick={handleOpenFileDialog}
>
Add More
@@ -546,22 +532,15 @@ export function FileUpload({
<Button
type="button"
variant="outline"
className="w-full justify-center text-center font-normal"
className="w-full justify-center text-center h-10 bg-background text-sm font-normal"
onClick={handleOpenFileDialog}
>
<Upload className="mr-2 h-4 w-4" />
{multiple ? 'Upload Files' : 'Upload File'}
<div className="flex items-center justify-center gap-2 w-full">
{/* <Upload className="h-4 w-4" /> */}
<span>{multiple ? 'Upload Files' : 'Upload File'}</span>
<span className="text-xs text-muted-foreground">({maxSize}MB max)</span>
</div>
</Button>
<Tooltip>
<TooltipTrigger className="ml-2">
<Info className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Max file size: {maxSize}MB</p>
{multiple && <p>You can select multiple files at once</p>}
</TooltipContent>
</Tooltip>
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { format } from 'date-fns'
import { Calendar, Trash2 } from 'lucide-react'
import { Trash2, X } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
@@ -14,13 +14,7 @@ import {
} 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 { DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
@@ -31,6 +25,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { UnsavedChangesDialog } from '../../../components/webhook/components/ui/confirmation'
import { useSubBlockValue } from '../../../hooks/use-sub-block-value'
import { TimeInput } from '../../time-input'
@@ -70,6 +65,9 @@ export function ScheduleModal({
const [cronExpression, setCronExpression] = useSubBlockValue(blockId, 'cronExpression')
const [timezone, setTimezone] = useSubBlockValue(blockId, 'timezone')
// Get the startWorkflow value at the component level
const [startWorkflow, setStartWorkflow] = useSubBlockValue(blockId, 'startWorkflow')
// UI states
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
@@ -254,6 +252,18 @@ export function ScheduleModal({
return
}
// Make sure the block's startWorkflow field is set to 'schedule'
// This is critical to ensure the schedule is actually enabled
logger.debug('Current startWorkflow value:', startWorkflow)
if (startWorkflow !== 'schedule') {
logger.debug('Setting startWorkflow to schedule')
setStartWorkflow('schedule')
// Give the state time to update
await new Promise((resolve) => setTimeout(resolve, 100))
}
const success = await onSave()
if (success) {
@@ -272,6 +282,7 @@ export function ScheduleModal({
timezone: timezone || 'UTC',
cronExpression: cronExpression || '',
}
logger.debug('Schedule saved successfully, updating initial values', updatedValues)
setInitialValues(updatedValues)
setHasChanges(false)
onClose()
@@ -320,263 +331,291 @@ export function ScheduleModal({
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>
<DialogContent className="sm:max-w-[600px] flex flex-col p-0 gap-0" hideCloseButton>
<DialogHeader className="px-6 py-4 border-b">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-medium">Schedule Configuration</DialogTitle>
<Button variant="ghost" size="icon" className="h-8 w-8 p-0" onClick={handleClose}>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</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>
<div className="pt-4 px-6 pb-6 overflow-y-auto">
{errorMessage && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
{/* 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
<div className="space-y-6">
{/* Common date and time fields */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label htmlFor="scheduleStartAt" className="text-sm font-medium">
Start At
</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>
<Popover>
<PopoverTrigger asChild>
<Button
id="scheduleStartAt"
variant="outline"
className="justify-start text-left font-normal w-full h-10"
>
{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="weeklyDayTime" className="text-sm font-medium">
Time of Day
<div className="space-y-1">
<label htmlFor="scheduleTime" className="text-sm font-medium">
Time
</label>
<TimeInput blockId={blockId} subBlockId="weeklyDayTime" placeholder="Select time" />
<TimeInput
blockId={blockId}
subBlockId="scheduleTime"
placeholder="Select time"
className="h-10"
/>
</div>
</>
)}
</div>
{/* Monthly schedule options */}
{scheduleType === 'monthly' && (
<>
<div className="grid gap-2">
<label htmlFor="monthlyDay" className="text-sm font-medium">
Day of Month
{/* Frequency selector */}
<div className="space-y-1">
<label htmlFor="scheduleType" className="text-sm font-medium">
Frequency
</label>
<Select
value={scheduleType || 'daily'}
onValueChange={(value) => setScheduleType(value)}
>
<SelectTrigger className="h-10">
<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="space-y-1">
<label htmlFor="minutesInterval" className="text-sm font-medium">
Run Every (minutes)
</label>
<Input
id="monthlyDay"
value={monthlyDay || ''}
onChange={(e) => setMonthlyDay(e.target.value)}
placeholder="1"
id="minutesInterval"
value={minutesInterval || ''}
onChange={(e) => setMinutesInterval(e.target.value)}
placeholder="15"
type="number"
min="1"
max="31"
className="h-10"
/>
</div>
)}
{/* Hourly schedule options */}
{scheduleType === 'hourly' && (
<div className="space-y-1">
<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"
className="h-10"
/>
<p className="text-xs text-muted-foreground">
Specify which day of the month the workflow should run (1-31)
Specify which minute of each hour the workflow should run (0-59)
</p>
</div>
)}
<div className="grid gap-2">
<label htmlFor="monthlyTime" className="text-sm font-medium">
{/* Daily schedule options */}
{scheduleType === 'daily' && (
<div className="space-y-1">
<label htmlFor="dailyTime" className="text-sm font-medium">
Time of Day
</label>
<TimeInput blockId={blockId} subBlockId="monthlyTime" placeholder="Select time" />
<TimeInput
blockId={blockId}
subBlockId="dailyTime"
placeholder="Select time"
className="h-10"
/>
</div>
</>
)}
)}
{/* Custom cron options */}
{scheduleType === 'custom' && (
<div className="grid gap-2">
<label htmlFor="cronExpression" className="text-sm font-medium">
Cron Expression
{/* Weekly schedule options */}
{scheduleType === 'weekly' && (
<div className="space-y-4">
<div className="space-y-1">
<label htmlFor="weeklyDay" className="text-sm font-medium">
Day of Week
</label>
<Select value={weeklyDay || 'MON'} onValueChange={(value) => setWeeklyDay(value)}>
<SelectTrigger className="h-10">
<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="space-y-1">
<label htmlFor="weeklyDayTime" className="text-sm font-medium">
Time of Day
</label>
<TimeInput
blockId={blockId}
subBlockId="weeklyDayTime"
placeholder="Select time"
className="h-10"
/>
</div>
</div>
)}
{/* Monthly schedule options */}
{scheduleType === 'monthly' && (
<div className="space-y-4">
<div className="space-y-1">
<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"
className="h-10"
/>
<p className="text-xs text-muted-foreground">
Specify which day of the month the workflow should run (1-31)
</p>
</div>
<div className="space-y-1">
<label htmlFor="monthlyTime" className="text-sm font-medium">
Time of Day
</label>
<TimeInput
blockId={blockId}
subBlockId="monthlyTime"
placeholder="Select time"
className="h-10"
/>
</div>
</div>
)}
{/* Custom cron options */}
{scheduleType === 'custom' && (
<div className="space-y-1">
<label htmlFor="cronExpression" className="text-sm font-medium">
Cron Expression
</label>
<Input
id="cronExpression"
value={cronExpression || ''}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="*/15 * * * *"
className="h-10"
/>
<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="space-y-1">
<label htmlFor="timezone" className="text-sm font-medium">
Timezone
</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>
<Select value={timezone || 'UTC'} onValueChange={(value) => setTimezone(value)}>
<SelectTrigger className="h-10">
<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>
)}
{/* 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'}
<DialogFooter className="px-6 pt-0 pb-6 w-full">
<div className="flex w-full justify-between">
<div>
{scheduleId && onDelete && (
<Button
type="button"
variant="destructive"
onClick={openDeleteConfirm}
disabled={isDeleting || isSaving}
size="default"
className="h-10"
>
<Trash2 className="h-4 w-4 mr-2" />
{isDeleting ? 'Deleting...' : 'Delete Schedule'}
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose} size="default" className="h-10">
Cancel
</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>
<Button
onClick={handleSave}
disabled={!hasChanges || isSaving}
className={cn('h-10', hasChanges ? 'bg-primary hover:bg-primary/90' : '')}
size="default"
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>

View File

@@ -1,7 +1,6 @@
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'
@@ -24,10 +23,12 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
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 [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
// Track when we need to force a refresh of schedule data
const [refreshCounter, setRefreshCounter] = useState(0)
const params = useParams()
const workflowId = params.id as string
@@ -36,41 +37,66 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
const blocks = useWorkflowStore((state) => state.blocks)
const edges = useWorkflowStore((state) => state.edges)
const loops = useWorkflowStore((state) => state.loops)
const triggerUpdate = useWorkflowStore((state) => state.triggerUpdate)
const setScheduleStatus = useWorkflowStore((state) => state.setScheduleStatus)
// 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/schedules?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)
}
}
// Get the startWorkflow value to determine if scheduling is enabled
// and expose the setter so we can update it
const [startWorkflow, setStartWorkflow] = useSubBlockValue(blockId, 'startWorkflow')
const isScheduleEnabled = startWorkflow === 'schedule'
// Function to check if schedule exists in the database
const checkSchedule = async () => {
setIsLoading(true)
try {
// Check if there's a schedule for this workflow, passing the mode parameter
const response = await fetch(`/api/schedules?workflowId=${workflowId}&mode=schedule`, {
// Add cache: 'no-store' to prevent caching of this request
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
})
if (response.ok) {
const data = await response.json()
logger.debug(`Schedule check response:`, data)
if (data.schedule) {
setScheduleId(data.schedule.id)
setNextRunAt(data.schedule.nextRunAt)
setLastRanAt(data.schedule.lastRanAt)
setCronExpression(data.schedule.cronExpression)
// Set active schedule flag to true since we found an active schedule
setScheduleStatus(true)
} else {
setScheduleId(null)
setNextRunAt(null)
setLastRanAt(null)
setCronExpression(null)
// Set active schedule flag to false since no schedule was found
setScheduleStatus(false)
}
}
} catch (error) {
logger.error('Error checking schedule:', { error })
setError('Failed to check schedule status')
} finally {
setIsLoading(false)
}
}
// Check for schedule on mount and when relevant dependencies change
useEffect(() => {
// Always check for schedules regardless of the UI setting
// This ensures we detect schedules even when the UI is set to manual
checkSchedule()
}, [workflowId, scheduleType, isModalOpen]) // Re-check when modal closes
}, [workflowId, scheduleType, isModalOpen, refreshCounter])
// Format the schedule information for display
const getScheduleInfo = () => {
@@ -93,13 +119,13 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
}
return (
<div className="text-xs text-muted-foreground">
<div className="flex items-center">
<span className="font-medium">{scheduleTiming}</span>
<>
<div className="font-normal text-sm truncate">{scheduleTiming}</div>
<div className="text-xs text-muted-foreground">
<div>Next run: {formatDateTime(new Date(nextRunAt))}</div>
{lastRanAt && <div>Last run: {formatDateTime(new Date(lastRanAt))}</div>}
</div>
<div>Next run: {formatDateTime(new Date(nextRunAt))}</div>
{lastRanAt && <div>Last run: {formatDateTime(new Date(lastRanAt))}</div>}
</div>
</>
)
}
@@ -109,11 +135,24 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
const handleCloseModal = () => {
setIsModalOpen(false)
// Force a refresh when closing the modal
setRefreshCounter((prev) => prev + 1)
}
const handleSaveSchedule = async (): Promise<boolean> => {
setIsSaving(true)
setError(null)
try {
// Make sure that startWorkflow is set to 'schedule'
if (startWorkflow !== 'schedule') {
// Set startWorkflow to 'schedule' to enable scheduling
setStartWorkflow('schedule')
// Give a moment for the state to update
await new Promise((resolve) => setTimeout(resolve, 100))
}
// Send the complete workflow state to be saved/updated
const response = await fetch(`/api/schedules/schedule`, {
method: 'POST',
@@ -130,12 +169,43 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
}),
})
// Clone the response to read it as text and parse as JSON
const responseText = await response.text()
let responseData
try {
responseData = JSON.parse(responseText)
} catch (e) {
logger.error('Failed to parse response JSON', e, responseText)
responseData = {}
}
if (!response.ok) {
const data = await response.json()
setError(data.error || 'Failed to save schedule')
setError(responseData.error || 'Failed to save schedule')
return false
}
logger.debug('Schedule save response:', responseData)
// Update our local state with the response data
// This allows showing schedule info immediately without waiting for another API call
if (responseData.cronExpression) {
setCronExpression(responseData.cronExpression)
}
if (responseData.nextRunAt) {
setNextRunAt(
typeof responseData.nextRunAt === 'string'
? responseData.nextRunAt
: responseData.nextRunAt.toISOString?.() || responseData.nextRunAt
)
}
// Set schedule status to true when we've successfully saved
setScheduleStatus(true)
// Force a refresh after successful save
setRefreshCounter((prev) => prev + 1)
return true
} catch (error) {
logger.error('Error saving schedule:', { error })
@@ -167,6 +237,18 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
setLastRanAt(null)
setCronExpression(null)
// Update startWorkflow value to manual to trigger re-render
setStartWorkflow('manual')
// Set active schedule flag to false since we deleted the schedule
setScheduleStatus(false)
// Trigger workflow update to refresh the UI
triggerUpdate()
// Force a refresh after successful delete
setRefreshCounter((prev) => prev + 1)
return true
} catch (error) {
logger.error('Error deleting schedule:', { error })
@@ -181,47 +263,48 @@ export function ScheduleConfig({ blockId, subBlockId, isConnecting }: ScheduleCo
const isScheduleActive = !!scheduleId && !!nextRunAt
return (
<div className="mt-2 space-y-2">
<div className="w-full" onClick={(e) => e.stopPropagation()}>
{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>
<div className="flex items-center justify-center py-2">
<div className="h-5 w-5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent" />
</div>
) : 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"
<div className="flex flex-col space-y-2 mb-2">
<div className="flex items-center justify-between px-3 py-2 rounded border border-border bg-background">
<div className="flex items-center gap-2 flex-1">
<div className="flex-1 truncate">{getScheduleInfo()}</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenModal}
disabled={isDeleting || isConnecting}
>
Active
</Badge>
{isDeleting ? (
<div className="h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent" />
) : (
<ExternalLink className="h-4 w-4" />
)}
</Button>
</div>
{getScheduleInfo()}
</div>
) : (
<Button variant="outline" size="sm" className="w-full" disabled={isConnecting}>
<Button
variant="outline"
size="sm"
className="w-full h-10 text-sm font-normal bg-background flex items-center"
onClick={handleOpenModal}
disabled={isConnecting || isSaving || isDeleting}
>
<Calendar className="h-4 w-4 mr-2" />
No schedule configured
Configure Schedule
</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}

View File

@@ -12,9 +12,10 @@ interface TimeInputProps {
blockId: string
subBlockId: string
placeholder?: string
className?: string
}
export function TimeInput({ blockId, subBlockId, placeholder }: TimeInputProps) {
export function TimeInput({ blockId, subBlockId, placeholder, className }: TimeInputProps) {
const [value, setValue] = useSubBlockValue<string>(blockId, subBlockId, true)
const [isOpen, setIsOpen] = React.useState(false)
@@ -79,7 +80,8 @@ export function TimeInput({ blockId, subBlockId, placeholder }: TimeInputProps)
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!value && 'text-muted-foreground'
!value && 'text-muted-foreground',
className
)}
>
<Clock className="mr-1 h-4 w-4" />

View File

@@ -137,6 +137,7 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
subBlockId={config.id}
acceptedTypes={config.acceptedTypes || '*'}
multiple={config.multiple === true}
maxSize={config.maxSize}
/>
)
case 'webhook-config':

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Info, RectangleHorizontal, RectangleVertical } from 'lucide-react'
import { Calendar, Info, RectangleHorizontal, RectangleVertical } from 'lucide-react'
import { Handle, NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -12,7 +12,6 @@ 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'
@@ -46,6 +45,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
)
const isWide = useWorkflowStore((state) => state.blocks[id]?.isWide ?? false)
const blockHeight = useWorkflowStore((state) => state.blocks[id]?.height ?? 0)
const hasActiveSchedule = useWorkflowStore((state) => state.hasActiveSchedule ?? false)
// Workflow store actions
const updateBlockName = useWorkflowStore((state) => state.updateBlockName)
@@ -194,6 +194,10 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}
}
// Check if this is a starter block and has an active schedule
const isStarterBlock = type === 'starter'
const showScheduleIndicator = isStarterBlock && hasActiveSchedule
return (
<div className="relative group">
<Card
@@ -288,7 +292,21 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
Disabled
</Badge>
)}
{type === 'starter' && <ScheduleStatus blockId={id} />}
{/* Schedule indicator badge - displayed for starter blocks with active schedules */}
{showScheduleIndicator && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="bg-green-50 text-green-600 border-green-200 animate-pulse-slow"
>
<Calendar className="h-3 w-3 mr-1" />
Scheduled
</Badge>
</TooltipTrigger>
<TooltipContent side="top">This workflow is running on a schedule</TooltipContent>
</Tooltip>
)}
{config.longDescription && (
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -19,6 +19,7 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
layout: 'full',
acceptedTypes: '.pdf,.csv,.docx',
multiple: true,
maxSize: 100,
},
],
tools: {

View File

@@ -29,6 +29,7 @@ const fileUploadBlock: SubBlockConfig = {
field: 'inputMethod',
value: 'upload',
},
maxSize: 50,
}
export const MistralParseBlock: BlockConfig<MistralParserOutput> = {

View File

@@ -115,6 +115,7 @@ export interface SubBlockConfig {
// File upload specific properties
acceptedTypes?: string
multiple?: boolean
maxSize?: number
}
// Main block definition

View File

@@ -625,6 +625,14 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
get().updateLastSaved()
workflowSync.sync()
},
setScheduleStatus: (hasActiveSchedule: boolean) => {
// Only update if the status has changed to avoid unnecessary rerenders
if (get().hasActiveSchedule !== hasActiveSchedule) {
set({ hasActiveSchedule })
get().updateLastSaved()
}
},
})),
{ name: 'workflow-store' }
)

View File

@@ -42,6 +42,7 @@ export interface WorkflowState {
isDeployed?: boolean
deployedAt?: Date
needsRedeployment?: boolean
hasActiveSchedule?: boolean
}
export interface WorkflowActions {
@@ -50,7 +51,7 @@ export interface WorkflowActions {
removeBlock: (id: string) => void
addEdge: (edge: Edge) => void
removeEdge: (edgeId: string) => void
clear: () => void
clear: () => Partial<WorkflowState>
updateLastSaved: () => void
toggleBlockEnabled: (id: string) => void
duplicateBlock: (id: string) => void
@@ -58,12 +59,13 @@ export interface WorkflowActions {
updateBlockName: (id: string, name: string) => void
toggleBlockWide: (id: string) => void
updateBlockHeight: (id: string, height: number) => void
triggerUpdate: () => void
updateLoopIterations: (loopId: string, iterations: number) => void
updateLoopType: (loopId: string, loopType: Loop['loopType']) => void
updateLoopForEachItems: (loopId: string, items: string) => void
triggerUpdate: () => void
setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => void
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void
setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => void
setScheduleStatus: (hasActiveSchedule: boolean) => void
}
export type WorkflowStore = WorkflowState & WorkflowActions

View File

@@ -113,6 +113,14 @@ export default {
'0%,70%,100%': { opacity: '1' },
'20%,50%': { opacity: '0' },
},
'pulse-slow': {
'0%, 100%': {
opacity: '1',
},
'50%': {
opacity: '0.7',
},
},
},
animation: {
'slide-down': 'slide-down 0.3s ease-out',
@@ -122,6 +130,7 @@ export default {
'rocket-pulse': 'rocket-pulse 1.5s ease-in-out infinite',
'run-glow': 'run-glow 2s ease-in-out infinite',
'caret-blink': 'caret-blink 1.25s ease-out infinite',
'pulse-slow': 'pulse-slow 3s ease-in-out infinite',
},
},
},