Skip to content

Commit 492c81c

Browse files
ivasilovsaltcodjordienr
authored
feat: Cron tab for previous runs (supabase#30690)
* Redo cron ui to use a data table * Add component for form header * Add next run column * add cron jobs page * Load jobs from url, add runs footer, link to logs * Type issue * Fix height of tab area * Use nuqs with history * improve pgcron logs, add severity filters * Check for v1.5 to see if seconds are supported * fix cron jobs logs table name * Add type to the table * Move expression warning to own function * Fit to new layout * Types * Fix long code blocks * Revert some of the changes. * Use job name as a tab name. Other minor fixes. * Use Infinite query for the cron runs. * Revert some extra changes. Will be added to another PR. * Rename pg functions --------- Co-authored-by: Terry Sutton <[email protected]> Co-authored-by: Jordi Enric <[email protected]>
1 parent 2195581 commit 492c81c

File tree

19 files changed

+493
-46
lines changed

19 files changed

+493
-46
lines changed

apps/studio/components/interfaces/Integrations/CronJobs/CronJobCard.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,6 @@ interface CronJobCardProps {
2727
onDeleteCronJob: (job: CronJob) => void
2828
}
2929

30-
const generateJobDetailsSQL = (jobId: number) => {
31-
return `select * from cron.job_run_details where jobid = '${jobId}' order by start_time desc limit 10`
32-
}
33-
3430
export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCardProps) => {
3531
const [toggleConfirmationModalShown, showToggleConfirmationModal] = useState(false)
3632
const { ref } = useParams()
@@ -81,9 +77,7 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
8177
Edit cron job
8278
</DropdownMenuItem>
8379
<DropdownMenuItem asChild>
84-
<Link
85-
href={`/project/${ref}/sql/new?content=${encodeURIComponent(generateJobDetailsSQL(job.jobid))}`}
86-
>
80+
<Link href={`/project/${ref}/integrations/cron-jobs/cron-jobs/${job.jobname}`}>
8781
View previous runs
8882
</Link>
8983
</DropdownMenuItem>

apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export const CRONJOB_DEFINITIONS = [
1818
{
1919
value: 'sql_function',
2020
icon: <ScrollText strokeWidth={1} />,
21-
label: 'Postgres SQL Function',
22-
description: 'Choose a Postgres SQL functions to run.',
21+
label: 'Database function',
22+
description: 'Choose a database function to run.',
2323
},
2424

2525
{

apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,36 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
111111
return DEFAULT_CRONJOB_COMMAND
112112
}
113113

114+
export function calculateDuration(start: string, end: string): string {
115+
const startTime = new Date(start).getTime()
116+
const endTime = new Date(end).getTime()
117+
const duration = endTime - startTime
118+
return isNaN(duration) ? 'Invalid Date' : `${duration} ms`
119+
}
120+
121+
export function formatDate(dateString: string): string {
122+
const date = new Date(dateString)
123+
if (isNaN(date.getTime())) {
124+
return 'Invalid Date'
125+
}
126+
const options: Intl.DateTimeFormatOptions = {
127+
year: 'numeric',
128+
month: 'short', // Use 'long' for full month name
129+
day: '2-digit',
130+
hour: '2-digit',
131+
minute: '2-digit',
132+
second: '2-digit',
133+
hour12: false, // Use 12-hour format if preferred
134+
timeZoneName: 'short', // Optional: to include timezone
135+
}
136+
return date.toLocaleString(undefined, options)
137+
}
138+
114139
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
115140
export const secondsPattern = /^\d+\s+seconds$/
116141
export const cronPattern =
117142
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/
143+
144+
export function isSecondsFormat(schedule: string): boolean {
145+
return secondsPattern.test(schedule.trim())
146+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export default function CronJobsEmptyState({ page }: { page: string }) {
2+
return (
3+
<div className=" text-center h-full w-full items-center justify-center rounded-md px-4 py-12 ">
4+
<p className="text-sm text-foreground">
5+
{page === 'jobs' ? 'No cron jobs created yet' : 'No runs for this cron job yet'}
6+
</p>
7+
<p className="text-sm text-foreground-lighter">
8+
{page === 'jobs'
9+
? 'Create one by clicking "Create a new cron job"'
10+
: 'Check the schedule of your cron jobs to see when they run'}
11+
</p>
12+
</div>
13+
)
14+
}

apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ export const CronjobsTab = () => {
2626
projectRef: project?.ref,
2727
connectionString: project?.connectionString,
2828
})
29-
3029
if (isLoading)
3130
return (
3231
<div className="p-10">
@@ -95,9 +94,14 @@ export const CronjobsTab = () => {
9594
Your search for "{searchQuery}" did not return any results
9695
</p>
9796
</div>
97+
) : isLoading ? (
98+
<div className="p-10">
99+
<GenericSkeletonLoader />
100+
</div>
98101
) : (
99102
filteredCronJobs.map((job) => (
100103
<CronJobCard
104+
key={job.jobid}
101105
job={job}
102106
onEditCronJob={(job) => setCreateCronJobSheetShown(job)}
103107
onDeleteCronJob={(job) => setCronJobForDeletion(job)}
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import { toString as CronToString } from 'cronstrue'
2+
import { List } from 'lucide-react'
3+
import Link from 'next/link'
4+
import { UIEvent, useCallback, useMemo } from 'react'
5+
import DataGrid, { Column, Row } from 'react-data-grid'
6+
7+
import { useParams } from 'common'
8+
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
9+
import { useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query'
10+
import {
11+
CronJobRun,
12+
useCronJobRunsInfiniteQuery,
13+
} from 'data/database-cron-jobs/database-cron-jobs-runs-infinite-query'
14+
import {
15+
Badge,
16+
Button,
17+
cn,
18+
LoadingLine,
19+
SimpleCodeBlock,
20+
Tooltip_Shadcn_,
21+
TooltipContent_Shadcn_,
22+
TooltipTrigger_Shadcn_,
23+
} from 'ui'
24+
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
25+
import { calculateDuration, formatDate, isSecondsFormat } from './CronJobs.utils'
26+
import CronJobsEmptyState from './CronJobsEmptyState'
27+
28+
const cronJobColumns = [
29+
{
30+
id: 'runid',
31+
name: 'RunID',
32+
minWidth: 60,
33+
value: (row: CronJobRun) => (
34+
<div className="flex items-center gap-1.5">
35+
<h3 className="text-xs">{row.runid}</h3>
36+
</div>
37+
),
38+
},
39+
{
40+
id: 'message',
41+
name: 'Message',
42+
minWidth: 200,
43+
value: (row: CronJobRun) => (
44+
<div className="flex items-center gap-1.5">
45+
<Tooltip_Shadcn_>
46+
<TooltipTrigger_Shadcn_ asChild>
47+
<span className="text-xs cursor-pointer truncate max-w-[300px]">
48+
{row.return_message}
49+
</span>
50+
</TooltipTrigger_Shadcn_>
51+
<TooltipContent_Shadcn_ side="bottom" align="center" className="max-w-[300px] text-wrap">
52+
<SimpleCodeBlock
53+
showCopy={true}
54+
className="sql"
55+
parentClassName="!p-0 [&>div>span]:text-xs"
56+
>
57+
{row.return_message}
58+
</SimpleCodeBlock>
59+
</TooltipContent_Shadcn_>
60+
</Tooltip_Shadcn_>
61+
</div>
62+
),
63+
},
64+
65+
{
66+
id: 'status',
67+
name: 'Status',
68+
minWidth: 75,
69+
value: (row: CronJobRun) => (
70+
<Badge variant={row.status === 'succeeded' ? 'success' : 'warning'}>{row.status}</Badge>
71+
),
72+
},
73+
{
74+
id: 'start_time',
75+
name: 'Start Time',
76+
minWidth: 120,
77+
value: (row: CronJobRun) => <div className="text-xs">{formatDate(row.start_time)}</div>,
78+
},
79+
{
80+
id: 'end_time',
81+
name: 'End Time',
82+
minWidth: 120,
83+
value: (row: CronJobRun) => <div className="text-xs">{formatDate(row.end_time)}</div>,
84+
},
85+
86+
{
87+
id: 'duration',
88+
name: 'Duration',
89+
minWidth: 100,
90+
value: (row: CronJobRun) => (
91+
<div className="text-xs">{calculateDuration(row.start_time, row.end_time)}</div>
92+
),
93+
},
94+
]
95+
96+
const columns = cronJobColumns.map((col) => {
97+
const result: Column<CronJobRun> = {
98+
key: col.id,
99+
name: col.name,
100+
resizable: true,
101+
minWidth: col.minWidth ?? 120,
102+
headerCellClass: 'first:pl-6 cursor-pointer',
103+
renderHeaderCell: () => {
104+
return (
105+
<div className="flex items-center justify-between font-mono font-normal text-xs w-full">
106+
<div className="flex items-center gap-x-2">
107+
<p className="!text-foreground">{col.name}</p>
108+
</div>
109+
</div>
110+
)
111+
},
112+
renderCell: (props) => {
113+
const value = col.value(props.row)
114+
115+
return (
116+
<div
117+
className={cn(
118+
'w-full flex flex-col justify-center font-mono text-xs',
119+
typeof value === 'number' ? 'text-right' : ''
120+
)}
121+
>
122+
<span>{value}</span>
123+
</div>
124+
)
125+
},
126+
}
127+
return result
128+
})
129+
130+
function isAtBottom({ currentTarget }: UIEvent<HTMLDivElement>): boolean {
131+
return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight
132+
}
133+
134+
export const PreviousRunsTab = () => {
135+
const { childId: jobName } = useParams()
136+
const { project } = useProjectContext()
137+
138+
const { data: cronJobs, isLoading: isLoadingCronJobs } = useCronJobsQuery({
139+
projectRef: project?.ref,
140+
connectionString: project?.connectionString,
141+
})
142+
143+
const currentJobState = cronJobs?.find((job) => job.jobname === jobName)
144+
145+
const {
146+
data,
147+
isLoading: isLoadingCronJobRuns,
148+
fetchNextPage,
149+
isFetching,
150+
} = useCronJobRunsInfiniteQuery(
151+
{
152+
projectRef: project?.ref,
153+
connectionString: project?.connectionString,
154+
jobId: Number(currentJobState?.jobid),
155+
},
156+
{ enabled: !!currentJobState?.jobid, staleTime: 30 }
157+
)
158+
159+
const handleScroll = useCallback(
160+
(event: UIEvent<HTMLDivElement>) => {
161+
if (isLoadingCronJobRuns || !isAtBottom(event)) return
162+
// the cancelRefetch is to prevent the query from being refetched when the user scrolls back up and down again,
163+
// resulting in multiple fetchNextPage calls
164+
fetchNextPage({ cancelRefetch: false })
165+
},
166+
[fetchNextPage, isLoadingCronJobRuns]
167+
)
168+
169+
const cronJobRuns = useMemo(() => data?.pages.flatMap((p) => p) || [], [data?.pages])
170+
171+
return (
172+
<div className="h-full flex flex-col">
173+
<div className="mt-4 h-full">
174+
<LoadingLine loading={isFetching} />
175+
<DataGrid
176+
className="flex-grow h-full"
177+
rowHeight={44}
178+
headerRowHeight={36}
179+
onScroll={handleScroll}
180+
columns={columns}
181+
rows={cronJobRuns ?? []}
182+
rowClass={() => {
183+
const isSelected = false
184+
return cn([
185+
`${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} `,
186+
`${isSelected ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-foreground' : ''}`,
187+
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
188+
'[&>.rdg-cell:first-child>div]:ml-4',
189+
])
190+
}}
191+
renderers={{
192+
renderRow(_idx, props) {
193+
return <Row key={props.row.job_pid} {...props} />
194+
},
195+
noRowsFallback: isLoadingCronJobRuns ? (
196+
<div className="absolute top-14 px-6 w-full">
197+
<GenericSkeletonLoader />
198+
</div>
199+
) : (
200+
<div className="flex items-center justify-center w-full col-span-6">
201+
<CronJobsEmptyState page="runs" />
202+
</div>
203+
),
204+
}}
205+
/>
206+
</div>
207+
208+
<div className="px-6 py-6 flex gap-12 border-t">
209+
{isLoadingCronJobs ? (
210+
<GenericSkeletonLoader />
211+
) : (
212+
<>
213+
<div className="grid gap-2 w-56">
214+
<h3 className="text-sm">Schedule</h3>
215+
<p className="text-xs text-foreground-light">
216+
{currentJobState?.schedule ? (
217+
<>
218+
<span className="font-mono text-lg">{currentJobState.schedule}</span>
219+
<p>
220+
{isSecondsFormat(currentJobState.schedule)
221+
? ''
222+
: CronToString(currentJobState.schedule.toLowerCase())}
223+
</p>
224+
</>
225+
) : (
226+
<span>Loading schedule...</span>
227+
)}
228+
</p>
229+
</div>
230+
231+
<div className="grid gap-2">
232+
<h3 className="text-sm">Command</h3>
233+
<Tooltip_Shadcn_>
234+
<TooltipTrigger_Shadcn_ className=" text-left p-0! cursor-pointer truncate max-w-[300px] h-12 relative">
235+
<SimpleCodeBlock
236+
showCopy={false}
237+
className="sql"
238+
parentClassName=" [&>div>span]:text-xs bg-alternative-200 !p-2 rounded-md"
239+
>
240+
{currentJobState?.command}
241+
</SimpleCodeBlock>
242+
<div className="bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-background-200 to-transparent absolute " />
243+
</TooltipTrigger_Shadcn_>
244+
<TooltipContent_Shadcn_
245+
side="bottom"
246+
align="center"
247+
className="max-w-[400px] text-wrap"
248+
>
249+
<SimpleCodeBlock
250+
showCopy={false}
251+
className="sql"
252+
parentClassName=" [&>div>span]:text-xs bg-alternative-200 !p-2 rounded-md"
253+
>
254+
{currentJobState?.command}
255+
</SimpleCodeBlock>
256+
</TooltipContent_Shadcn_>
257+
</Tooltip_Shadcn_>
258+
{/* <div className="text-xs text-foreground-light">
259+
<SimpleCodeBlock
260+
showCopy={false}
261+
className="sql"
262+
parentClassName=" [&>div>span]:text-xs bg-alternative-200 !p-2 rounded-md"
263+
>
264+
{currentJobState?.command}
265+
</SimpleCodeBlock>
266+
</div> */}
267+
</div>
268+
269+
<div className="grid gap-2">
270+
<h3 className="text-sm">Explore</h3>
271+
<Button asChild type="outline" icon={<List strokeWidth={1.5} size="14" />}>
272+
{/* [Terry] need to link to the exact jobid, but not currently supported */}
273+
<Link target="_blank" href={`/project/${project?.ref}/logs/pgcron-logs/`}>
274+
View logs
275+
</Link>
276+
</Button>
277+
</div>
278+
</>
279+
)}
280+
</div>
281+
</div>
282+
)
283+
}

0 commit comments

Comments
 (0)