Skip to content

Commit eac8aca

Browse files
committed
Schedules page for workflows
1 parent 3371540 commit eac8aca

File tree

7 files changed

+394
-5
lines changed

7 files changed

+394
-5
lines changed

apps/sim/app/api/schedules/route.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
import { db } from '@sim/db'
2-
import { workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema'
2+
import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq, isNull, or } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
6+
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
67
import { getSession } from '@/lib/auth'
78
import { generateRequestId } from '@/lib/core/utils/request'
89
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
910

1011
const logger = createLogger('ScheduledAPI')
1112

1213
/**
13-
* Get schedule information for a workflow
14+
* Get schedule information for a workflow, or all schedules for a workspace.
15+
*
16+
* Query params (choose one):
17+
* - workflowId + optional blockId → single schedule for one workflow
18+
* - workspaceId → all schedules across the workspace
1419
*/
1520
export async function GET(req: NextRequest) {
1621
const requestId = generateRequestId()
1722
const url = new URL(req.url)
1823
const workflowId = url.searchParams.get('workflowId')
24+
const workspaceId = url.searchParams.get('workspaceId')
1925
const blockId = url.searchParams.get('blockId')
2026

2127
try {
@@ -25,8 +31,15 @@ export async function GET(req: NextRequest) {
2531
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
2632
}
2733

34+
if (workspaceId) {
35+
return handleWorkspaceSchedules(requestId, session.user.id, workspaceId)
36+
}
37+
2838
if (!workflowId) {
29-
return NextResponse.json({ error: 'Missing workflowId parameter' }, { status: 400 })
39+
return NextResponse.json(
40+
{ error: 'Missing workflowId or workspaceId parameter' },
41+
{ status: 400 }
42+
)
3043
}
3144

3245
const authorization = await authorizeWorkflowByWorkspacePermission({
@@ -99,3 +112,56 @@ export async function GET(req: NextRequest) {
99112
return NextResponse.json({ error: 'Failed to retrieve workflow schedule' }, { status: 500 })
100113
}
101114
}
115+
116+
async function handleWorkspaceSchedules(
117+
requestId: string,
118+
userId: string,
119+
workspaceId: string
120+
) {
121+
const hasPermission = await verifyWorkspaceMembership(userId, workspaceId)
122+
if (!hasPermission) {
123+
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
124+
}
125+
126+
logger.info(`[${requestId}] Getting all schedules for workspace ${workspaceId}`)
127+
128+
const rows = await db
129+
.select({
130+
schedule: workflowSchedule,
131+
workflowName: workflow.name,
132+
workflowColor: workflow.color,
133+
})
134+
.from(workflowSchedule)
135+
.innerJoin(workflow, eq(workflow.id, workflowSchedule.workflowId))
136+
.leftJoin(
137+
workflowDeploymentVersion,
138+
and(
139+
eq(workflowDeploymentVersion.workflowId, workflowSchedule.workflowId),
140+
eq(workflowDeploymentVersion.isActive, true)
141+
)
142+
)
143+
.where(
144+
and(
145+
eq(workflow.workspaceId, workspaceId),
146+
eq(workflowSchedule.triggerType, 'schedule'),
147+
or(
148+
eq(workflowSchedule.deploymentVersionId, workflowDeploymentVersion.id),
149+
and(isNull(workflowDeploymentVersion.id), isNull(workflowSchedule.deploymentVersionId))
150+
)
151+
)
152+
)
153+
154+
const headers = new Headers()
155+
headers.set('Cache-Control', 'no-store, max-age=0')
156+
157+
return NextResponse.json(
158+
{
159+
schedules: rows.map((r) => ({
160+
...r.schedule,
161+
workflowName: r.workflowName,
162+
workflowColor: r.workflowColor,
163+
})),
164+
},
165+
{ headers }
166+
)
167+
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
'use client'
2+
3+
import { useMemo, useState } from 'react'
4+
import { Clock, Search } from 'lucide-react'
5+
import { useParams, useRouter } from 'next/navigation'
6+
import {
7+
Badge,
8+
Table,
9+
TableBody,
10+
TableCell,
11+
TableHead,
12+
TableHeader,
13+
TableRow,
14+
Tooltip,
15+
} from '@/components/emcn'
16+
import { Input, Skeleton } from '@/components/ui'
17+
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
18+
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
19+
import { useWorkspaceSchedules } from '@/hooks/queries/schedules'
20+
import { useDebounce } from '@/hooks/use-debounce'
21+
22+
export function SchedulesView() {
23+
const params = useParams()
24+
const router = useRouter()
25+
const workspaceId = params.workspaceId as string
26+
27+
const { data: schedules = [], isLoading, error } = useWorkspaceSchedules(workspaceId)
28+
29+
const [searchQuery, setSearchQuery] = useState('')
30+
const debouncedSearchQuery = useDebounce(searchQuery, 300)
31+
32+
const filteredSchedules = useMemo(() => {
33+
if (!debouncedSearchQuery) return schedules
34+
35+
const query = debouncedSearchQuery.toLowerCase()
36+
return schedules.filter((s) => {
37+
const humanReadable = s.cronExpression
38+
? parseCronToHumanReadable(s.cronExpression, s.timezone)
39+
: ''
40+
return (
41+
s.workflowName.toLowerCase().includes(query) || humanReadable.toLowerCase().includes(query)
42+
)
43+
})
44+
}, [schedules, debouncedSearchQuery])
45+
46+
return (
47+
<div className='flex h-full flex-1 flex-col'>
48+
<div className='flex flex-1 overflow-hidden'>
49+
<div className='flex h-full flex-1 flex-col overflow-auto bg-white px-[24px] pt-[28px] pb-[24px] dark:bg-[var(--bg)]'>
50+
{/* Header */}
51+
<div>
52+
<div className='flex items-start gap-[12px]'>
53+
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#F59E0B] bg-[#FFFBEB] dark:border-[#B45309] dark:bg-[#451A03]'>
54+
<Clock className='h-[14px] w-[14px] text-[#F59E0B] dark:text-[#FBBF24]' />
55+
</div>
56+
<h1 className='font-medium text-[18px]'>Schedules</h1>
57+
</div>
58+
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
59+
View all scheduled workflows in your workspace.
60+
</p>
61+
</div>
62+
63+
{/* Search */}
64+
<div className='mt-[14px] flex items-center justify-between'>
65+
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-4)] px-[8px]'>
66+
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
67+
<Input
68+
placeholder='Search'
69+
value={searchQuery}
70+
onChange={(e) => setSearchQuery(e.target.value)}
71+
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'
72+
/>
73+
</div>
74+
</div>
75+
76+
{/* Content */}
77+
<div className='mt-[24px] min-h-0 flex-1 overflow-y-auto'>
78+
{isLoading ? (
79+
<ScheduleTableSkeleton />
80+
) : error ? (
81+
<div className='flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
82+
<p className='text-[14px] text-[var(--text-muted)]'>Failed to load schedules</p>
83+
</div>
84+
) : schedules.length === 0 ? (
85+
<div className='flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
86+
<div className='text-center'>
87+
<h3 className='font-medium text-[14px] text-[var(--text-secondary)]'>
88+
No scheduled workflows
89+
</h3>
90+
<p className='mt-[4px] text-[13px] text-[var(--text-muted)]'>
91+
Deploy a workflow with a schedule block to see it here.
92+
</p>
93+
</div>
94+
</div>
95+
) : filteredSchedules.length === 0 ? (
96+
<div className='py-[16px] text-center text-[14px] text-[var(--text-muted)]'>
97+
No schedules found matching &quot;{searchQuery}&quot;
98+
</div>
99+
) : (
100+
<Table className='table-fixed text-[14px]'>
101+
<TableHeader>
102+
<TableRow className='hover:bg-transparent'>
103+
<TableHead className='w-[28%] px-[12px] py-[8px] text-[13px] text-[var(--text-secondary)]'>
104+
Workflow
105+
</TableHead>
106+
<TableHead className='w-[30%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
107+
Schedule
108+
</TableHead>
109+
<TableHead className='w-[12%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
110+
Status
111+
</TableHead>
112+
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
113+
Next Run
114+
</TableHead>
115+
<TableHead className='w-[15%] px-[12px] py-[8px] text-left text-[13px] text-[var(--text-secondary)]'>
116+
Last Run
117+
</TableHead>
118+
</TableRow>
119+
</TableHeader>
120+
<TableBody>
121+
{filteredSchedules.map((schedule) => {
122+
const humanReadable = schedule.cronExpression
123+
? parseCronToHumanReadable(schedule.cronExpression, schedule.timezone)
124+
: 'Unknown schedule'
125+
126+
return (
127+
<TableRow
128+
key={schedule.id}
129+
className='cursor-pointer hover:bg-[var(--surface-2)]'
130+
onClick={() =>
131+
router.push(`/workspace/${workspaceId}/w/${schedule.workflowId}`)
132+
}
133+
>
134+
<TableCell className='px-[12px] py-[8px]'>
135+
<div className='flex min-w-0 items-center gap-[8px]'>
136+
<div
137+
className='h-[8px] w-[8px] flex-shrink-0 rounded-full'
138+
style={{ backgroundColor: schedule.workflowColor || '#3972F6' }}
139+
/>
140+
<span className='truncate text-[14px] text-[var(--text-primary)]'>
141+
{schedule.workflowName}
142+
</span>
143+
</div>
144+
</TableCell>
145+
<TableCell className='px-[12px] py-[8px] text-[13px] text-[var(--text-muted)]'>
146+
<span className='truncate'>{humanReadable}</span>
147+
</TableCell>
148+
<TableCell className='px-[12px] py-[8px]'>
149+
<Badge
150+
className={
151+
schedule.status === 'active'
152+
? 'rounded-[4px] text-[12px]'
153+
: 'rounded-[4px] text-[12px] opacity-60'
154+
}
155+
>
156+
{schedule.status}
157+
</Badge>
158+
</TableCell>
159+
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px] text-[var(--text-muted)]'>
160+
{schedule.nextRunAt ? (
161+
<Tooltip.Root>
162+
<Tooltip.Trigger asChild>
163+
<span>{formatRelativeTime(schedule.nextRunAt)}</span>
164+
</Tooltip.Trigger>
165+
<Tooltip.Content>
166+
{formatAbsoluteDate(schedule.nextRunAt)}
167+
</Tooltip.Content>
168+
</Tooltip.Root>
169+
) : (
170+
'—'
171+
)}
172+
</TableCell>
173+
<TableCell className='whitespace-nowrap px-[12px] py-[8px] text-[13px] text-[var(--text-muted)]'>
174+
{schedule.lastRanAt ? (
175+
<Tooltip.Root>
176+
<Tooltip.Trigger asChild>
177+
<span>{formatRelativeTime(schedule.lastRanAt)}</span>
178+
</Tooltip.Trigger>
179+
<Tooltip.Content>
180+
{formatAbsoluteDate(schedule.lastRanAt)}
181+
</Tooltip.Content>
182+
</Tooltip.Root>
183+
) : (
184+
'—'
185+
)}
186+
</TableCell>
187+
</TableRow>
188+
)
189+
})}
190+
</TableBody>
191+
</Table>
192+
)}
193+
</div>
194+
</div>
195+
</div>
196+
</div>
197+
)
198+
}
199+
200+
function ScheduleTableSkeleton() {
201+
return (
202+
<Table className='table-fixed text-[14px]'>
203+
<TableHeader>
204+
<TableRow className='hover:bg-transparent'>
205+
<TableHead className='w-[28%] px-[12px] py-[8px]'>
206+
<Skeleton className='h-[12px] w-[60px]' />
207+
</TableHead>
208+
<TableHead className='w-[30%] px-[12px] py-[8px]'>
209+
<Skeleton className='h-[12px] w-[56px]' />
210+
</TableHead>
211+
<TableHead className='w-[12%] px-[12px] py-[8px]'>
212+
<Skeleton className='h-[12px] w-[40px]' />
213+
</TableHead>
214+
<TableHead className='w-[15%] px-[12px] py-[8px]'>
215+
<Skeleton className='h-[12px] w-[52px]' />
216+
</TableHead>
217+
<TableHead className='w-[15%] px-[12px] py-[8px]'>
218+
<Skeleton className='h-[12px] w-[48px]' />
219+
</TableHead>
220+
</TableRow>
221+
</TableHeader>
222+
<TableBody>
223+
{Array.from({ length: 5 }, (_, i) => (
224+
<TableRow key={i} className='hover:bg-transparent'>
225+
<TableCell className='px-[12px] py-[8px]'>
226+
<div className='flex min-w-0 items-center gap-[8px]'>
227+
<Skeleton className='h-[8px] w-[8px] rounded-full' />
228+
<Skeleton className='h-[14px] w-[140px]' />
229+
</div>
230+
</TableCell>
231+
<TableCell className='px-[12px] py-[8px]'>
232+
<Skeleton className='h-[12px] w-[160px]' />
233+
</TableCell>
234+
<TableCell className='px-[12px] py-[8px]'>
235+
<Skeleton className='h-[12px] w-[48px]' />
236+
</TableCell>
237+
<TableCell className='px-[12px] py-[8px]'>
238+
<Skeleton className='h-[12px] w-[60px]' />
239+
</TableCell>
240+
<TableCell className='px-[12px] py-[8px]'>
241+
<Skeleton className='h-[12px] w-[60px]' />
242+
</TableCell>
243+
</TableRow>
244+
))}
245+
</TableBody>
246+
</Table>
247+
)
248+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function SchedulesLayout({ children }: { children: React.ReactNode }) {
2+
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
3+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { redirect } from 'next/navigation'
2+
import { getSession } from '@/lib/auth'
3+
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
4+
import { SchedulesView } from './components/schedules-view'
5+
6+
interface SchedulesPageProps {
7+
params: Promise<{
8+
workspaceId: string
9+
}>
10+
}
11+
12+
export default async function SchedulesPage({ params }: SchedulesPageProps) {
13+
const { workspaceId } = await params
14+
const session = await getSession()
15+
16+
if (!session?.user?.id) {
17+
redirect('/')
18+
}
19+
20+
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
21+
if (!hasPermission) {
22+
redirect('/')
23+
}
24+
25+
return <SchedulesView />
26+
}

0 commit comments

Comments
 (0)