mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
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;`
|
||||
|
||||
Reference in new issue
Block a user