fix: Minor fixes for the Cron integration (#35674)

* Fix the CronJob type because its name is optional.

* The Job previous runs page should be identified by job id, not job name because it can be undefined. Fix the tabs to show the job name by using additional query param.

* Add a tooltip explaining that cron jobs without names can't be edited.

* The parser for cron jobs should handle URLS with query params or hashes.

* Remove the .only description from the tests.

* Show Unknown for cron jobs which have "" as name.

* Fix a crash when a cron job has "1 second" (singular) as schedule.

* Calculate the next run properly when the schedule is "1 second".

* Show a simplified confirm modal when deleting a cron job without a name.
This commit is contained in:
Ivan Vasilov authored and GitHub committed 2025-05-16 11:49:29 +02:00
1 parent 0a53b2a2e6
commit b94ca1d53b
7 files changed
+127 -34

No files matched your search

@@ -22,6 +22,9 @@ import {
DropdownMenuTrigger,
Label_Shadcn_,
Switch,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
@@ -53,6 +56,17 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
const { mutate: sendEvent } = useSendEventMutation()
const { mutate: toggleDatabaseCronJob, isLoading } = useDatabaseCronJobToggleMutation()
const onEdit = () => {
sendEvent({
action: 'cron_job_update_clicked',
groups: {
project: selectedProject?.ref ?? 'Unknown',
organization: org?.slug ?? 'Unknown',
},
})
onEditCronJob(job)
}
return (
<>
<div className="bg-surface-100 border-default overflow-hidden border shadow px-5 py-4 flex flex-row rounded-md space-x-4">
@@ -64,10 +78,10 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
<span
className={cn(
'text-base',
job.jobname === null ? 'text-foreground-lighter' : 'text-foreground'
job.jobname ? 'text-foreground' : 'text-foreground-lighter'
)}
>
{job.jobname ?? 'No name provided'}
{job.jobname || 'No name provided'}
</span>
<div className="flex items-center gap-x-2">
{isLoading ? (
@@ -102,7 +116,7 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
}}
>
<Link
href={`/project/${ref}/integrations/cron/jobs/${encodeURIComponent(job.jobname)}`}
href={`/project/${ref}/integrations/cron/jobs/${encodeURIComponent(job.jobid)}?child-label=${encodeURIComponent(job.jobname || `Job #${job.jobid}`)}`}
>
History
</Link>
@@ -112,20 +126,21 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
<Button type="default" icon={<MoreVertical />} className="px-1.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem
onClick={() => {
sendEvent({
action: 'cron_job_update_clicked',
groups: {
project: selectedProject?.ref ?? 'Unknown',
organization: org?.slug ?? 'Unknown',
},
})
onEditCronJob(job)
}}
>
Edit cron job
</DropdownMenuItem>
{job.jobname ? (
<DropdownMenuItem onClick={onEdit}>Edit cron job</DropdownMenuItem>
) : (
<Tooltip>
<TooltipTrigger className="w-full">
<DropdownMenuItem onClick={onEdit} disabled>
Edit cron job
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent>
This cron job doesn’t have a name and can’t be edited. Create a new one and
delete this job.
</TooltipContent>
</Tooltip>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
@@ -41,11 +41,16 @@ describe('parseCronJobCommand', () => {
})
it('should return a edge function config when the command posts to its own supabase.co project', () => {
const command = `select net.http_post( url:='https://random_project_ref.supabase.co/functions/v1/_', headers:=jsonb_build_object(), body:='', timeout_milliseconds:=5000 );`
const command = `select net.http_post( url:='https://random_project_ref.supabase.co/functions/v1/_', headers:=jsonb_build_object('Authorization', 'Bearer something'), body:='', timeout_milliseconds:=5000 );`
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
edgeFunctionName: 'https://random_project_ref.supabase.co/functions/v1/_',
method: 'POST',
httpHeaders: [],
httpHeaders: [
{
name: 'Authorization',
value: 'Bearer something',
},
],
httpBody: '',
timeoutMs: 5000,
type: 'edge_function',
@@ -53,6 +58,42 @@ describe('parseCronJobCommand', () => {
})
})
it('should return a edge function config when the body is missing', () => {
const command = `select net.http_post( url:='https://random_project_ref.supabase.co/functions/v1/_', headers:=jsonb_build_object('Authorization', 'Bearer something'), timeout_milliseconds:=5000 );`
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
edgeFunctionName: 'https://random_project_ref.supabase.co/functions/v1/_',
method: 'POST',
httpHeaders: [
{
name: 'Authorization',
value: 'Bearer something',
},
],
httpBody: '',
timeoutMs: 5000,
type: 'edge_function',
snippet: command,
})
})
it("should return a HTTP request config when there's a query parameter or hash in the URL (also handles edge function)", () => {
const command = `select net.http_post( url:='https://random_project_ref.supabase.co/functions/v1/_?first=1#second=2', headers:=jsonb_build_object('Authorization', 'Bearer something'), timeout_milliseconds:=5000 )`
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
endpoint: 'https://random_project_ref.supabase.co/functions/v1/_?first=1#second=2',
method: 'POST',
httpHeaders: [
{
name: 'Authorization',
value: 'Bearer something',
},
],
httpBody: '',
timeoutMs: 5000,
type: 'http_request',
snippet: command,
})
})
it('should return a HTTP request config when the command posts to another supabase.co project', () => {
const command = `select net.http_post( url:='https://another_project_ref.supabase.co/functions/v1/_', headers:=jsonb_build_object(), body:='', timeout_milliseconds:=5000 );`
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
@@ -85,7 +85,22 @@ export const parseCronJobCommand = (originalCommand: string, projectRef: string)
}
}
if (url.includes(`${projectRef}.supabase.`) && url.includes('/functions/v1/')) {
// If there's a search param or hash in the edge function URL, let it be handled by the HTTP Request case.
// Otherwise, the params/hash may be lost during editing of the cron job.
let searchParams = ''
let urlHash = ''
try {
const urlObject = new URL(url)
searchParams = urlObject.search
urlHash = urlObject.hash
} catch {}
if (
url.includes(`${projectRef}.supabase.`) &&
url.includes('/functions/v1/') &&
searchParams.length === 0 &&
urlHash.length === 0
) {
return {
type: 'edge_function',
method: method === 'http_get' ? 'GET' : 'POST',
@@ -168,10 +183,10 @@ export const cronPattern =
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
export const secondsPattern = /^\d+\s+seconds$/
export const secondsPattern = /^\d+\s+seconds*$/
export function isSecondsFormat(schedule: string): boolean {
return secondsPattern.test(schedule.trim())
return secondsPattern.test(schedule.trim().toLocaleLowerCase())
}
export function getScheduleMessage(scheduleString: string) {
@@ -230,11 +245,11 @@ export const getNextRun = (schedule: string, lastRun?: string) => {
return undefined
}
} else {
// [Joshen] Only going to attempt to parse if the schedule is as simple as "n seconds", "n minutes", or "n days"
// [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.split(' ')
const [value, unit] = schedule.toLocaleLowerCase().split(' ')
if (
['seconds', 'minutes', 'days'].includes(unit) &&
['second', 'seconds'].includes(unit) &&
!Number.isNaN(Number(value)) &&
lastRun !== undefined
) {
@@ -6,6 +6,7 @@ import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
interface DeleteCronJobProps {
cronJob: CronJob
@@ -43,6 +44,22 @@ export const DeleteCronJob = ({ cronJob, visible, onClose }: DeleteCronJobProps)
return null
}
// Cron job name is optional. If the cron job has no name, show a simplified modal which doesn't require the user to input the name.
if (!cronJob.jobname) {
return (
<ConfirmationModal
variant="destructive"
visible={visible}
onCancel={() => onClose()}
onConfirm={handleDelete}
title={`Delete the cron job`}
loading={isLoading}
confirmLabel={`Delete`}
alert={{ title: 'You cannot recover this cron job once deleted.' }}
/>
)
}
return (
<TextConfirmModal
variant="destructive"
@@ -133,16 +133,16 @@ function isAtBottom({ currentTarget }: UIEvent<HTMLDivElement>): boolean {
}
export const PreviousRunsTab = () => {
const { childId: jobName } = useParams()
const { childId } = useParams()
const { project } = useProjectContext()
const jobId = Number(childId)
const { data: cronJobs, isLoading: isLoadingCronJobs } = useCronJobsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const currentJobState = cronJobs?.find((job) => job.jobname === jobName)
const {
data,
isLoading: isLoadingCronJobRuns,
@@ -153,9 +153,9 @@ export const PreviousRunsTab = () => {
{
projectRef: project?.ref,
connectionString: project?.connectionString,
jobId: Number(currentJobState?.jobid),
jobId: jobId,
},
{ enabled: !!currentJobState?.jobid, staleTime: 30 }
{ enabled: !!jobId, staleTime: 30 }
)
useEffect(() => {
@@ -177,6 +177,7 @@ export const PreviousRunsTab = () => {
[fetchNextPage, isLoadingCronJobRuns]
)
const currentJobState = cronJobs?.find((job) => job.jobid === jobId)
const cronJobRuns = useMemo(() => data?.pages.flatMap((p) => p) || [], [data?.pages])
return (
@@ -224,7 +225,9 @@ export const PreviousRunsTab = () => {
<p className="text-xs text-foreground-light">
{currentJobState?.schedule ? (
<>
<span className="font-mono text-lg">{currentJobState.schedule}</span>
<span className="font-mono text-lg">
{currentJobState.schedule.toLocaleLowerCase()}
</span>
<p>
{isSecondsFormat(currentJobState.schedule)
? ''
@@ -25,7 +25,7 @@ interface IntegrationTabsProps {
export const IntegrationTabs = ({ scroll, isSticky }: IntegrationTabsProps) => {
const navRef = useRef(null)
const { project } = useProjectContext()
const { id, pageId, childId } = useParams()
const { id, pageId, childId, childLabel } = useParams()
const isMobile = useBreakpoint('md')
const { installedIntegrations } = useInstalledIntegrations()
@@ -97,7 +97,9 @@ export const IntegrationTabs = ({ scroll, isSticky }: IntegrationTabsProps) => {
>
<NavMenuItem active={true} className="flex items-center gap-2">
{tab.childIcon}
<Link href={`${tabUrl}/${childId}`}>{childId}</Link>
<Link href={`${tabUrl}/${childId}`}>
{childLabel ? childLabel : childId}
</Link>
</NavMenuItem>
</motion.div>
</>
@@ -17,7 +17,7 @@ export type CronJob = {
database: string
username: string
active: boolean
jobname: string
jobname: string | null
}
const cronJobSqlQuery = `select * from cron.job order by jobid;`