mirror of
https://github.com/supabase/supabase.git
synced 2026-09-07 02:20:52 +08:00
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>
139 lines
4.9 KiB
TypeScript
139 lines
4.9 KiB
TypeScript
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()
|
|
})
|
|
})
|