[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:
Alaister YoungandAlaister Young authored and GitHub committed 2026-07-24 16:43:00 +08:00
1 parent 223a10d1bb
commit a06eb5f26f
11 files changed
+633 -200

No files matched your search

@@ -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()
})
})
@@ -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) {
+293 -179
View File
@@ -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.
})
})
})
+21
View File
@@ -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)}
);`