mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(schedules): add edit support with context menu for standalone jobs
This commit is contained in:
@@ -15,9 +15,19 @@ const logger = createLogger('ScheduleAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const scheduleUpdateSchema = z.object({
|
||||
action: z.enum(['reactivate', 'disable']),
|
||||
})
|
||||
const scheduleUpdateSchema = z.discriminatedUnion('action', [
|
||||
z.object({ action: z.literal('reactivate') }),
|
||||
z.object({ action: z.literal('disable') }),
|
||||
z.object({
|
||||
action: z.literal('update'),
|
||||
title: z.string().min(1).optional(),
|
||||
prompt: z.string().min(1).optional(),
|
||||
cronExpression: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
lifecycle: z.enum(['persistent', 'until_complete']).optional(),
|
||||
maxRuns: z.number().nullable().optional(),
|
||||
}),
|
||||
])
|
||||
|
||||
type ScheduleRow = {
|
||||
id: string
|
||||
@@ -140,6 +150,66 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ message: 'Schedule disabled successfully' })
|
||||
}
|
||||
|
||||
if (action === 'update') {
|
||||
if (schedule.sourceType !== 'job') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only standalone job schedules can be edited' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const updates = validation.data
|
||||
const setFields: Record<string, unknown> = { updatedAt: new Date() }
|
||||
|
||||
if (updates.title !== undefined) setFields.jobTitle = updates.title.trim()
|
||||
if (updates.prompt !== undefined) setFields.prompt = updates.prompt.trim()
|
||||
if (updates.timezone !== undefined) setFields.timezone = updates.timezone
|
||||
if (updates.lifecycle !== undefined) {
|
||||
setFields.lifecycle = updates.lifecycle
|
||||
if (updates.lifecycle === 'persistent') {
|
||||
setFields.maxRuns = null
|
||||
}
|
||||
}
|
||||
if (updates.maxRuns !== undefined) setFields.maxRuns = updates.maxRuns
|
||||
|
||||
if (updates.cronExpression !== undefined) {
|
||||
const tz = updates.timezone ?? schedule.timezone ?? 'UTC'
|
||||
const cronResult = validateCronExpression(updates.cronExpression, tz)
|
||||
if (!cronResult.isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: cronResult.error || 'Invalid cron expression' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
setFields.cronExpression = updates.cronExpression
|
||||
if (schedule.status === 'active' && cronResult.nextRun) {
|
||||
setFields.nextRunAt = cronResult.nextRun
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(workflowSchedule)
|
||||
.set(setFields)
|
||||
.where(eq(workflowSchedule.id, scheduleId))
|
||||
|
||||
logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.SCHEDULE_UPDATED,
|
||||
resourceType: AuditResourceType.SCHEDULE,
|
||||
resourceId: scheduleId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description: `Updated job schedule ${scheduleId}`,
|
||||
metadata: {},
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: 'Schedule updated successfully' })
|
||||
}
|
||||
|
||||
// reactivate
|
||||
if (schedule.status === 'active') {
|
||||
return NextResponse.json({ message: 'Schedule is already active' })
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { CreateScheduleModal } from './create-schedule-modal'
|
||||
export { ScheduleModal } from './schedule-modal'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
Button,
|
||||
@@ -17,17 +17,22 @@ import {
|
||||
Textarea,
|
||||
TimePicker,
|
||||
} from '@/components/emcn'
|
||||
import { parseCronToHumanReadable, validateCronExpression } from '@/lib/workflows/schedules/utils'
|
||||
import { useCreateSchedule } from '@/hooks/queries/schedules'
|
||||
import {
|
||||
DAY_MAP,
|
||||
parseCronToHumanReadable,
|
||||
parseCronToScheduleType,
|
||||
validateCronExpression,
|
||||
} from '@/lib/workflows/schedules/utils'
|
||||
import type { ScheduleType } from '@/lib/workflows/schedules/utils'
|
||||
import type { WorkspaceScheduleData } from '@/hooks/queries/schedules'
|
||||
import { useCreateSchedule, useUpdateSchedule } from '@/hooks/queries/schedules'
|
||||
|
||||
const logger = createLogger('CreateScheduleModal')
|
||||
const logger = createLogger('ScheduleModal')
|
||||
|
||||
const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
type ScheduleType = 'minutes' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'
|
||||
|
||||
const SCHEDULE_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Every X Minutes', value: 'minutes' },
|
||||
{ label: 'Hourly', value: 'hourly' },
|
||||
@@ -47,16 +52,6 @@ const WEEKDAY_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Sunday', value: 'SUN' },
|
||||
]
|
||||
|
||||
const DAY_MAP: Record<string, number> = {
|
||||
MON: 1,
|
||||
TUE: 2,
|
||||
WED: 3,
|
||||
THU: 4,
|
||||
FRI: 5,
|
||||
SAT: 6,
|
||||
SUN: 0,
|
||||
}
|
||||
|
||||
const TIMEZONE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'UTC', value: 'UTC' },
|
||||
{ label: 'US Pacific (UTC-8)', value: 'America/Los_Angeles' },
|
||||
@@ -95,10 +90,11 @@ const TIMEZONE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Auckland (UTC+12)', value: 'Pacific/Auckland' },
|
||||
]
|
||||
|
||||
interface CreateScheduleModalProps {
|
||||
interface ScheduleModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workspaceId: string
|
||||
schedule?: WorkspaceScheduleData
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,12 +151,16 @@ function buildCronExpression(
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateScheduleModal({
|
||||
export function ScheduleModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
}: CreateScheduleModalProps) {
|
||||
schedule,
|
||||
}: ScheduleModalProps) {
|
||||
const createScheduleMutation = useCreateSchedule()
|
||||
const updateScheduleMutation = useUpdateSchedule()
|
||||
|
||||
const isEditing = Boolean(schedule)
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
@@ -177,7 +177,27 @@ export function CreateScheduleModal({
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [lifecycle, setLifecycle] = useState<'persistent' | 'until_complete'>('persistent')
|
||||
const [maxRuns, setMaxRuns] = useState('')
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !schedule) return
|
||||
const cronState = parseCronToScheduleType(schedule.cronExpression)
|
||||
setTitle(schedule.jobTitle || '')
|
||||
setPrompt(schedule.prompt || '')
|
||||
setScheduleType(cronState.scheduleType)
|
||||
setMinutesInterval(cronState.minutesInterval)
|
||||
setHourlyMinute(cronState.hourlyMinute)
|
||||
setDailyTime(cronState.dailyTime)
|
||||
setWeeklyDay(cronState.weeklyDay)
|
||||
setWeeklyDayTime(cronState.weeklyDayTime)
|
||||
setMonthlyDay(cronState.monthlyDay)
|
||||
setMonthlyTime(cronState.monthlyTime)
|
||||
setCronExpression(cronState.cronExpression)
|
||||
setTimezone(schedule.timezone || DEFAULT_TIMEZONE)
|
||||
setLifecycle(schedule.lifecycle === 'until_complete' ? 'until_complete' : 'persistent')
|
||||
setMaxRuns(schedule.maxRuns ? String(schedule.maxRuns) : '')
|
||||
setStartDate('')
|
||||
}, [open, schedule])
|
||||
|
||||
const computedCron = useMemo(
|
||||
() =>
|
||||
@@ -245,7 +265,7 @@ export function CreateScheduleModal({
|
||||
setStartDate('')
|
||||
setLifecycle('persistent')
|
||||
setMaxRuns('')
|
||||
setCreateError(null)
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -253,26 +273,39 @@ export function CreateScheduleModal({
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!computedCron || !isFormValid) return
|
||||
|
||||
setCreateError(null)
|
||||
setSubmitError(null)
|
||||
try {
|
||||
await createScheduleMutation.mutateAsync({
|
||||
workspaceId,
|
||||
title: title.trim(),
|
||||
prompt: prompt.trim(),
|
||||
cronExpression: computedCron,
|
||||
timezone: resolvedTimezone,
|
||||
lifecycle,
|
||||
maxRuns: lifecycle === 'until_complete' && maxRuns ? parseInt(maxRuns, 10) : undefined,
|
||||
startDate: startDate || undefined,
|
||||
})
|
||||
if (isEditing && schedule) {
|
||||
await updateScheduleMutation.mutateAsync({
|
||||
scheduleId: schedule.id,
|
||||
workspaceId,
|
||||
title: title.trim(),
|
||||
prompt: prompt.trim(),
|
||||
cronExpression: computedCron,
|
||||
timezone: resolvedTimezone,
|
||||
lifecycle,
|
||||
maxRuns: lifecycle === 'until_complete' && maxRuns ? parseInt(maxRuns, 10) : null,
|
||||
})
|
||||
} else {
|
||||
await createScheduleMutation.mutateAsync({
|
||||
workspaceId,
|
||||
title: title.trim(),
|
||||
prompt: prompt.trim(),
|
||||
cronExpression: computedCron,
|
||||
timezone: resolvedTimezone,
|
||||
lifecycle,
|
||||
maxRuns: lifecycle === 'until_complete' && maxRuns ? parseInt(maxRuns, 10) : undefined,
|
||||
startDate: startDate || undefined,
|
||||
})
|
||||
}
|
||||
handleClose()
|
||||
} catch (error: unknown) {
|
||||
logger.error('Schedule creation failed:', { error })
|
||||
setCreateError(
|
||||
error instanceof Error ? error.message : 'Failed to create schedule. Please try again.'
|
||||
logger.error('Schedule submission failed:', { error })
|
||||
setSubmitError(
|
||||
error instanceof Error ? error.message : 'Failed to save schedule. Please try again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -280,7 +313,7 @@ export function CreateScheduleModal({
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent size='lg'>
|
||||
<ModalHeader>Create new schedule</ModalHeader>
|
||||
<ModalHeader>{isEditing ? 'Edit schedule' : 'Create new schedule'}</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[18px]'>
|
||||
{/* Title */}
|
||||
@@ -290,7 +323,7 @@ export function CreateScheduleModal({
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value)
|
||||
if (createError) setCreateError(null)
|
||||
if (submitError) setSubmitError(null)
|
||||
}}
|
||||
placeholder='e.g., Daily report generation'
|
||||
className='h-9'
|
||||
@@ -308,7 +341,7 @@ export function CreateScheduleModal({
|
||||
value={prompt}
|
||||
onChange={(e) => {
|
||||
setPrompt(e.target.value)
|
||||
if (createError) setCreateError(null)
|
||||
if (submitError) setSubmitError(null)
|
||||
}}
|
||||
placeholder='Describe what this scheduled task should do...'
|
||||
className='min-h-[80px] resize-none'
|
||||
@@ -442,18 +475,19 @@ export function CreateScheduleModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Date */}
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<p className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
Start date
|
||||
<span className='ml-[4px] font-normal text-[var(--text-muted)]'>(optional)</span>
|
||||
</p>
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
placeholder='Starts immediately'
|
||||
/>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<p className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
Start date
|
||||
<span className='ml-[4px] font-normal text-[var(--text-muted)]'>(optional)</span>
|
||||
</p>
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
placeholder='Starts immediately'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lifecycle */}
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
@@ -510,8 +544,8 @@ export function CreateScheduleModal({
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{createError && (
|
||||
<p className='text-[13px] text-[var(--text-error)] leading-tight'>{createError}</p>
|
||||
{submitError && (
|
||||
<p className='text-[13px] text-[var(--text-error)] leading-tight'>{submitError}</p>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
@@ -522,10 +556,12 @@ export function CreateScheduleModal({
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleCreate}
|
||||
disabled={!isFormValid || createScheduleMutation.isPending}
|
||||
onClick={handleSubmit}
|
||||
disabled={!isFormValid || createScheduleMutation.isPending || updateScheduleMutation.isPending}
|
||||
>
|
||||
{createScheduleMutation.isPending ? 'Creating...' : 'Create'}
|
||||
{isEditing
|
||||
? updateScheduleMutation.isPending ? 'Saving...' : 'Save changes'
|
||||
: createScheduleMutation.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
@@ -0,0 +1 @@
|
||||
export { ScheduleContextMenu } from './schedule-context-menu'
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface ScheduleContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
onClose: () => void
|
||||
isJob: boolean
|
||||
isActive: boolean
|
||||
onEdit?: () => void
|
||||
onPause?: () => void
|
||||
onResume?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export function ScheduleContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
isJob,
|
||||
isActive,
|
||||
onEdit,
|
||||
onPause,
|
||||
onResume,
|
||||
onDelete,
|
||||
}: ScheduleContextMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{isJob && onEdit && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onEdit()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</PopoverItem>
|
||||
)}
|
||||
{isJob && onEdit && <PopoverDivider />}
|
||||
{isActive && onPause && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onPause()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Pause
|
||||
</PopoverItem>
|
||||
)}
|
||||
{!isActive && onResume && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onResume()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Resume
|
||||
</PopoverItem>
|
||||
)}
|
||||
{(onPause || onResume) && onDelete && <PopoverDivider />}
|
||||
{onDelete && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</PopoverItem>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -3,14 +3,22 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import { Calendar } from '@/components/emcn/icons'
|
||||
import { formatAbsoluteDate } from '@/lib/core/utils/formatting'
|
||||
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
|
||||
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
|
||||
import { Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
|
||||
import { CreateScheduleModal } from '@/app/workspace/[workspaceId]/schedules/components/create-schedule-modal'
|
||||
import { ScheduleModal } from '@/app/workspace/[workspaceId]/schedules/components/create-schedule-modal'
|
||||
import { ScheduleContextMenu } from '@/app/workspace/[workspaceId]/schedules/components/schedule-context-menu'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import type { WorkspaceScheduleData } from '@/hooks/queries/schedules'
|
||||
import { useWorkspaceSchedules } from '@/hooks/queries/schedules'
|
||||
import {
|
||||
useDeleteSchedule,
|
||||
useDisableSchedule,
|
||||
useReactivateSchedule,
|
||||
useWorkspaceSchedules,
|
||||
} from '@/hooks/queries/schedules'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
const logger = createLogger('Schedules')
|
||||
@@ -36,12 +44,26 @@ export function Schedules() {
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { data: allItems = [], isLoading, error } = useWorkspaceSchedules(workspaceId)
|
||||
const deleteSchedule = useDeleteSchedule()
|
||||
const disableSchedule = useDisableSchedule()
|
||||
const reactivateSchedule = useReactivateSchedule()
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to load schedules:', error)
|
||||
}
|
||||
|
||||
const {
|
||||
isOpen: isRowContextMenuOpen,
|
||||
position: rowContextMenuPosition,
|
||||
menuRef: rowMenuRef,
|
||||
handleContextMenu: handleRowCtxMenu,
|
||||
closeMenu: closeRowContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [activeSchedule, setActiveSchedule] = useState<WorkspaceScheduleData | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
|
||||
@@ -90,16 +112,71 @@ export function Schedules() {
|
||||
[filteredItems]
|
||||
)
|
||||
|
||||
const itemById = useMemo(
|
||||
() => new Map(filteredItems.map((i) => [i.id, i])),
|
||||
[filteredItems]
|
||||
)
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(rowId: string) => {
|
||||
const item = filteredItems.find((i) => i.id === rowId)
|
||||
if (isRowContextMenuOpen) return
|
||||
const item = itemById.get(rowId)
|
||||
if (item?.workflowId) {
|
||||
router.push(`/workspace/${workspaceId}/w/${item.workflowId}`)
|
||||
}
|
||||
},
|
||||
[filteredItems, router, workspaceId]
|
||||
[itemById, isRowContextMenuOpen, router, workspaceId]
|
||||
)
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
const item = itemById.get(rowId) ?? null
|
||||
setActiveSchedule(item)
|
||||
handleRowCtxMenu(e)
|
||||
},
|
||||
[itemById, handleRowCtxMenu]
|
||||
)
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!activeSchedule) return
|
||||
try {
|
||||
await deleteSchedule.mutateAsync({
|
||||
scheduleId: activeSchedule.id,
|
||||
workspaceId,
|
||||
})
|
||||
setIsDeleteDialogOpen(false)
|
||||
setActiveSchedule(null)
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete schedule:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePause = async () => {
|
||||
if (!activeSchedule) return
|
||||
try {
|
||||
await disableSchedule.mutateAsync({
|
||||
scheduleId: activeSchedule.id,
|
||||
workspaceId,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to pause schedule:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResume = async () => {
|
||||
if (!activeSchedule) return
|
||||
try {
|
||||
await reactivateSchedule.mutateAsync({
|
||||
scheduleId: activeSchedule.id,
|
||||
workflowId: activeSchedule.workflowId || '',
|
||||
blockId: '',
|
||||
workspaceId,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to resume schedule:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Resource
|
||||
@@ -120,13 +197,69 @@ export function Schedules() {
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
onRowClick={handleRowClick}
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<CreateScheduleModal
|
||||
|
||||
<ScheduleContextMenu
|
||||
isOpen={isRowContextMenuOpen}
|
||||
position={rowContextMenuPosition}
|
||||
menuRef={rowMenuRef}
|
||||
onClose={closeRowContextMenu}
|
||||
isJob={activeSchedule?.sourceType === 'job'}
|
||||
isActive={activeSchedule?.status === 'active'}
|
||||
onEdit={() => setIsEditModalOpen(true)}
|
||||
onPause={handlePause}
|
||||
onResume={handleResume}
|
||||
onDelete={() => setIsDeleteDialogOpen(true)}
|
||||
/>
|
||||
|
||||
<ScheduleModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
|
||||
<ScheduleModal
|
||||
open={isEditModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsEditModalOpen(open)
|
||||
if (!open) setActiveSchedule(null)
|
||||
}}
|
||||
workspaceId={workspaceId}
|
||||
schedule={activeSchedule ?? undefined}
|
||||
/>
|
||||
|
||||
<Modal open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalHeader>Delete Schedule</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{activeSchedule?.jobTitle || activeSchedule?.workflowName || 'this schedule'}
|
||||
</span>
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => {
|
||||
setIsDeleteDialogOpen(false)
|
||||
setActiveSchedule(null)
|
||||
}}
|
||||
disabled={deleteSchedule.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='default' onClick={handleDelete} disabled={deleteSchedule.isPending}>
|
||||
{deleteSchedule.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -275,6 +275,49 @@ export function useDeleteSchedule() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation to update fields on a standalone job schedule
|
||||
*/
|
||||
export function useUpdateSchedule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
scheduleId,
|
||||
workspaceId,
|
||||
...updates
|
||||
}: {
|
||||
scheduleId: string
|
||||
workspaceId: string
|
||||
title?: string
|
||||
prompt?: string
|
||||
cronExpression?: string
|
||||
timezone?: string
|
||||
lifecycle?: 'persistent' | 'until_complete'
|
||||
maxRuns?: number | null
|
||||
}) => {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'update', ...updates }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Failed to update schedule')
|
||||
}
|
||||
|
||||
return { workspaceId }
|
||||
},
|
||||
onSuccess: ({ workspaceId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(workspaceId) })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to update schedule', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation to create a standalone scheduled job
|
||||
*/
|
||||
|
||||
@@ -484,6 +484,106 @@ export const parseCronToHumanReadable = (cronExpression: string, timezone?: stri
|
||||
}
|
||||
}
|
||||
|
||||
const REVERSE_DAY_MAP: Record<number, string> = {
|
||||
0: 'SUN',
|
||||
1: 'MON',
|
||||
2: 'TUE',
|
||||
3: 'WED',
|
||||
4: 'THU',
|
||||
5: 'FRI',
|
||||
6: 'SAT',
|
||||
7: 'SUN',
|
||||
}
|
||||
|
||||
export type ScheduleType = 'minutes' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'
|
||||
|
||||
export interface CronFormState {
|
||||
scheduleType: ScheduleType
|
||||
minutesInterval: string
|
||||
hourlyMinute: string
|
||||
dailyTime: string
|
||||
weeklyDay: string
|
||||
weeklyDayTime: string
|
||||
monthlyDay: string
|
||||
monthlyTime: string
|
||||
cronExpression: string
|
||||
}
|
||||
|
||||
const CRON_FORM_DEFAULTS: CronFormState = {
|
||||
scheduleType: 'custom',
|
||||
minutesInterval: '15',
|
||||
hourlyMinute: '0',
|
||||
dailyTime: '09:00',
|
||||
weeklyDay: 'MON',
|
||||
weeklyDayTime: '09:00',
|
||||
monthlyDay: '1',
|
||||
monthlyTime: '09:00',
|
||||
cronExpression: '',
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-parses a cron expression into schedule type and form field values.
|
||||
* Used to pre-populate the schedule modal when editing an existing schedule.
|
||||
*/
|
||||
export function parseCronToScheduleType(cronExpression: string | null | undefined): CronFormState {
|
||||
if (!cronExpression?.trim()) {
|
||||
return { ...CRON_FORM_DEFAULTS }
|
||||
}
|
||||
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
if (parts.length !== 5) {
|
||||
return { ...CRON_FORM_DEFAULTS, cronExpression }
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
if (minute.startsWith('*/') && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
||||
const interval = parseInt(minute.slice(2), 10)
|
||||
if (!isNaN(interval) && interval > 0) {
|
||||
return { ...CRON_FORM_DEFAULTS, scheduleType: 'minutes', minutesInterval: String(interval) }
|
||||
}
|
||||
}
|
||||
|
||||
const m = parseInt(minute, 10)
|
||||
const h = parseInt(hour, 10)
|
||||
|
||||
if (!isNaN(m) && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
||||
return { ...CRON_FORM_DEFAULTS, scheduleType: 'hourly', hourlyMinute: String(m) }
|
||||
}
|
||||
|
||||
if (!isNaN(m) && !isNaN(h) && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
||||
return { ...CRON_FORM_DEFAULTS, scheduleType: 'daily', dailyTime: `${pad(h)}:${pad(m)}` }
|
||||
}
|
||||
|
||||
if (!isNaN(m) && !isNaN(h) && dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
|
||||
const dow = parseInt(dayOfWeek, 10)
|
||||
const dayName = REVERSE_DAY_MAP[dow]
|
||||
if (dayName) {
|
||||
return {
|
||||
...CRON_FORM_DEFAULTS,
|
||||
scheduleType: 'weekly',
|
||||
weeklyDay: dayName,
|
||||
weeklyDayTime: `${pad(h)}:${pad(m)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNaN(m) && !isNaN(h) && dayOfMonth !== '*' && month === '*' && dayOfWeek === '*') {
|
||||
const dom = parseInt(dayOfMonth, 10)
|
||||
if (!isNaN(dom) && dom >= 1 && dom <= 31) {
|
||||
return {
|
||||
...CRON_FORM_DEFAULTS,
|
||||
scheduleType: 'monthly',
|
||||
monthlyDay: String(dom),
|
||||
monthlyTime: `${pad(h)}:${pad(m)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...CRON_FORM_DEFAULTS, cronExpression }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format schedule information for display
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user