Schedules page for workflows

This commit is contained in:
Siddharth Ganesan
2026-03-05 10:31:01 -08:00
parent 337154054e
commit eac8aca0c0
7 changed files with 394 additions and 5 deletions

View File

@@ -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 }
)
}

View File

@@ -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 &quot;{searchQuery}&quot;
</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>
)
}

View File

@@ -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>
}

View 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 />
}

View File

@@ -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',

View File

@@ -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,

View File

@@ -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
*/