mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
improvement(schedules): use tanstack query to fetch schedule data, cleanup ui on schedule info component (#2584)
* improvement(schedules): use tanstack query to fetch schedule data, cleanup ui on schedule info component * update trigger-save UI, increase auto disable to 100 consecutive from 10 * updated docs * consolidate consts
This commit is contained in:
@@ -56,7 +56,7 @@ Sie müssen Ihren Workflow bereitstellen, damit der Zeitplan mit der Ausführung
|
||||
|
||||
## Automatische Deaktivierung
|
||||
|
||||
Zeitpläne werden nach **10 aufeinanderfolgenden Fehlschlägen** automatisch deaktiviert, um unkontrollierte Fehler zu verhindern. Bei Deaktivierung:
|
||||
Zeitpläne werden nach **100 aufeinanderfolgenden Fehlschlägen** automatisch deaktiviert, um unkontrollierte Fehler zu verhindern. Bei Deaktivierung:
|
||||
|
||||
- Erscheint ein Warnhinweis auf dem Zeitplan-Block
|
||||
- Die Ausführung des Zeitplans wird gestoppt
|
||||
|
||||
@@ -56,7 +56,7 @@ You must deploy your workflow for the schedule to start running. Configure the s
|
||||
|
||||
## Automatic Disabling
|
||||
|
||||
Schedules automatically disable after **10 consecutive failures** to prevent runaway errors. When disabled:
|
||||
Schedules automatically disable after **100 consecutive failures** to prevent runaway errors. When disabled:
|
||||
|
||||
- A warning badge appears on the schedule block
|
||||
- The schedule stops executing
|
||||
|
||||
@@ -56,7 +56,7 @@ Debes desplegar tu flujo de trabajo para que la programación comience a ejecuta
|
||||
|
||||
## Desactivación automática
|
||||
|
||||
Las programaciones se desactivan automáticamente después de **10 fallos consecutivos** para evitar errores descontrolados. Cuando se desactiva:
|
||||
Las programaciones se desactivan automáticamente después de **100 fallos consecutivos** para evitar errores descontrolados. Cuando se desactiva:
|
||||
|
||||
- Aparece una insignia de advertencia en el bloque de programación
|
||||
- La programación deja de ejecutarse
|
||||
|
||||
@@ -56,7 +56,7 @@ Vous devez déployer votre workflow pour que la planification commence à s'exé
|
||||
|
||||
## Désactivation automatique
|
||||
|
||||
Les planifications se désactivent automatiquement après **10 échecs consécutifs** pour éviter les erreurs incontrôlées. Lorsqu'elle est désactivée :
|
||||
Les planifications se désactivent automatiquement après **100 échecs consécutifs** pour éviter les erreurs incontrôlées. Lorsqu'elle est désactivée :
|
||||
|
||||
- Un badge d'avertissement apparaît sur le bloc de planification
|
||||
- La planification cesse de s'exécuter
|
||||
|
||||
@@ -56,7 +56,7 @@ import { Image } from '@/components/ui/image'
|
||||
|
||||
## 自動無効化
|
||||
|
||||
スケジュールは**10回連続で失敗**すると、エラーの連鎖を防ぐため自動的に無効化されます。無効化されると:
|
||||
スケジュールは**100回連続で失敗**すると、エラーの連鎖を防ぐため自動的に無効化されます。無効化されると:
|
||||
|
||||
- スケジュールブロックに警告バッジが表示されます
|
||||
- スケジュールの実行が停止します
|
||||
|
||||
@@ -56,7 +56,7 @@ import { Image } from '@/components/ui/image'
|
||||
|
||||
## 自动禁用
|
||||
|
||||
计划在连续 **10 次失败** 后会自动禁用,以防止错误持续发生。禁用后:
|
||||
计划在连续 **100 次失败** 后会自动禁用,以防止错误持续发生。禁用后:
|
||||
|
||||
- 计划块上会显示警告徽章
|
||||
- 计划将停止执行
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('Schedule GET API', () => {
|
||||
it('indicates disabled schedule with failures', async () => {
|
||||
mockDbChain([
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
[{ id: 'sched-1', status: 'disabled', failedCount: 10 }],
|
||||
[{ id: 'sched-1', status: 'disabled', failedCount: 100 }],
|
||||
])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
|
||||
import { useRedeployWorkflowSchedule, useScheduleQuery } from '@/hooks/queries/schedules'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
const logger = createLogger('ScheduleStatus')
|
||||
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
||||
|
||||
interface ScheduleInfoProps {
|
||||
blockId: string
|
||||
@@ -20,172 +18,93 @@ interface ScheduleInfoProps {
|
||||
export function ScheduleInfo({ blockId, isPreview = false }: ScheduleInfoProps) {
|
||||
const params = useParams()
|
||||
const workflowId = params.workflowId as string
|
||||
const [scheduleStatus, setScheduleStatus] = useState<'active' | 'disabled' | null>(null)
|
||||
const [nextRunAt, setNextRunAt] = useState<Date | null>(null)
|
||||
const [lastRanAt, setLastRanAt] = useState<Date | null>(null)
|
||||
const [failedCount, setFailedCount] = useState<number>(0)
|
||||
const [isLoadingStatus, setIsLoadingStatus] = useState(true)
|
||||
const [savedCronExpression, setSavedCronExpression] = useState<string | null>(null)
|
||||
const [isRedeploying, setIsRedeploying] = useState(false)
|
||||
const [hasSchedule, setHasSchedule] = useState(false)
|
||||
|
||||
const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone'))
|
||||
|
||||
const fetchScheduleStatus = useCallback(async () => {
|
||||
if (isPreview) return
|
||||
const { data: schedule, isLoading } = useScheduleQuery(workflowId, blockId, {
|
||||
enabled: !isPreview,
|
||||
})
|
||||
|
||||
setIsLoadingStatus(true)
|
||||
try {
|
||||
const response = await fetch(`/api/schedules?workflowId=${workflowId}&blockId=${blockId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.schedule) {
|
||||
setHasSchedule(true)
|
||||
setScheduleStatus(data.schedule.status)
|
||||
setNextRunAt(data.schedule.nextRunAt ? new Date(data.schedule.nextRunAt) : null)
|
||||
setLastRanAt(data.schedule.lastRanAt ? new Date(data.schedule.lastRanAt) : null)
|
||||
setFailedCount(data.schedule.failedCount || 0)
|
||||
setSavedCronExpression(data.schedule.cronExpression || null)
|
||||
} else {
|
||||
// No schedule exists (workflow not deployed or no schedule block)
|
||||
setHasSchedule(false)
|
||||
setScheduleStatus(null)
|
||||
setNextRunAt(null)
|
||||
setLastRanAt(null)
|
||||
setFailedCount(0)
|
||||
setSavedCronExpression(null)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching schedule status', { error })
|
||||
} finally {
|
||||
setIsLoadingStatus(false)
|
||||
}
|
||||
}, [workflowId, blockId, isPreview])
|
||||
const redeployMutation = useRedeployWorkflowSchedule()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreview) {
|
||||
fetchScheduleStatus()
|
||||
}
|
||||
}, [isPreview, fetchScheduleStatus])
|
||||
|
||||
/**
|
||||
* Handles redeploying the workflow when schedule is disabled due to failures.
|
||||
* Redeploying will recreate the schedule with reset failure count.
|
||||
*/
|
||||
const handleRedeploy = async () => {
|
||||
if (isPreview || isRedeploying) return
|
||||
|
||||
setIsRedeploying(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deployChatEnabled: false }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh schedule status after redeploy
|
||||
await fetchScheduleStatus()
|
||||
logger.info('Workflow redeployed successfully to reset schedule', { workflowId, blockId })
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
logger.error('Failed to redeploy workflow', { error: errorData.error })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error redeploying workflow', { error })
|
||||
} finally {
|
||||
setIsRedeploying(false)
|
||||
}
|
||||
const handleRedeploy = () => {
|
||||
if (isPreview || redeployMutation.isPending) return
|
||||
redeployMutation.mutate({ workflowId, blockId })
|
||||
}
|
||||
|
||||
// Don't render anything if there's no deployed schedule
|
||||
if (!hasSchedule && !isLoadingStatus) {
|
||||
if (!schedule || isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
const timezone = scheduleTimezone || schedule?.timezone || 'UTC'
|
||||
const failedCount = schedule?.failedCount || 0
|
||||
const isDisabled = schedule?.status === 'disabled'
|
||||
const nextRunAt = schedule?.nextRunAt ? new Date(schedule.nextRunAt) : null
|
||||
|
||||
return (
|
||||
<div className='mt-2'>
|
||||
{isLoadingStatus ? (
|
||||
<div className='flex items-center gap-2 text-muted-foreground text-sm'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
Loading schedule status...
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-1.5'>
|
||||
{/* Status badges */}
|
||||
{(failedCount > 0 || isDisabled) && (
|
||||
<div className='space-y-1'>
|
||||
{/* Failure badge with redeploy action */}
|
||||
{failedCount >= 10 && scheduleStatus === 'disabled' && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleRedeploy}
|
||||
disabled={isRedeploying}
|
||||
className='flex w-full cursor-pointer items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-left text-destructive text-sm transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
{isRedeploying ? (
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
) : (
|
||||
<AlertTriangle className='h-4 w-4 flex-shrink-0' />
|
||||
)}
|
||||
<span>
|
||||
{isRedeploying
|
||||
? 'Redeploying...'
|
||||
: `Schedule disabled after ${failedCount} failures - Click to redeploy`}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show warning for failed runs under threshold */}
|
||||
{failedCount > 0 && failedCount < 10 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-destructive text-sm'>
|
||||
⚠️ {failedCount} failed run{failedCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cron expression human-readable description */}
|
||||
{savedCronExpression && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Runs{' '}
|
||||
{parseCronToHumanReadable(
|
||||
savedCronExpression,
|
||||
scheduleTimezone || 'UTC'
|
||||
).toLowerCase()}
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{failedCount >= MAX_CONSECUTIVE_FAILURES && isDisabled ? (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='cursor-pointer'
|
||||
style={{
|
||||
borderColor: 'var(--warning)',
|
||||
color: 'var(--warning)',
|
||||
}}
|
||||
onClick={handleRedeploy}
|
||||
>
|
||||
{redeployMutation.isPending ? 'redeploying...' : 'disabled'}
|
||||
</Badge>
|
||||
) : failedCount > 0 ? (
|
||||
<Badge
|
||||
variant='outline'
|
||||
style={{
|
||||
borderColor: 'var(--warning)',
|
||||
color: 'var(--warning)',
|
||||
}}
|
||||
>
|
||||
{failedCount} failed
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{failedCount >= MAX_CONSECUTIVE_FAILURES && isDisabled && (
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Disabled after {MAX_CONSECUTIVE_FAILURES} consecutive failures
|
||||
</p>
|
||||
)}
|
||||
{redeployMutation.isError && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
Failed to redeploy. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next run time */}
|
||||
{/* Schedule info - only show when active */}
|
||||
{!isDisabled && (
|
||||
<div className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
{schedule?.cronExpression && (
|
||||
<span>{parseCronToHumanReadable(schedule.cronExpression, timezone)}</span>
|
||||
)}
|
||||
{nextRunAt && (
|
||||
<p className='text-sm'>
|
||||
<span className='font-medium'>Next run:</span>{' '}
|
||||
{nextRunAt.toLocaleString('en-US', {
|
||||
timeZone: scheduleTimezone || 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}{' '}
|
||||
{scheduleTimezone || 'UTC'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Last ran time */}
|
||||
{lastRanAt && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
<span className='font-medium'>Last ran:</span>{' '}
|
||||
{lastRanAt.toLocaleString('en-US', {
|
||||
timeZone: scheduleTimezone || 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}{' '}
|
||||
{scheduleTimezone || 'UTC'}
|
||||
</p>
|
||||
<>
|
||||
{schedule?.cronExpression && <span className='mx-1'>·</span>}
|
||||
<span>
|
||||
Next:{' '}
|
||||
{nextRunAt.toLocaleString('en-US', {
|
||||
timeZone: timezone,
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ModalHeader,
|
||||
} from '@/components/emcn/components'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
@@ -367,12 +366,7 @@ export function TriggerSave({
|
||||
saveStatus === 'error' && 'bg-red-600 hover:bg-red-700'
|
||||
)}
|
||||
>
|
||||
{saveStatus === 'saving' && (
|
||||
<>
|
||||
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
Saving...
|
||||
</>
|
||||
)}
|
||||
{saveStatus === 'saving' && 'Saving...'}
|
||||
{saveStatus === 'saved' && 'Saved'}
|
||||
{saveStatus === 'error' && 'Error'}
|
||||
{saveStatus === 'idle' && (webhookId ? 'Update Configuration' : 'Save Configuration')}
|
||||
@@ -394,59 +388,48 @@ export function TriggerSave({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<Alert variant='destructive' className='mt-2'>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{errorMessage && <p className='mt-2 text-[12px] text-[var(--text-error)]'>{errorMessage}</p>}
|
||||
|
||||
{webhookId && hasWebhookUrlDisplay && (
|
||||
<div className='mt-2 space-y-1'>
|
||||
<div className='mt-4 space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-sm'>Test Webhook URL</span>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Test Webhook URL
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
variant='ghost'
|
||||
onClick={generateTestUrl}
|
||||
disabled={isGeneratingTestUrl || isProcessing}
|
||||
className='h-[32px] rounded-[8px] px-[12px]'
|
||||
>
|
||||
{isGeneratingTestUrl ? (
|
||||
<>
|
||||
<div className='mr-2 h-3 w-3 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
|
||||
Generating…
|
||||
</>
|
||||
) : testUrl ? (
|
||||
'Regenerate'
|
||||
) : (
|
||||
'Generate'
|
||||
)}
|
||||
{isGeneratingTestUrl ? 'Generating…' : testUrl ? 'Regenerate' : 'Generate'}
|
||||
</Button>
|
||||
</div>
|
||||
{testUrl ? (
|
||||
<ShortInput
|
||||
blockId={blockId}
|
||||
subBlockId={`${subBlockId}-test-url`}
|
||||
config={{
|
||||
id: `${subBlockId}-test-url`,
|
||||
type: 'short-input',
|
||||
readOnly: true,
|
||||
showCopyButton: true,
|
||||
}}
|
||||
value={testUrl}
|
||||
readOnly={true}
|
||||
showCopyButton={true}
|
||||
disabled={isPreview || disabled}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
<>
|
||||
<ShortInput
|
||||
blockId={blockId}
|
||||
subBlockId={`${subBlockId}-test-url`}
|
||||
config={{
|
||||
id: `${subBlockId}-test-url`,
|
||||
type: 'short-input',
|
||||
readOnly: true,
|
||||
showCopyButton: true,
|
||||
}}
|
||||
value={testUrl}
|
||||
readOnly={true}
|
||||
showCopyButton={true}
|
||||
disabled={isPreview || disabled}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
{testUrlExpiresAt && (
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Expires {new Date(testUrlExpiresAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Generate a temporary URL that executes this webhook against the live (undeployed)
|
||||
workflow state.
|
||||
</p>
|
||||
)}
|
||||
{testUrlExpiresAt && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Expires at {new Date(testUrlExpiresAt).toLocaleString()}
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Generate a temporary URL to test against the live (undeployed) workflow state.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
useReactivateSchedule,
|
||||
useScheduleInfo as useScheduleInfoQuery,
|
||||
} from '@/hooks/queries/schedules'
|
||||
import type { ScheduleInfo } from '../types'
|
||||
|
||||
const logger = createLogger('useScheduleInfo')
|
||||
|
||||
/**
|
||||
* Return type for the useScheduleInfo hook
|
||||
*/
|
||||
@@ -18,7 +18,7 @@ export interface UseScheduleInfoReturn {
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for fetching schedule information
|
||||
* Custom hook for fetching schedule information using TanStack Query
|
||||
*
|
||||
* @param blockId - The ID of the block
|
||||
* @param blockType - The type of the block
|
||||
@@ -30,96 +30,37 @@ export function useScheduleInfo(
|
||||
blockType: string,
|
||||
workflowId: string
|
||||
): UseScheduleInfoReturn {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [scheduleInfo, setScheduleInfo] = useState<ScheduleInfo | null>(null)
|
||||
|
||||
const fetchScheduleInfo = useCallback(
|
||||
async (wfId: string) => {
|
||||
if (!wfId) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
workflowId: wfId,
|
||||
blockId,
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/schedules?${params}`, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
setScheduleInfo(null)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.schedule) {
|
||||
setScheduleInfo(null)
|
||||
return
|
||||
}
|
||||
|
||||
const schedule = data.schedule
|
||||
const scheduleTimezone = schedule.timezone || 'UTC'
|
||||
|
||||
setScheduleInfo({
|
||||
scheduleTiming: schedule.cronExpression
|
||||
? parseCronToHumanReadable(schedule.cronExpression, scheduleTimezone)
|
||||
: 'Unknown schedule',
|
||||
nextRunAt: schedule.nextRunAt,
|
||||
lastRanAt: schedule.lastRanAt,
|
||||
timezone: scheduleTimezone,
|
||||
status: schedule.status,
|
||||
isDisabled: schedule.status === 'disabled',
|
||||
failedCount: schedule.failedCount || 0,
|
||||
id: schedule.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching schedule info:', error)
|
||||
setScheduleInfo(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[blockId]
|
||||
const { scheduleInfo: queryScheduleInfo, isLoading } = useScheduleInfoQuery(
|
||||
workflowId,
|
||||
blockId,
|
||||
blockType
|
||||
)
|
||||
|
||||
const reactivateMutation = useReactivateSchedule()
|
||||
|
||||
const reactivateSchedule = useCallback(
|
||||
async (scheduleId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'reactivate' }),
|
||||
})
|
||||
|
||||
if (response.ok && workflowId) {
|
||||
await fetchScheduleInfo(workflowId)
|
||||
} else {
|
||||
logger.error('Failed to reactivate schedule')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error reactivating schedule:', error)
|
||||
}
|
||||
await reactivateMutation.mutateAsync({
|
||||
scheduleId,
|
||||
workflowId,
|
||||
blockId,
|
||||
})
|
||||
},
|
||||
[workflowId, fetchScheduleInfo]
|
||||
[reactivateMutation, workflowId, blockId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (blockType === 'schedule' && workflowId) {
|
||||
fetchScheduleInfo(workflowId)
|
||||
} else {
|
||||
setScheduleInfo(null)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [blockType, workflowId, fetchScheduleInfo])
|
||||
const scheduleInfo: ScheduleInfo | null = queryScheduleInfo
|
||||
? {
|
||||
scheduleTiming: queryScheduleInfo.scheduleTiming,
|
||||
nextRunAt: queryScheduleInfo.nextRunAt,
|
||||
lastRanAt: queryScheduleInfo.lastRanAt,
|
||||
timezone: queryScheduleInfo.timezone,
|
||||
status: queryScheduleInfo.status,
|
||||
isDisabled: queryScheduleInfo.isDisabled,
|
||||
failedCount: queryScheduleInfo.failedCount,
|
||||
id: queryScheduleInfo.id,
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
scheduleInfo,
|
||||
|
||||
@@ -27,11 +27,10 @@ import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
|
||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
||||
|
||||
const logger = createLogger('TriggerScheduleExecution')
|
||||
|
||||
const MAX_CONSECUTIVE_FAILURES = 10
|
||||
|
||||
type WorkflowRecord = typeof workflow.$inferSelect
|
||||
type WorkflowScheduleUpdate = Partial<typeof workflowSchedule.$inferInsert>
|
||||
type ExecutionCoreResult = Awaited<ReturnType<typeof executeWorkflowCore>>
|
||||
|
||||
184
apps/sim/hooks/queries/schedules.ts
Normal file
184
apps/sim/hooks/queries/schedules.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
|
||||
|
||||
const logger = createLogger('ScheduleQueries')
|
||||
|
||||
export const scheduleKeys = {
|
||||
all: ['schedules'] as const,
|
||||
schedule: (workflowId: string, blockId: string) =>
|
||||
[...scheduleKeys.all, workflowId, blockId] as const,
|
||||
}
|
||||
|
||||
export interface ScheduleData {
|
||||
id: string
|
||||
status: 'active' | 'disabled'
|
||||
cronExpression: string | null
|
||||
nextRunAt: string | null
|
||||
lastRanAt: string | null
|
||||
timezone: string
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
export interface ScheduleInfo {
|
||||
id: string
|
||||
status: 'active' | 'disabled'
|
||||
scheduleTiming: string
|
||||
nextRunAt: string | null
|
||||
lastRanAt: string | null
|
||||
timezone: string
|
||||
isDisabled: boolean
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches schedule data for a specific workflow block
|
||||
*/
|
||||
async function fetchSchedule(workflowId: string, blockId: string): Promise<ScheduleData | null> {
|
||||
const params = new URLSearchParams({ workflowId, blockId })
|
||||
const response = await fetch(`/api/schedules?${params}`, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.schedule || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch schedule data for a workflow block
|
||||
*/
|
||||
export function useScheduleQuery(
|
||||
workflowId: string | undefined,
|
||||
blockId: string | undefined,
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: scheduleKeys.schedule(workflowId ?? '', blockId ?? ''),
|
||||
queryFn: () => fetchSchedule(workflowId!, blockId!),
|
||||
enabled: !!workflowId && !!blockId && (options?.enabled ?? true),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get processed schedule info with human-readable timing
|
||||
*/
|
||||
export function useScheduleInfo(
|
||||
workflowId: string | undefined,
|
||||
blockId: string | undefined,
|
||||
blockType: string,
|
||||
options?: { timezone?: string }
|
||||
): {
|
||||
scheduleInfo: ScheduleInfo | null
|
||||
isLoading: boolean
|
||||
refetch: () => void
|
||||
} {
|
||||
const isScheduleBlock = blockType === 'schedule'
|
||||
|
||||
const { data, isLoading, refetch } = useScheduleQuery(workflowId, blockId, {
|
||||
enabled: isScheduleBlock,
|
||||
})
|
||||
|
||||
if (!data) {
|
||||
return { scheduleInfo: null, isLoading, refetch }
|
||||
}
|
||||
|
||||
const timezone = options?.timezone || data.timezone || 'UTC'
|
||||
const scheduleTiming = data.cronExpression
|
||||
? parseCronToHumanReadable(data.cronExpression, timezone)
|
||||
: 'Unknown schedule'
|
||||
|
||||
return {
|
||||
scheduleInfo: {
|
||||
id: data.id,
|
||||
status: data.status,
|
||||
scheduleTiming,
|
||||
nextRunAt: data.nextRunAt,
|
||||
lastRanAt: data.lastRanAt,
|
||||
timezone,
|
||||
isDisabled: data.status === 'disabled',
|
||||
failedCount: data.failedCount || 0,
|
||||
},
|
||||
isLoading,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation to reactivate a disabled schedule
|
||||
*/
|
||||
export function useReactivateSchedule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
scheduleId,
|
||||
workflowId,
|
||||
blockId,
|
||||
}: {
|
||||
scheduleId: string
|
||||
workflowId: string
|
||||
blockId: string
|
||||
}) => {
|
||||
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'reactivate' }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to reactivate schedule')
|
||||
}
|
||||
|
||||
return { workflowId, blockId }
|
||||
},
|
||||
onSuccess: ({ workflowId, blockId }) => {
|
||||
logger.info('Schedule reactivated', { workflowId, blockId })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: scheduleKeys.schedule(workflowId, blockId),
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to reactivate schedule', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation to redeploy a workflow (which recreates the schedule)
|
||||
*/
|
||||
export function useRedeployWorkflowSchedule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workflowId, blockId }: { workflowId: string; blockId: string }) => {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deployChatEnabled: false }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to redeploy workflow')
|
||||
}
|
||||
|
||||
return { workflowId, blockId }
|
||||
},
|
||||
onSuccess: ({ workflowId, blockId }) => {
|
||||
logger.info('Workflow redeployed for schedule reset', { workflowId, blockId })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: scheduleKeys.schedule(workflowId, blockId),
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to redeploy workflow', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -8,11 +8,10 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { GmailAttachment } from '@/tools/gmail/types'
|
||||
import { downloadAttachments, extractAttachmentInfo } from '@/tools/gmail/utils'
|
||||
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
||||
|
||||
const logger = createLogger('GmailPollingService')
|
||||
|
||||
const MAX_CONSECUTIVE_FAILURES = 10
|
||||
|
||||
interface GmailWebhookConfig {
|
||||
labelIds: string[]
|
||||
labelFilterBehavior: 'INCLUDE' | 'EXCLUDE'
|
||||
|
||||
@@ -7,11 +7,10 @@ import { pollingIdempotency } from '@/lib/core/idempotency'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
||||
|
||||
const logger = createLogger('OutlookPollingService')
|
||||
|
||||
const MAX_CONSECUTIVE_FAILURES = 10
|
||||
|
||||
async function markWebhookFailed(webhookId: string) {
|
||||
try {
|
||||
const result = await db
|
||||
|
||||
@@ -7,10 +7,9 @@ import { pollingIdempotency } from '@/lib/core/idempotency/service'
|
||||
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
|
||||
|
||||
const logger = createLogger('RssPollingService')
|
||||
|
||||
const MAX_CONSECUTIVE_FAILURES = 10
|
||||
const MAX_GUIDS_TO_TRACK = 100 // Track recent guids to prevent duplicates
|
||||
|
||||
interface RssWebhookConfig {
|
||||
|
||||
@@ -40,3 +40,9 @@ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [
|
||||
'testUrl',
|
||||
'testUrlExpiresAt',
|
||||
]
|
||||
|
||||
/**
|
||||
* Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled.
|
||||
* This prevents runaway errors from continuously executing failing workflows.
|
||||
*/
|
||||
export const MAX_CONSECUTIVE_FAILURES = 100
|
||||
|
||||
Reference in New Issue
Block a user