mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
[FE-3724] feat(studio): add enable cleanup button to cron jobs page (#48200)
Adds a standalone **Enable cleanup** button to the Cron Jobs page header so users can schedule the daily `delete-job-run-details` cleanup job proactively — previously this was only reachable inside the conditional "table too big" overflow dialog. Addresses [FE-3724](https://linear.app/supabase/issue/FE-3724/enable-pg-cron-cleanup-job-from-ui-and-api) (the UI half; the Management API half needs platform-side work). **Added:** - `Enable cleanup` button in the cron jobs header (left of Refresh), hidden while the existence check loads and whenever a `delete-job-run-details` job already exists - Confirmation dialog with a retention-period select (defaults to 7 days), live SQL preview, and telemetry (`cron_job_cleanup_enable_button_clicked` with `origin` + `retentionInterval`) - Component tests (MSW) for visibility gating and the schedule/cancel flows - E2E regression test for the full schedule → delete → button-reappears cycle **Fixed:** - Name-based `useCronJobQuery` lookup: the `queryFn` dropped the `name` param, and a not-found job returned `undefined` (rejected by react-query v5) — now passes `name` through and returns `CronJob | null` - Cache invalidation gaps: create/delete now invalidate the whole cron-jobs prefix (list, count, job details), so the footer count updates after create/delete and the button reappears after the cleanup job is deleted. The schedule mutation deliberately invalidates only the existence check + count (see inline comment) - Pre-existing e2e leak: the cleanup-workflow test left `delete-job-run-details` scheduled; it now cleans up after itself ## Screenshots | Header button | Dialog | | --- | --- | | <img width="890" height="325" alt="Screenshot 2026-07-22 at 9 44 40 PM" src="https://github.com/user-attachments/assets/966cd640-d8a6-4c8f-92e7-73151bf4de9c" /> | <img width="512" height="461" alt="fe3724-dialog" src="https://github.com/user-attachments/assets/6be1785f-cc7e-4048-a648-9ef260b0949f" /> | ## To test - Go to a project's Integrations → Cron → Jobs with pg_cron enabled and no `delete-job-run-details` job → the `Enable cleanup` button shows next to Refresh - Open the dialog, switch retention intervals → the SQL preview updates; confirm → success toast, the job appears in the grid (`0 12 * * *`), and the button disappears without a reload - Delete the `delete-job-run-details` job from the grid → the button reappears without a reload - Create then delete any other job → the footer `Total: N jobs` count updates both ways without a reload - Regression: with the high-query-cost banner forced (or via the e2e), the overflow dialog's "Schedule cleanup job" step still shows its success state — the dialog must not close mid-flow <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Added an **Enable cleanup** action to the Cron Jobs tab header, including a retention selector and SQL preview. * Enabling schedules the daily cleanup, shows a success toast, updates the grid, and hides the enable button; **Cancel** closes the dialog without scheduling. * **Bug Fixes** * Improved cron job lookup to work by name when needed. * Refreshed related cron job data more reliably after scheduling and deletion. * **Telemetry** * Added an event for cleanup enable button clicks. * **Tests** * Added component and Playwright coverage for enable/cancel/schedule/delete and cleanup banner flows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
1 parent
223a10d1bb
commit
a06eb5f26f
11 files changed
+633
-200
No files matched your search
+138
@@ -0,0 +1,138 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { EnableCleanupButton } from './CronJobsTab.EnableCleanupButton'
|
||||
import { ProjectContextProvider } from '@/components/layouts/ProjectLayout/ProjectContext'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
import { routerMock } from '@/tests/lib/route-mock'
|
||||
|
||||
mockAnimationsApi()
|
||||
|
||||
const cleanupJobRow = {
|
||||
jobid: 1,
|
||||
jobname: 'delete-job-run-details',
|
||||
schedule: '0 12 * * *',
|
||||
command: `DELETE FROM cron.job_run_details WHERE end_time < now() - interval '7 days';`,
|
||||
active: true,
|
||||
}
|
||||
|
||||
// Mutable state for the pg-meta mock, reset per test. Scheduling flips
|
||||
// cleanupJobExists so the subsequent existence refetch sees the new job,
|
||||
// mirroring the real invalidation flow.
|
||||
let cleanupJobExists = false
|
||||
let lookupCount = 0
|
||||
let scheduleQueries: string[] = []
|
||||
|
||||
const renderButton = (onScheduled = vi.fn()) => {
|
||||
customRender(
|
||||
<ProjectContextProvider projectRef="default">
|
||||
<EnableCleanupButton onScheduled={onScheduled} />
|
||||
</ProjectContextProvider>
|
||||
)
|
||||
return onScheduled
|
||||
}
|
||||
|
||||
describe('EnableCleanupButton', () => {
|
||||
beforeEach(() => {
|
||||
cleanupJobExists = false
|
||||
lookupCount = 0
|
||||
scheduleQueries = []
|
||||
|
||||
// useSelectedProjectQuery -> useParams
|
||||
routerMock.setCurrentUrl('/project/default/integrations/cron/jobs')
|
||||
// useSelectedProjectQuery
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/projects/:ref',
|
||||
// @ts-expect-error partial project shape
|
||||
response: {
|
||||
cloud_provider: 'localhost',
|
||||
id: 1,
|
||||
inserted_at: '2021-08-02T06:40:40.646Z',
|
||||
name: 'Default Project',
|
||||
organization_id: 1,
|
||||
ref: 'default',
|
||||
region: 'local',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
},
|
||||
})
|
||||
// The existence lookup (useCronJobQuery by name) and the schedule mutation
|
||||
// both go through the pg-meta query endpoint with different SQL
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/pg-meta/:ref/query',
|
||||
response: async ({ request }) => {
|
||||
const { query } = (await request.json()) as { query: string }
|
||||
|
||||
if (query.includes('cron.schedule')) {
|
||||
scheduleQueries.push(query)
|
||||
cleanupJobExists = true
|
||||
return HttpResponse.json([{ schedule: 1 }])
|
||||
}
|
||||
|
||||
// jobname lookup
|
||||
lookupCount += 1
|
||||
return HttpResponse.json(cleanupJobExists ? [cleanupJobRow] : [])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('shows the button once the cleanup job is confirmed missing', async () => {
|
||||
renderButton()
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Enable cleanup' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('hides the button when the cleanup job already exists', async () => {
|
||||
cleanupJobExists = true
|
||||
renderButton()
|
||||
|
||||
await waitFor(() => expect(lookupCount).toBeGreaterThan(0))
|
||||
expect(screen.queryByRole('button', { name: 'Enable cleanup' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('schedules the cleanup job with the selected retention interval', async () => {
|
||||
const onScheduled = renderButton()
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Enable cleanup' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByText('Enable automatic cleanup')).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('combobox')).toHaveTextContent('Older than 7 days')
|
||||
|
||||
await userEvent.click(within(dialog).getByRole('combobox'))
|
||||
await userEvent.click(await screen.findByRole('option', { name: 'Older than 1 day' }))
|
||||
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Enable cleanup' }))
|
||||
|
||||
await waitFor(() => expect(scheduleQueries).toHaveLength(1))
|
||||
expect(scheduleQueries[0]).toContain(`'delete-job-run-details'`)
|
||||
expect(scheduleQueries[0]).toContain(`'0 12 * * *'`)
|
||||
expect(scheduleQueries[0]).toContain(`interval ''1 day''`)
|
||||
|
||||
await waitFor(() => expect(onScheduled).toHaveBeenCalledTimes(1))
|
||||
|
||||
// The mutation invalidates the existence query, which now returns the job,
|
||||
// so the whole component (dialog included) unmounts
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'Enable cleanup' })).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('cancel closes the dialog without scheduling', async () => {
|
||||
renderButton()
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Enable cleanup' }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(scheduleQueries).toHaveLength(0)
|
||||
expect(screen.getByRole('button', { name: 'Enable cleanup' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import { CRON_CLEANUP_JOB_NAME, getScheduleDeleteCronJobRunDetailsSql } from '@supabase/pg-meta'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from 'ui'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
|
||||
import { CLEANUP_INTERVALS } from './CronJobsTab.constants'
|
||||
import { useCronJobQuery } from '@/data/database-cron-jobs/database-cron-job-query'
|
||||
import { useScheduleCronJobRunDetailsCleanupMutation } from '@/data/database-cron-jobs/schedule-clean-up-mutation'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
const DEFAULT_CLEANUP_INTERVAL =
|
||||
CLEANUP_INTERVALS.find((option) => option.value === '7 days')?.value ?? CLEANUP_INTERVALS[0].value
|
||||
|
||||
interface EnableCleanupButtonProps {
|
||||
onScheduled: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click action to schedule the daily cleanup job that trims old rows from
|
||||
* cron.job_run_details. Hidden once the cleanup job already exists — the job
|
||||
* itself then shows up in the jobs grid.
|
||||
*/
|
||||
export const EnableCleanupButton = ({ onScheduled }: EnableCleanupButtonProps) => {
|
||||
const track = useTrack()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [cleanupInterval, setCleanupInterval] = useState(DEFAULT_CLEANUP_INTERVAL)
|
||||
|
||||
const { data: cleanupJob, isSuccess } = useCronJobQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
name: CRON_CLEANUP_JOB_NAME,
|
||||
})
|
||||
|
||||
const { mutate: scheduleCleanup, isPending: isScheduling } =
|
||||
useScheduleCronJobRunDetailsCleanupMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Scheduled daily cleanup job.')
|
||||
setOpen(false)
|
||||
onScheduled()
|
||||
},
|
||||
})
|
||||
|
||||
if (!isSuccess || cleanupJob !== null) return null
|
||||
|
||||
const onConfirm = () => {
|
||||
if (!project?.ref) {
|
||||
return toast.error('There was an error scheduling the cleanup. Please try again.')
|
||||
}
|
||||
track('cron_job_cleanup_enable_button_clicked', {
|
||||
origin: 'dialog',
|
||||
retentionInterval: cleanupInterval,
|
||||
})
|
||||
scheduleCleanup({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
interval: cleanupInterval,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen)
|
||||
if (isOpen) track('cron_job_cleanup_enable_button_clicked', { origin: 'header' })
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default">Enable cleanup</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable automatic cleanup</DialogTitle>
|
||||
<DialogDescription>
|
||||
Schedules a daily job that deletes old cron job run records
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection className="flex flex-col gap-y-4">
|
||||
<p className="text-sm">
|
||||
Every cron job run is recorded in the{' '}
|
||||
<code className="text-code-inline break-keep!">cron.job_run_details</code> table.
|
||||
Without periodic cleanup, the table grows indefinitely and bloats the database.
|
||||
</p>
|
||||
<div className="flex flex-col gap-y-2 text-sm">
|
||||
<p className="text-foreground">Delete run history</p>
|
||||
<div className="sm:w-64">
|
||||
<Select
|
||||
disabled={isScheduling}
|
||||
value={cleanupInterval}
|
||||
onValueChange={setCleanupInterval}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select an interval" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CLEANUP_INTERVALS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<CodeBlock
|
||||
hideLineNumbers
|
||||
language="sql"
|
||||
value={getScheduleDeleteCronJobRunDetailsSql(cleanupInterval)}
|
||||
className="py-3 px-4 text-xs"
|
||||
wrapperClassName="max-w-full"
|
||||
/>
|
||||
</DialogSection>
|
||||
<DialogFooter>
|
||||
<Button variant="default" disabled={isScheduling} onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={isScheduling} onClick={onConfirm}>
|
||||
Enable cleanup
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { KeyboardEvent, Ref } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
import { EnableCleanupButton } from './CronJobsTab.EnableCleanupButton'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
|
||||
interface CronJobsTabHeaderProps {
|
||||
@@ -59,6 +60,7 @@ export const CronJobsTabHeader = ({
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<EnableCleanupButton onScheduled={onRefresh} />
|
||||
<Button variant="default" icon={<RefreshCw />} loading={isRefreshing} onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -27,13 +27,13 @@ export async function getDatabaseCronJob({
|
||||
sql: !!id
|
||||
? safeSql`SELECT * FROM cron.job where jobid = ${literal(id)};`
|
||||
: safeSql`SELECT * FROM cron.job where jobname = ${literal(name)};`,
|
||||
queryKey: ['cron-job', id],
|
||||
queryKey: ['cron-job', id ?? name],
|
||||
})
|
||||
|
||||
return result[0]
|
||||
return (result[0] ?? null) as CronJob | null
|
||||
}
|
||||
|
||||
export type DatabaseCronJobData = CronJob
|
||||
export type DatabaseCronJobData = CronJob | null
|
||||
export type DatabaseCronJobError = ResponseError
|
||||
|
||||
export const useCronJobQuery = <TData = DatabaseCronJobData>(
|
||||
@@ -45,7 +45,7 @@ export const useCronJobQuery = <TData = DatabaseCronJobData>(
|
||||
) =>
|
||||
useQuery<DatabaseCronJobData, DatabaseCronJobError, TData>({
|
||||
queryKey: databaseCronJobsKeys.job(projectRef, id ?? name),
|
||||
queryFn: () => getDatabaseCronJob({ projectRef, connectionString, id }),
|
||||
queryFn: () => getDatabaseCronJob({ projectRef, connectionString, id, name }),
|
||||
enabled:
|
||||
enabled &&
|
||||
typeof projectRef !== 'undefined' &&
|
||||
|
||||
@@ -48,19 +48,13 @@ export const useDatabaseCronJobCreateMutation = ({
|
||||
return useMutation<DatabaseCronJobCreateData, ResponseError, DatabaseCronJobCreateVariables>({
|
||||
mutationFn: (vars) => createDatabaseCronJob(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, searchTerm, identifier } = variables
|
||||
const { projectRef, searchTerm } = variables
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: databaseCronJobsKeys.jobs(projectRef) }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: databaseCronJobsKeys.listInfinite(projectRef, searchTerm),
|
||||
queryKey: databaseCronJobsKeys.listInfiniteMinimal(projectRef, searchTerm),
|
||||
}),
|
||||
...(!!identifier
|
||||
? [
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: databaseCronJobsKeys.job(projectRef, identifier),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
])
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
|
||||
@@ -48,9 +48,12 @@ export const useDatabaseCronJobDeleteMutation = ({
|
||||
mutationFn: (vars) => deleteDatabaseCronJob(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, searchTerm } = variables
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: databaseCronJobsKeys.listInfinite(projectRef, searchTerm),
|
||||
})
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: databaseCronJobsKeys.jobs(projectRef) }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: databaseCronJobsKeys.listInfiniteMinimal(projectRef, searchTerm),
|
||||
}),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -4,6 +4,8 @@ export const databaseCronJobsKeys = {
|
||||
create: () => ['cron-jobs', 'create'] as const,
|
||||
delete: () => ['cron-jobs', 'delete'] as const,
|
||||
alter: () => ['cronjobs', 'alter'] as const,
|
||||
/** Prefix of job, listInfinite, count, run, and runsInfinite — but not listInfiniteMinimal */
|
||||
jobs: (projectRef: string | undefined) => ['projects', projectRef, 'cron-jobs'] as const,
|
||||
job: (projectRef: string | undefined, identifier: number | string | undefined) =>
|
||||
['projects', projectRef, 'cron-jobs', identifier] as const,
|
||||
listInfinite: (projectRef: string | undefined, searchTerm: string | undefined) =>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getScheduleDeleteCronJobRunDetailsSql } from '@supabase/pg-meta'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { CRON_CLEANUP_JOB_NAME, getScheduleDeleteCronJobRunDetailsSql } from '@supabase/pg-meta'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { getScheduleDeleteCronJobRunDetailsKey } from './keys'
|
||||
import { databaseCronJobsKeys, getScheduleDeleteCronJobRunDetailsKey } from './keys'
|
||||
import { executeSql } from '@/data/sql/execute-sql-mutation'
|
||||
import type { ResponseError, UseCustomMutationOptions } from '@/types'
|
||||
|
||||
@@ -43,6 +43,8 @@ export const useScheduleCronJobRunDetailsCleanupMutation = ({
|
||||
>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<
|
||||
ScheduleCronJobRunDetailsCleanupData,
|
||||
ResponseError,
|
||||
@@ -50,6 +52,18 @@ export const useScheduleCronJobRunDetailsCleanupMutation = ({
|
||||
>({
|
||||
mutationFn: (vars) => scheduleCronJobRunDetailsCleanup(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
// Deliberately narrower than the jobs() prefix: invalidating the jobs list here would
|
||||
// refetch it while it sits in the cost-threshold error state, which briefly resets it
|
||||
// to pending and unmounts the overflow notice (closing its dialog mid-flow). Both
|
||||
// callers refetch their own grid via callbacks instead.
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: databaseCronJobsKeys.job(variables.projectRef, CRON_CLEANUP_JOB_NAME),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: databaseCronJobsKeys.count(variables.projectRef),
|
||||
}),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -70,6 +70,27 @@ const unscheduleJobByIdViaAPI = async (page: Page, ref: string, jobId: number) =
|
||||
})
|
||||
}
|
||||
|
||||
// Mirrors CRON_CLEANUP_JOB_NAME in @supabase/pg-meta (not a dependency of this package).
|
||||
// The name is hardcoded in the product, so tests touching the cleanup feature contend on
|
||||
// this single global name.
|
||||
const CLEANUP_JOB_NAME = 'delete-job-run-details'
|
||||
|
||||
// Runs in the page context (supabase_admin) because cron.unschedule by name only finds jobs
|
||||
// the calling role can manage, and the UI schedules the job as supabase_admin. Deletes
|
||||
// tolerantly since the job may or may not exist.
|
||||
const unscheduleCleanupJobIfExists = async (page: Page, ref: string) => {
|
||||
await page.request.post(toUrl(`/api/platform/pg-meta/${ref}/query`), {
|
||||
failOnStatusCode: true,
|
||||
data: {
|
||||
query: `do $$ begin
|
||||
if exists (select 1 from cron.job where jobname = '${CLEANUP_JOB_NAME}') then
|
||||
perform cron.unschedule('${CLEANUP_JOB_NAME}');
|
||||
end if;
|
||||
end $$;`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Cron Jobs', () => {
|
||||
test.beforeAll(async () => {
|
||||
await withFileOnceSetup(import.meta.url, async () => {
|
||||
@@ -341,205 +362,298 @@ test.describe('Cron Jobs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('High Query Cost Banner', () => {
|
||||
test('shows banner and still displays cron jobs when query cost exceeds threshold', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
const testJobName = 'pw_high_cost_test_job'
|
||||
await navigateToCronJobsPage(page, ref)
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await createJobViaAPI(page, ref, testJobName)
|
||||
},
|
||||
async () => {
|
||||
await deleteJobViaAPI(page, ref, testJobName)
|
||||
}
|
||||
)
|
||||
// Both groups below schedule the product-wide delete-job-run-details job, so they contend
|
||||
// on a single global job name. Opt out of fullyParallel for them (sequential, same worker)
|
||||
// to avoid cross-worker races on that name.
|
||||
test.describe('Cleanup Job Scheduling', () => {
|
||||
test.describe.configure({ mode: 'default' })
|
||||
|
||||
// Now set up the mock for the EXPLAIN query (preflight check) to return high cost
|
||||
// This simulates the scenario where cron.job_run_details is too large
|
||||
await page.route('**/pg-meta/*/query**', async (route) => {
|
||||
const request = route.request()
|
||||
const postData = request.postDataJSON()
|
||||
// Regression test for FE-3724: the "Enable cleanup" header button schedules the daily
|
||||
// job_run_details cleanup job without waiting for the high-query-cost banner. Covers the
|
||||
// full cycle in one scenario because the states are causally chained: schedule via the
|
||||
// dialog (button hides), then delete the job and verify the button reappears without a
|
||||
// reload (the cache-invalidation regression).
|
||||
test.describe('Enable Cleanup Button', () => {
|
||||
test('schedules the cleanup job and reappears after the job is deleted', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await unscheduleCleanupJobIfExists(page, ref)
|
||||
},
|
||||
async () => {
|
||||
await unscheduleCleanupJobIfExists(page, ref)
|
||||
}
|
||||
)
|
||||
await navigateToCronJobsPage(page, ref)
|
||||
|
||||
// Intercept EXPLAIN queries for the cron jobs query (the preflight check)
|
||||
if (
|
||||
postData?.query?.toLowerCase().startsWith('explain') &&
|
||||
postData?.query?.includes('cron.job')
|
||||
) {
|
||||
// Return a mock EXPLAIN result with very high cost (over 100,000 threshold)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
'QUERY PLAN':
|
||||
'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)',
|
||||
},
|
||||
]),
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
const enableCleanupButton = page.getByRole('button', { name: 'Enable cleanup' })
|
||||
await expect(
|
||||
enableCleanupButton,
|
||||
'Enable cleanup button should be visible when the cleanup job does not exist'
|
||||
).toBeVisible({ timeout: 30000 })
|
||||
|
||||
await enableCleanupButton.click()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Enable automatic cleanup' }),
|
||||
'Dialog should open with the enable cleanup title'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('combobox'),
|
||||
'Retention select should default to 7 days'
|
||||
).toContainText('Older than 7 days')
|
||||
await expect(
|
||||
dialog.getByText(/cron\.schedule/),
|
||||
'Dialog should preview the SQL that will run'
|
||||
).toBeVisible()
|
||||
|
||||
await dialog.getByRole('button', { name: 'Enable cleanup' }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText('Scheduled daily cleanup job.'),
|
||||
'Success toast should appear after scheduling'
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
|
||||
// The scheduled job shows up in the grid and the header button hides, both without a reload
|
||||
const jobRow = page.getByRole('row', { name: new RegExp(CLEANUP_JOB_NAME) })
|
||||
await expect(jobRow, 'Scheduled cleanup job should appear in the grid').toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
await expect(
|
||||
enableCleanupButton,
|
||||
'Enable cleanup button should hide once the cleanup job exists'
|
||||
).toHaveCount(0)
|
||||
|
||||
// Delete the job from the grid and verify the button comes back without a reload
|
||||
await jobRow.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Delete job' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Delete this cron job' })).toBeVisible()
|
||||
await page.getByPlaceholder('Type in name of cron job').fill(CLEANUP_JOB_NAME)
|
||||
await page.getByRole('button', { name: `Delete cron job ${CLEANUP_JOB_NAME}` }).click()
|
||||
|
||||
await expect(page.getByText(/Successfully removed cron job/)).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
await expect(jobRow).not.toBeVisible({ timeout: 10000 })
|
||||
await expect(
|
||||
enableCleanupButton,
|
||||
'Enable cleanup button should reappear after the cleanup job is deleted'
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
// Navigate to the cron jobs page - this will trigger the mocked preflight check
|
||||
await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
|
||||
|
||||
// Wait for the grid to load - this verifies cron jobs are still visible in minimal mode
|
||||
await expect(page.getByRole('grid')).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// Should show the overflow banner (minimal mode indicator)
|
||||
await expect(
|
||||
page.getByText('Last run for each cron job omitted due to high query cost')
|
||||
).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// The test job should still be visible in the grid (minimal mode shows jobs without last run)
|
||||
await expect(page.getByRole('row', { name: new RegExp(testJobName) })).toBeVisible()
|
||||
|
||||
// An alert about high query costs should be visible and contain a "Learn more" button
|
||||
await expect(
|
||||
page.getByRole('alert').getByRole('button', { name: 'Learn more' })
|
||||
).toBeVisible()
|
||||
|
||||
// Remove the route mock for subsequent tests
|
||||
await page.unroute('**/pg-meta/*/query**')
|
||||
})
|
||||
|
||||
test('Learn more dialog shows cleanup options', async ({ page, ref }) => {
|
||||
// Set up the mock again for this test
|
||||
await page.route('**/pg-meta/*/query**', async (route) => {
|
||||
const request = route.request()
|
||||
const postData = request.postDataJSON()
|
||||
test.describe('High Query Cost Banner', () => {
|
||||
test('shows banner and still displays cron jobs when query cost exceeds threshold', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
const testJobName = 'pw_high_cost_test_job'
|
||||
await navigateToCronJobsPage(page, ref)
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await createJobViaAPI(page, ref, testJobName)
|
||||
},
|
||||
async () => {
|
||||
await deleteJobViaAPI(page, ref, testJobName)
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
postData?.query?.toLowerCase().startsWith('explain') &&
|
||||
postData?.query?.includes('cron.job')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
'QUERY PLAN':
|
||||
'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)',
|
||||
},
|
||||
]),
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
// Now set up the mock for the EXPLAIN query (preflight check) to return high cost
|
||||
// This simulates the scenario where cron.job_run_details is too large
|
||||
await page.route('**/pg-meta/*/query**', async (route) => {
|
||||
const request = route.request()
|
||||
const postData = request.postDataJSON()
|
||||
|
||||
// Intercept EXPLAIN queries for the cron jobs query (the preflight check)
|
||||
if (
|
||||
postData?.query?.toLowerCase().startsWith('explain') &&
|
||||
postData?.query?.includes('cron.job')
|
||||
) {
|
||||
// Return a mock EXPLAIN result with very high cost (over 100,000 threshold)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
'QUERY PLAN':
|
||||
'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)',
|
||||
},
|
||||
]),
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
// Navigate to the cron jobs page - this will trigger the mocked preflight check
|
||||
await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
|
||||
|
||||
// Wait for the grid to load - this verifies cron jobs are still visible in minimal mode
|
||||
await expect(page.getByRole('grid')).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// Should show the overflow banner (minimal mode indicator)
|
||||
await expect(
|
||||
page.getByText('Last run for each cron job omitted due to high query cost')
|
||||
).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// The test job should still be visible in the grid (minimal mode shows jobs without last run)
|
||||
await expect(page.getByRole('row', { name: new RegExp(testJobName) })).toBeVisible()
|
||||
|
||||
// An alert about high query costs should be visible and contain a "Learn more" button
|
||||
await expect(
|
||||
page.getByRole('alert').getByRole('button', { name: 'Learn more' })
|
||||
).toBeVisible()
|
||||
|
||||
// Remove the route mock for subsequent tests
|
||||
await page.unroute('**/pg-meta/*/query**')
|
||||
})
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
|
||||
test('Learn more dialog shows cleanup options', async ({ page, ref }) => {
|
||||
// Set up the mock again for this test
|
||||
await page.route('**/pg-meta/*/query**', async (route) => {
|
||||
const request = route.request()
|
||||
const postData = request.postDataJSON()
|
||||
|
||||
// Wait for the banner to appear
|
||||
await expect(
|
||||
page.getByText('Last run for each cron job omitted due to high query cost')
|
||||
).toBeVisible({
|
||||
timeout: 15000,
|
||||
if (
|
||||
postData?.query?.toLowerCase().startsWith('explain') &&
|
||||
postData?.query?.includes('cron.job')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
'QUERY PLAN':
|
||||
'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)',
|
||||
},
|
||||
]),
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
|
||||
|
||||
// Wait for the banner to appear
|
||||
await expect(
|
||||
page.getByText('Last run for each cron job omitted due to high query cost')
|
||||
).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// Click the "Learn more" button in the alert to open the dialog
|
||||
await page.getByRole('alert').getByRole('button', { name: 'Learn more' }).click()
|
||||
|
||||
// The dialog should open with the explanation
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Last run for cron jobs omitted for overview' })
|
||||
).toBeVisible()
|
||||
|
||||
// Should explain the issue
|
||||
await expect(
|
||||
page.getByText(/the estimated query cost exceeds safety thresholds/)
|
||||
).toBeVisible()
|
||||
|
||||
// Should show Step 1 with cleanup options
|
||||
await expect(page.getByText('Step 1: Delete older entries')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Delete rows now' })).toBeVisible()
|
||||
|
||||
// Should show the interval selector
|
||||
await expect(page.getByRole('combobox')).toBeVisible()
|
||||
|
||||
// Should show Step 2 (disabled until step 1 is complete)
|
||||
await expect(page.getByText('Step 2: Schedule an automated cleanup')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Complete step 1 to enable scheduling a daily cleanup job')
|
||||
).toBeVisible()
|
||||
|
||||
// Remove the route mock
|
||||
await page.unroute('**/pg-meta/*/query**')
|
||||
})
|
||||
|
||||
// Click the "Learn more" button in the alert to open the dialog
|
||||
await page.getByRole('alert').getByRole('button', { name: 'Learn more' }).click()
|
||||
test('cleanup workflow: delete rows and schedule cleanup job', async ({ page, ref }) => {
|
||||
// This flow schedules the delete-job-run-details job; remove it afterwards so tests
|
||||
// that depend on the job's absence (Enable Cleanup Button) start clean. Deleting via
|
||||
// the UI here causes pointer events issues with Radix dialogs, hence via SQL.
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {},
|
||||
async () => {
|
||||
await unscheduleCleanupJobIfExists(page, ref)
|
||||
}
|
||||
)
|
||||
|
||||
// The dialog should open with the explanation
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Last run for cron jobs omitted for overview' })
|
||||
).toBeVisible()
|
||||
// Set up the mock for the high cost scenario
|
||||
await page.route('**/pg-meta/*/query**', async (route) => {
|
||||
const request = route.request()
|
||||
const postData = request.postDataJSON()
|
||||
|
||||
// Should explain the issue
|
||||
await expect(
|
||||
page.getByText(/the estimated query cost exceeds safety thresholds/)
|
||||
).toBeVisible()
|
||||
if (
|
||||
postData?.query?.toLowerCase().startsWith('explain') &&
|
||||
postData?.query?.includes('cron.job')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
'QUERY PLAN':
|
||||
'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)',
|
||||
},
|
||||
]),
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
// Should show Step 1 with cleanup options
|
||||
await expect(page.getByText('Step 1: Delete older entries')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Delete rows now' })).toBeVisible()
|
||||
await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
|
||||
|
||||
// Should show the interval selector
|
||||
await expect(page.getByRole('combobox')).toBeVisible()
|
||||
// Wait for the banner and click Learn more
|
||||
await expect(
|
||||
page.getByText('Last run for each cron job omitted due to high query cost')
|
||||
).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await page.getByRole('alert').getByRole('button', { name: 'Learn more' }).click()
|
||||
|
||||
// Should show Step 2 (disabled until step 1 is complete)
|
||||
await expect(page.getByText('Step 2: Schedule an automated cleanup')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Complete step 1 to enable scheduling a daily cleanup job')
|
||||
).toBeVisible()
|
||||
// Wait for dialog to open
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Last run for cron jobs omitted for overview' })
|
||||
).toBeVisible()
|
||||
|
||||
// Remove the route mock
|
||||
await page.unroute('**/pg-meta/*/query**')
|
||||
})
|
||||
// Step 1: Click "Delete rows now" to run the cleanup
|
||||
await page.getByRole('button', { name: 'Delete rows now' }).click()
|
||||
|
||||
test('cleanup workflow: delete rows and schedule cleanup job', async ({ page, ref }) => {
|
||||
// Set up the mock for the high cost scenario
|
||||
await page.route('**/pg-meta/*/query**', async (route) => {
|
||||
const request = route.request()
|
||||
const postData = request.postDataJSON()
|
||||
// Wait for Step 1 to complete - should show success message
|
||||
// Note: In a fresh DB, there might be 0 rows deleted, which is still success
|
||||
await expect(page.getByText(/Successfully deleted \d+ rows/)).toBeVisible({
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
if (
|
||||
postData?.query?.toLowerCase().startsWith('explain') &&
|
||||
postData?.query?.includes('cron.job')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
'QUERY PLAN':
|
||||
'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)',
|
||||
},
|
||||
]),
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
// Step 2 should now be enabled - the "Schedule cleanup job" button should be visible
|
||||
await expect(page.getByRole('button', { name: 'Schedule cleanup job' })).toBeVisible()
|
||||
|
||||
// Should show the SQL preview for the cleanup job
|
||||
await expect(page.getByText(/cron\.schedule/)).toBeVisible()
|
||||
|
||||
// Click "Schedule cleanup job"
|
||||
await page.getByRole('button', { name: 'Schedule cleanup job' }).click()
|
||||
|
||||
// Wait for Step 2 to complete - should show success message
|
||||
await expect(page.getByText('Daily cleanup job scheduled successfully')).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// Clean up: remove the route mock; the scheduled delete-job-run-details job is
|
||||
// removed by the withSetupCleanup teardown at the top of this test.
|
||||
await page.unroute('**/pg-meta/*/query**')
|
||||
})
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
|
||||
|
||||
// Wait for the banner and click Learn more
|
||||
await expect(
|
||||
page.getByText('Last run for each cron job omitted due to high query cost')
|
||||
).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await page.getByRole('alert').getByRole('button', { name: 'Learn more' }).click()
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Last run for cron jobs omitted for overview' })
|
||||
).toBeVisible()
|
||||
|
||||
// Step 1: Click "Delete rows now" to run the cleanup
|
||||
await page.getByRole('button', { name: 'Delete rows now' }).click()
|
||||
|
||||
// Wait for Step 1 to complete - should show success message
|
||||
// Note: In a fresh DB, there might be 0 rows deleted, which is still success
|
||||
await expect(page.getByText(/Successfully deleted \d+ rows/)).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// Step 2 should now be enabled - the "Schedule cleanup job" button should be visible
|
||||
await expect(page.getByRole('button', { name: 'Schedule cleanup job' })).toBeVisible()
|
||||
|
||||
// Should show the SQL preview for the cleanup job
|
||||
await expect(page.getByText(/cron\.schedule/)).toBeVisible()
|
||||
|
||||
// Click "Schedule cleanup job"
|
||||
await page.getByRole('button', { name: 'Schedule cleanup job' }).click()
|
||||
|
||||
// Wait for Step 2 to complete - should show success message
|
||||
await expect(page.getByText('Daily cleanup job scheduled successfully')).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// Clean up: remove the route mock
|
||||
await page.unroute('**/pg-meta/*/query**')
|
||||
|
||||
// Note: Test jobs (pw_high_cost_test_job, delete-job-run-details) will be cleaned up
|
||||
// in the next test run by the CRUD tests' beforeAll cleanup, or manually.
|
||||
// Attempting to delete here causes pointer events issues with Radix dialogs.
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -234,6 +234,26 @@ export interface CronJobHistoryClickedEvent {
|
||||
groups: TelemetryGroups
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable cleanup button clicked, either in the cron jobs page header (opens the
|
||||
* confirmation dialog) or within the dialog itself (schedules the daily cleanup
|
||||
* job that deletes old rows from cron.job_run_details).
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/integrations/cron/jobs
|
||||
*/
|
||||
export interface CronJobCleanupEnableButtonClickedEvent {
|
||||
action: 'cron_job_cleanup_enable_button_clicked'
|
||||
properties: {
|
||||
/** Where the button was clicked from */
|
||||
origin: 'header' | 'dialog'
|
||||
/** Retention period chosen for the cleanup job, e.g. 7 days. Only sent on dialog confirm. */
|
||||
retentionInterval?: string
|
||||
}
|
||||
groups: TelemetryGroups
|
||||
}
|
||||
|
||||
/**
|
||||
* A feature preview was enabled by the user through the FeaturePreviewModal.
|
||||
*
|
||||
@@ -3553,6 +3573,7 @@ export type TelemetryEvent =
|
||||
| CronJobUpdateClickedEvent
|
||||
| CronJobDeleteClickedEvent
|
||||
| CronJobHistoryClickedEvent
|
||||
| CronJobCleanupEnableButtonClickedEvent
|
||||
| FeaturePreviewEnabledEvent
|
||||
| FeaturePreviewDisabledEvent
|
||||
| TimezonePickerClickedEvent
|
||||
|
||||
@@ -107,7 +107,7 @@ WITH deleted AS (
|
||||
SELECT count(*) as deleted_count FROM deleted;`
|
||||
}
|
||||
|
||||
const CRON_CLEANUP_SCHEDULE_NAME = 'delete-job-run-details'
|
||||
export const CRON_CLEANUP_JOB_NAME = 'delete-job-run-details'
|
||||
const CRON_CLEANUP_SCHEDULE_EXPRESSION = '0 12 * * *'
|
||||
|
||||
export const getScheduleDeleteCronJobRunDetailsSql = (interval: string): SafeSqlFragment => {
|
||||
@@ -115,7 +115,7 @@ export const getScheduleDeleteCronJobRunDetailsSql = (interval: string): SafeSql
|
||||
|
||||
return safeSql`
|
||||
SELECT cron.schedule(
|
||||
${literal(CRON_CLEANUP_SCHEDULE_NAME)},
|
||||
${literal(CRON_CLEANUP_JOB_NAME)},
|
||||
${literal(CRON_CLEANUP_SCHEDULE_EXPRESSION)},
|
||||
${literal(command)}
|
||||
);`
|
||||
|
||||
Reference in new issue
Block a user