mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Schedules page for workflows
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema'
|
||||
import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
@@ -10,12 +11,17 @@ import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
const logger = createLogger('ScheduledAPI')
|
||||
|
||||
/**
|
||||
* Get schedule information for a workflow
|
||||
* Get schedule information for a workflow, or all schedules for a workspace.
|
||||
*
|
||||
* Query params (choose one):
|
||||
* - workflowId + optional blockId → single schedule for one workflow
|
||||
* - workspaceId → all schedules across the workspace
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const url = new URL(req.url)
|
||||
const workflowId = url.searchParams.get('workflowId')
|
||||
const workspaceId = url.searchParams.get('workspaceId')
|
||||
const blockId = url.searchParams.get('blockId')
|
||||
|
||||
try {
|
||||
@@ -25,8 +31,15 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (workspaceId) {
|
||||
return handleWorkspaceSchedules(requestId, session.user.id, workspaceId)
|
||||
}
|
||||
|
||||
if (!workflowId) {
|
||||
return NextResponse.json({ error: 'Missing workflowId parameter' }, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing workflowId or workspaceId parameter' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
@@ -99,3 +112,56 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Failed to retrieve workflow schedule' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWorkspaceSchedules(
|
||||
requestId: string,
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
) {
|
||||
const hasPermission = await verifyWorkspaceMembership(userId, workspaceId)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Getting all schedules for workspace ${workspaceId}`)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
schedule: workflowSchedule,
|
||||
workflowName: workflow.name,
|
||||
workflowColor: workflow.color,
|
||||
})
|
||||
.from(workflowSchedule)
|
||||
.innerJoin(workflow, eq(workflow.id, workflowSchedule.workflowId))
|
||||
.leftJoin(
|
||||
workflowDeploymentVersion,
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowSchedule.workflowId),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(workflow.workspaceId, workspaceId),
|
||||
eq(workflowSchedule.triggerType, 'schedule'),
|
||||
or(
|
||||
eq(workflowSchedule.deploymentVersionId, workflowDeploymentVersion.id),
|
||||
and(isNull(workflowDeploymentVersion.id), isNull(workflowSchedule.deploymentVersionId))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const headers = new Headers()
|
||||
headers.set('Cache-Control', 'no-store, max-age=0')
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
schedules: rows.map((r) => ({
|
||||
...r.schedule,
|
||||
workflowName: r.workflowName,
|
||||
workflowColor: r.workflowColor,
|
||||
})),
|
||||
},
|
||||
{ headers }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Clock, Search } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
|
||||
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
|
||||
import { useWorkspaceSchedules } from '@/hooks/queries/schedules'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
export function SchedulesView() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { data: schedules = [], isLoading, error } = useWorkspaceSchedules(workspaceId)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
|
||||
const filteredSchedules = useMemo(() => {
|
||||
if (!debouncedSearchQuery) return schedules
|
||||
|
||||
const query = debouncedSearchQuery.toLowerCase()
|
||||
return schedules.filter((s) => {
|
||||
const humanReadable = s.cronExpression
|
||||
? parseCronToHumanReadable(s.cronExpression, s.timezone)
|
||||
: ''
|
||||
return (
|
||||
s.workflowName.toLowerCase().includes(query) || humanReadable.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
}, [schedules, debouncedSearchQuery])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex h-full flex-1 flex-col overflow-auto bg-white px-[24px] pt-[28px] pb-[24px] dark:bg-[var(--bg)]'>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#F59E0B] bg-[#FFFBEB] dark:border-[#B45309] dark:bg-[#451A03]'>
|
||||
<Clock className='h-[14px] w-[14px] text-[#F59E0B] dark:text-[#FBBF24]' />
|
||||
</div>
|
||||
<h1 className='font-medium text-[18px]'>Schedules</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
|
||||
View all scheduled workflows in your workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-4)] px-[8px]'>
|
||||
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
<Input
|
||||
placeholder='Search'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className='mt-[24px] min-h-0 flex-1 overflow-y-auto'>
|
||||
{isLoading ? (
|
||||
<ScheduleTableSkeleton />
|
||||
) : error ? (
|
||||
<div className='flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||
<p className='text-[14px] text-[var(--text-muted)]'>Failed to load schedules</p>
|
||||
</div>
|
||||
) : schedules.length === 0 ? (
|
||||
<div className='flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<h3 className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
No scheduled workflows
|
||||
</h3>
|
||||
<p className='mt-[4px] text-[13px] text-[var(--text-muted)]'>
|
||||
Deploy a workflow with a schedule block to see it here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredSchedules.length === 0 ? (
|
||||
<div className='py-[16px] text-center text-[14px] text-[var(--text-muted)]'>
|
||||
No schedules found matching "{searchQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<Table className='table-fixed text-[14px]'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-[28%] px-[12px] py-[8px] text-[13px] text-[var(--text-secondary)]'>
|
||||
Workflow
|
||||
</TableHead>
|
||||
<TableHead className='w-[30%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
|
||||
Schedule
|
||||
</TableHead>
|
||||
<TableHead className='w-[12%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
|
||||
Next Run
|
||||
</TableHead>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
|
||||
Last Run
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSchedules.map((schedule) => {
|
||||
const humanReadable = schedule.cronExpression
|
||||
? parseCronToHumanReadable(schedule.cronExpression, schedule.timezone)
|
||||
: 'Unknown schedule'
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={schedule.id}
|
||||
className='cursor-pointer hover:bg-[var(--surface-2)]'
|
||||
onClick={() =>
|
||||
router.push(`/workspace/${workspaceId}/w/${schedule.workflowId}`)
|
||||
}
|
||||
>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
<div
|
||||
className='h-[8px] w-[8px] flex-shrink-0 rounded-full'
|
||||
style={{ backgroundColor: schedule.workflowColor || '#3972F6' }}
|
||||
/>
|
||||
<span className='truncate text-[14px] text-[var(--text-primary)]'>
|
||||
{schedule.workflowName}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px] text-[13px] text-[var(--text-muted)]'>
|
||||
<span className='truncate'>{humanReadable}</span>
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<Badge
|
||||
className={
|
||||
schedule.status === 'active'
|
||||
? 'rounded-[4px] text-[12px]'
|
||||
: 'rounded-[4px] text-[12px] opacity-60'
|
||||
}
|
||||
>
|
||||
{schedule.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px] text-[var(--text-muted)]'>
|
||||
{schedule.nextRunAt ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span>{formatRelativeTime(schedule.nextRunAt)}</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{formatAbsoluteDate(schedule.nextRunAt)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px] text-[var(--text-muted)]'>
|
||||
{schedule.lastRanAt ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span>{formatRelativeTime(schedule.lastRanAt)}</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{formatAbsoluteDate(schedule.lastRanAt)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScheduleTableSkeleton() {
|
||||
return (
|
||||
<Table className='table-fixed text-[14px]'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-[28%] px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[12px] w-[60px]' />
|
||||
</TableHead>
|
||||
<TableHead className='w-[30%] px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[12px] w-[56px]' />
|
||||
</TableHead>
|
||||
<TableHead className='w-[12%] px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[12px] w-[40px]' />
|
||||
</TableHead>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[12px] w-[52px]' />
|
||||
</TableHead>
|
||||
<TableHead className='w-[15%] px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[12px] w-[48px]' />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<TableRow key={i} className='hover:bg-transparent'>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
<Skeleton className='h-[8px] w-[8px] rounded-full' />
|
||||
<Skeleton className='h-[14px] w-[140px]' />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[12px] w-[160px]' />
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[12px] w-[48px]' />
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[12px] w-[60px]' />
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<Skeleton className='h-[12px] w-[60px]' />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function SchedulesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
|
||||
}
|
||||
26
apps/sim/app/workspace/[workspaceId]/schedules/page.tsx
Normal file
26
apps/sim/app/workspace/[workspaceId]/schedules/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { SchedulesView } from './components/schedules-view'
|
||||
|
||||
interface SchedulesPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function SchedulesPage({ params }: SchedulesPageProps) {
|
||||
const { workspaceId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
|
||||
if (!hasPermission) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
return <SchedulesView />
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import { Database, Files, HelpCircle, Layout, Settings } from 'lucide-react'
|
||||
import { Clock, Database, Files, HelpCircle, Layout, Settings } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Blimp, Library } from '@/components/emcn'
|
||||
@@ -142,6 +142,12 @@ export function SearchModal({
|
||||
href: `/workspace/${workspaceId}/files`,
|
||||
hidden: permissionConfig.hideFilesTab,
|
||||
},
|
||||
{
|
||||
id: 'schedules',
|
||||
name: 'Schedules',
|
||||
icon: Clock,
|
||||
href: `/workspace/${workspaceId}/schedules`,
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
name: 'Help',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Database, Files, HelpCircle, MoreHorizontal, Plus, Search, Settings } from 'lucide-react'
|
||||
import { Clock, Database, Files, HelpCircle, MoreHorizontal, Plus, Search, Settings } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import {
|
||||
@@ -316,6 +316,12 @@ export const Sidebar = memo(function Sidebar() {
|
||||
icon: Library,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
},
|
||||
{
|
||||
id: 'schedules',
|
||||
label: 'Schedules',
|
||||
icon: Clock,
|
||||
href: `/workspace/${workspaceId}/schedules`,
|
||||
},
|
||||
].filter((item) => !item.hidden),
|
||||
[
|
||||
workspaceId,
|
||||
|
||||
@@ -7,6 +7,7 @@ const logger = createLogger('ScheduleQueries')
|
||||
|
||||
export const scheduleKeys = {
|
||||
all: ['schedules'] as const,
|
||||
list: (workspaceId: string) => [...scheduleKeys.all, 'list', workspaceId] as const,
|
||||
schedule: (workflowId: string, blockId: string) =>
|
||||
[...scheduleKeys.all, workflowId, blockId] as const,
|
||||
}
|
||||
@@ -21,6 +22,12 @@ export interface ScheduleData {
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
export interface WorkspaceScheduleData extends ScheduleData {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
workflowColor: string
|
||||
}
|
||||
|
||||
export interface ScheduleInfo {
|
||||
id: string
|
||||
status: 'active' | 'disabled'
|
||||
@@ -50,6 +57,33 @@ async function fetchSchedule(workflowId: string, blockId: string): Promise<Sched
|
||||
return data.schedule || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all schedules for a workspace.
|
||||
*/
|
||||
export function useWorkspaceSchedules(workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: scheduleKeys.list(workspaceId ?? ''),
|
||||
queryFn: async () => {
|
||||
if (!workspaceId) throw new Error('Workspace ID required')
|
||||
|
||||
const res = await fetch(`/api/schedules?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}))
|
||||
throw new Error(error.error || 'Failed to fetch schedules')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return (data.schedules || []) as WorkspaceScheduleData[]
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch schedule data for a workflow block
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user