mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
## Context
The Integrations -> Cron overview page renders a list of cron jobs by
doing a join on both `cron.jobs` and `cron.job_run_details` in order to
pull the data for "Latest run" and "Status" ("Next run" is just derived
from "Latest Run" and the job's schedule)
In the past, we've had users run into issues where this query would time
out, likely due to the join on `job_run_details` especially if that
table is really big (and the columns on that table aren't indexed), and
our solution was to inform users to clear out that table + schedule a
job to clean the `job_run_details` table on a certain cadence. This CTA
however blocks the UI from rendering the cron jobs overview entirely
<img width="1644" height="590" alt="image"
src="https://github.com/user-attachments/assets/1a0458e9-f686-452b-88bc-44459bc7c615"
/>
## Changes involved
Am opting to use the new preflight check introduced
[here](https://github.com/supabase/supabase/pulls?q=is%3Apr+author%3Ajoshenlim+is%3Aclosed)
- Attempt to fetch the data for the cron jobs overview normally
- If preflight check rejects (due to high cost), then opt to fetch the
data while omitting the join
- Show a banner instead to inform the user accordingly
- So that way users can still view their cron jobs (the latest run
column is a nice to have convenience really)
<img width="1151" height="379" alt="image"
src="https://github.com/user-attachments/assets/72cfaafb-e951-4601-b54e-c36a6f36332d"
/>
- Clicking learn more will then open a dialog to explain what this is
all about
- The steps to clear the `job_run_details` table + schedule a clean up
job still remains
<img width="450" height="484" alt="image"
src="https://github.com/user-attachments/assets/4491e766-64a1-40c9-9703-2e8b0da11c3f"
/>
<img width="450" height="610" alt="Screenshot 2026-02-05 at 12 56 39"
src="https://github.com/user-attachments/assets/644c9a7b-5afb-46bb-8c1e-9ece3986c2fb"
/>
<img width="450" height="507" alt="Screenshot 2026-02-05 at 13 01 37"
src="https://github.com/user-attachments/assets/9f0f40f2-7376-4c28-90f1-0b21a1a4560a"
/>
- Non-UI related changes include
- Automatically refresh cron jobs after scheduling a job, remove manual
CTA to refresh the jobs after scheduling the clean up job
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Previous Runs tab on cron job pages
* Interactive "Learn more" cleanup dialog with guided delete and
schedule steps
* Close-confirmation modal when editing cron jobs
* High-cost banner that enables a lightweight "minimal" list mode
* **Bug Fixes**
* Clearer placeholder (minus icon/message) when a job has no runs
* **Performance**
* Safer, more efficient fetching and pagination for large cron job
lists; reduced retries on high-cost queries
* **Tests**
* Expanded end-to-end tests covering high-cost flows and the cleanup
dialog
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
import parser from 'cron-parser'
|
||
import dayjs from 'dayjs'
|
||
import { Copy, Edit, Minus, MoreVertical, Play, Trash } from 'lucide-react'
|
||
import { parseAsString, useQueryState } from 'nuqs'
|
||
import { useState } from 'react'
|
||
import { toast } from 'sonner'
|
||
|
||
import { useDatabaseCronJobRunCommandMutation } from 'data/database-cron-jobs/database-cron-job-run-mutation'
|
||
import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query'
|
||
import { useDatabaseCronJobToggleMutation } from 'data/database-cron-jobs/database-cron-jobs-toggle-mutation'
|
||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||
import {
|
||
Badge,
|
||
Button,
|
||
cn,
|
||
CodeBlock,
|
||
ContextMenu_Shadcn_,
|
||
ContextMenuContent_Shadcn_,
|
||
ContextMenuItem_Shadcn_,
|
||
ContextMenuSeparator_Shadcn_,
|
||
ContextMenuTrigger_Shadcn_,
|
||
copyToClipboard,
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogSection,
|
||
DialogSectionSeparator,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
HoverCard,
|
||
HoverCardContent,
|
||
HoverCardTrigger,
|
||
Switch,
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipTrigger,
|
||
} from 'ui'
|
||
import { TimestampInfo } from 'ui-patterns'
|
||
|
||
const getNextRun = (schedule: string, lastRun?: string) => {
|
||
// cron-parser can only deal with the traditional cron syntax but technically users can also
|
||
// use strings like "30 seconds" now, For the latter case, we try our best to parse the next run
|
||
// (can't guarantee as scope is quite big)
|
||
if (schedule.includes('*')) {
|
||
try {
|
||
const interval = parser.parseExpression(schedule, { tz: 'UTC' })
|
||
return interval.next().getTime()
|
||
} catch (error) {
|
||
return undefined
|
||
}
|
||
} else {
|
||
// [Joshen] Only going to attempt to parse if the schedule is as simple as "n second" or "n seconds"
|
||
// Returned undefined otherwise - we can revisit this perhaps if we get feedback about this
|
||
const [value, unit] = schedule.toLocaleLowerCase().split(' ')
|
||
if (
|
||
['second', 'seconds'].includes(unit) &&
|
||
!Number.isNaN(Number(value)) &&
|
||
lastRun !== undefined
|
||
) {
|
||
const parsedLastRun = dayjs(lastRun).add(Number(value), unit as dayjs.ManipulateType)
|
||
return parsedLastRun.valueOf()
|
||
} else {
|
||
return undefined
|
||
}
|
||
}
|
||
}
|
||
|
||
interface CronJobTableCellProps {
|
||
col: any
|
||
row: any
|
||
onSelectEdit: (job: CronJob) => void
|
||
onSelectDelete: (job: CronJob) => void
|
||
}
|
||
|
||
export const CronJobTableCell = ({
|
||
col,
|
||
row,
|
||
onSelectEdit,
|
||
onSelectDelete,
|
||
}: CronJobTableCellProps) => {
|
||
const { data: project } = useSelectedProjectQuery()
|
||
const [searchQuery] = useQueryState('search', parseAsString.withDefault(''))
|
||
|
||
const [showToggleModal, setShowToggleModal] = useState(false)
|
||
|
||
const value = row?.[col.id]
|
||
const hasValue = col.id in row
|
||
const { jobid, schedule, latest_run, status, active, jobname } = row
|
||
|
||
const formattedValue =
|
||
col.id === 'jobname' && !jobname
|
||
? 'No name provided'
|
||
: col.id === 'lastest_run'
|
||
? !!value
|
||
? dayjs(value).valueOf()
|
||
: undefined
|
||
: col.id === 'next_run'
|
||
? getNextRun(schedule, latest_run)
|
||
: value
|
||
|
||
const { mutate: runCronJob, isPending: isRunning } = useDatabaseCronJobRunCommandMutation({
|
||
onSuccess: () => {
|
||
toast.success(`Command from "${jobname}" ran successfully`)
|
||
},
|
||
})
|
||
|
||
const { mutate: toggleDatabaseCronJob, isPending: isToggling } = useDatabaseCronJobToggleMutation(
|
||
{
|
||
onSuccess: (_, vars) => {
|
||
toast.success(`Successfully ${vars.active ? 'enabled' : 'disabled'} "${jobname}"`)
|
||
setShowToggleModal(false)
|
||
},
|
||
}
|
||
)
|
||
|
||
const onRunCronJob = () => {
|
||
runCronJob({
|
||
projectRef: project?.ref!,
|
||
connectionString: project?.connectionString,
|
||
jobId: jobid,
|
||
})
|
||
}
|
||
|
||
const onConfirmToggle = () => {
|
||
toggleDatabaseCronJob({
|
||
projectRef: project?.ref!,
|
||
connectionString: project?.connectionString,
|
||
jobId: jobid,
|
||
active: !active,
|
||
searchTerm: searchQuery,
|
||
})
|
||
}
|
||
|
||
if (col.id === 'actions') {
|
||
return (
|
||
<div className="flex items-center">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
type="text"
|
||
loading={isRunning}
|
||
className="h-6 w-6"
|
||
icon={<MoreVertical />}
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-60 space-y-1">
|
||
<Tooltip>
|
||
<TooltipTrigger className="w-full">
|
||
<DropdownMenuItem
|
||
className="gap-x-2"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onRunCronJob()
|
||
}}
|
||
>
|
||
<Play size={12} />
|
||
Run command
|
||
</DropdownMenuItem>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
Manual runs execute the command immediately and will not appear in the cron jobs
|
||
table.
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<DropdownMenuItem
|
||
className="gap-x-2"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onSelectEdit(row)
|
||
}}
|
||
>
|
||
<Edit size={12} />
|
||
Edit job
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
className="gap-x-2"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onSelectDelete(row)
|
||
}}
|
||
>
|
||
<Trash size={12} />
|
||
Delete job
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (col.id === 'active') {
|
||
return (
|
||
<Dialog open={showToggleModal} onOpenChange={setShowToggleModal}>
|
||
<DialogTrigger className="flex items-center" onClick={(e) => e.stopPropagation()}>
|
||
<Switch
|
||
id={`cron-job-active-${jobid}`}
|
||
size="medium"
|
||
disabled={isToggling}
|
||
checked={active}
|
||
/>
|
||
</DialogTrigger>
|
||
<DialogContent
|
||
onClick={(e) => e.stopPropagation()}
|
||
dialogOverlayProps={{ onClick: (e) => e.stopPropagation() }}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>{active ? 'Disable' : 'Enable'} cron job</DialogTitle>
|
||
</DialogHeader>
|
||
<DialogSectionSeparator />
|
||
<DialogSection>
|
||
<p className="text-sm">
|
||
Are you sure you want to {active ? 'disable' : 'enable'} the cron job "{jobname}"?{' '}
|
||
</p>
|
||
</DialogSection>
|
||
<DialogFooter>
|
||
<Button type="default" onClick={() => setShowToggleModal(false)}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
type={active ? 'warning' : 'primary'}
|
||
loading={isToggling}
|
||
onClick={onConfirmToggle}
|
||
>
|
||
{active ? 'Disable' : 'Enable'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<ContextMenu_Shadcn_>
|
||
<ContextMenuTrigger_Shadcn_ asChild>
|
||
<div className={cn('w-full flex items-center text-xs')}>
|
||
{['latest_run', 'next_run'].includes(col.id) ? (
|
||
!hasValue ? (
|
||
<Minus size={14} className="text-foreground-lighter" />
|
||
) : col.id === 'latest_run' && formattedValue === null ? (
|
||
<p className="text-foreground-lighter">Job has not been run yet</p>
|
||
) : col.id === 'next_run' && !formattedValue ? (
|
||
<p className="text-foreground-lighter">Unable to parse next run for job</p>
|
||
) : (
|
||
<TimestampInfo
|
||
utcTimestamp={formattedValue}
|
||
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
|
||
className="font-sans text-xs"
|
||
/>
|
||
)
|
||
) : col.id === 'command' ? (
|
||
<HoverCard openDelay={0} closeDelay={0}>
|
||
<HoverCardTrigger asChild>
|
||
<div className="text-xs font-mono w-full h-full flex items-center">
|
||
{formattedValue}
|
||
</div>
|
||
</HoverCardTrigger>
|
||
<HoverCardContent
|
||
align="end"
|
||
className="p-0 w-[400px]"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<p className="text-xs font-mono px-2 py-1 border-b">Command</p>
|
||
<CodeBlock
|
||
hideLineNumbers
|
||
language="sql"
|
||
value={formattedValue.trim()}
|
||
className={cn(
|
||
'py-0 px-3.5 max-w-full prose dark:prose-dark border-0 rounded-t-none',
|
||
'[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11',
|
||
'[&>code]:text-xs'
|
||
)}
|
||
/>
|
||
</HoverCardContent>
|
||
</HoverCard>
|
||
) : (
|
||
<p
|
||
className={cn(
|
||
col.id === 'jobname' && !jobname && 'text-foreground-lighter',
|
||
col.id === 'command' && 'font-mono'
|
||
)}
|
||
>
|
||
{formattedValue}
|
||
</p>
|
||
)}
|
||
{col.id === 'latest_run' && !!status && (
|
||
<Badge
|
||
variant={status === 'failed' ? 'destructive' : 'success'}
|
||
className="capitalize ml-2"
|
||
>
|
||
{status}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</ContextMenuTrigger_Shadcn_>
|
||
<ContextMenuContent_Shadcn_ onClick={(e) => e.stopPropagation()}>
|
||
<ContextMenuItem_Shadcn_
|
||
className="gap-x-2"
|
||
onFocusCapture={(e) => e.stopPropagation()}
|
||
onSelect={() => copyToClipboard(formattedValue)}
|
||
>
|
||
<Copy size={12} />
|
||
<span>Copy {col.name.toLowerCase()}</span>
|
||
</ContextMenuItem_Shadcn_>
|
||
|
||
<ContextMenuItem_Shadcn_
|
||
disabled={!jobname}
|
||
onFocusCapture={(e) => e.stopPropagation()}
|
||
onSelect={() => onSelectEdit(row)}
|
||
>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<div className="flex items-center gap-x-2 w-full">
|
||
<Edit size={12} />
|
||
<span>Edit job</span>
|
||
</div>
|
||
</TooltipTrigger>
|
||
{!jobname && (
|
||
<TooltipContent side="right" className="w-56">
|
||
This cron job doesn’t have a name and can’t be edited. Create a new one and delete
|
||
this job.
|
||
</TooltipContent>
|
||
)}
|
||
</Tooltip>
|
||
</ContextMenuItem_Shadcn_>
|
||
|
||
<ContextMenuSeparator_Shadcn_ />
|
||
|
||
<ContextMenuItem_Shadcn_
|
||
className="gap-x-2"
|
||
onFocusCapture={(e) => e.stopPropagation()}
|
||
onSelect={() => onSelectDelete(row)}
|
||
>
|
||
<Trash size={12} />
|
||
<span>Delete job</span>
|
||
</ContextMenuItem_Shadcn_>
|
||
</ContextMenuContent_Shadcn_>
|
||
</ContextMenu_Shadcn_>
|
||
)
|
||
}
|