mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
fix(studio): handle large cron.job_run_details table gracefully (#41992)
* fix(studio): check job_run_details size in cron display When cron.job_run_details grows too large (200k+ rows), loading the cron jobs overview can timeout and affect other queries by pulling excessive data into shared buffers. This change: - Estimates table size using pg_stat before fetching cron jobs data - Shows a cleanup notice when the table exceeds the threshold - Provides batched deletion using ctid ranges to avoid buffer pollution - Allows scheduling an automated daily cleanup cron job - Handles timeout errors gracefully with a "suspected overflow" state The useCronJobsData hook now returns a discriminated union status that tracks loading, estimate-error, overflow-confirmed, overflow-suspected, and ready states, allowing the UI to respond appropriately to each case. * fix(studio): use index when querying cron.job_run_details cron.job_run_details is only indexed by runid, not by start_time. Change the query to use the runid index (which gives the same result, since runid is auto-incrementing).
This commit is contained in:
1 parent
ee25ba20dc
commit
d34cc65e30
20 files changed
+1769
-231
No files matched your search
@@ -228,9 +228,9 @@ export const formatCronJobColumns = ({
|
||||
}: {
|
||||
onSelectEdit: (job: CronJob) => void
|
||||
onSelectDelete: (job: CronJob) => void
|
||||
}) => {
|
||||
}): Array<Column<CronJob>> => {
|
||||
return CRON_TABLE_COLUMNS.map((col) => {
|
||||
const res: Column<any> = {
|
||||
const res: Column<CronJob> = {
|
||||
key: col.id,
|
||||
name: col.name,
|
||||
minWidth: col.minWidth ?? 100,
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
import { CheckCircle2, RefreshCw, XCircle } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Button,
|
||||
CodeBlock,
|
||||
Progress,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
SelectValue_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
import { getScheduleDeleteCronJobRunDetailsSql } from 'data/sql/queries/delete-cron-job-run-details'
|
||||
import { CLEANUP_INTERVALS } from './CronJobsTab.constants'
|
||||
import type { BatchDeletionProgress, CleanupState } from './CronJobsTab.useCleanupActions'
|
||||
|
||||
export interface CronJobRunDetailsOverflowNoticeProps {
|
||||
estimatedRows?: number
|
||||
mode: 'confirmed' | 'suspected'
|
||||
cleanupState: CleanupState
|
||||
selectedInterval: string
|
||||
onIntervalChange: (interval: string) => void
|
||||
onRunDeleteSql: () => void
|
||||
onRunScheduleSql: () => void
|
||||
onCancelDeletion: () => void
|
||||
onRetryDeletion: () => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export const CronJobRunDetailsOverflowNotice = ({
|
||||
estimatedRows,
|
||||
mode,
|
||||
cleanupState,
|
||||
selectedInterval,
|
||||
onIntervalChange,
|
||||
onRunDeleteSql,
|
||||
onRunScheduleSql,
|
||||
onCancelDeletion,
|
||||
onRetryDeletion,
|
||||
onRefresh,
|
||||
}: CronJobRunDetailsOverflowNoticeProps) => {
|
||||
const formattedRowEstimate =
|
||||
typeof estimatedRows === 'number' ? estimatedRows.toLocaleString() : 'unknown'
|
||||
const noticeTitle =
|
||||
mode === 'confirmed'
|
||||
? 'cron.job_run_details is too large to load'
|
||||
: 'Cron job overview timed out'
|
||||
const noticeDescription =
|
||||
mode === 'confirmed'
|
||||
? `We detected approximately ${formattedRowEstimate} rows in cron.job_run_details, which prevents the overview from running.`
|
||||
: `Loading the cron job overview timed out. The issue might be caused by your cron.job_run_details table having too many rows.`
|
||||
|
||||
const isDeleting = cleanupState.status === 'deleting'
|
||||
const isScheduling = cleanupState.status === 'scheduling'
|
||||
const isDeleteSuccess = cleanupState.status === 'delete-success'
|
||||
const isDeleteError = cleanupState.status === 'delete-error'
|
||||
const isScheduleSuccess = cleanupState.status === 'schedule-success'
|
||||
const isBusy = isDeleting || isScheduling
|
||||
|
||||
const canSchedule = isDeleteSuccess || isScheduleSuccess
|
||||
|
||||
return (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title={noticeTitle}
|
||||
description={noticeDescription}
|
||||
className="max-w-3xl w-full"
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
<p>
|
||||
Remove old run history now, then schedule a cron job that keeps trimming{' '}
|
||||
<code>cron.job_run_details</code> automatically so the overview remains responsive.
|
||||
</p>
|
||||
|
||||
{/* Step 1: Delete older entries */}
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-foreground">Step 1: Delete older entries</p>
|
||||
|
||||
{isDeleting ? (
|
||||
<DeletionProgress progress={cleanupState.progress} onCancel={onCancelDeletion} />
|
||||
) : isDeleteSuccess ? (
|
||||
<DeletionSuccess totalRowsDeleted={cleanupState.totalRowsDeleted} />
|
||||
) : isDeleteError ? (
|
||||
<DeletionError error={cleanupState.error} onRetry={onRetryDeletion} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="sm:w-64">
|
||||
<Select_Shadcn_
|
||||
value={selectedInterval}
|
||||
onValueChange={onIntervalChange}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<SelectTrigger_Shadcn_ className="w-full">
|
||||
<SelectValue_Shadcn_ placeholder="Select an interval" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{CLEANUP_INTERVALS.map((option) => (
|
||||
<SelectItem_Shadcn_ key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</div>
|
||||
<Button type="default" disabled={isBusy} onClick={onRunDeleteSql}>
|
||||
Delete rows now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 2: Schedule automated cleanup (only available after successful deletion) */}
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-foreground">Step 2: Schedule an automated cleanup</p>
|
||||
|
||||
{!canSchedule ? (
|
||||
<p className="text-foreground-lighter text-xs">
|
||||
Complete step 1 to enable scheduling a daily cleanup job.
|
||||
</p>
|
||||
) : isScheduleSuccess ? (
|
||||
<ScheduleSuccess onRefresh={onRefresh} />
|
||||
) : (
|
||||
<>
|
||||
<CodeBlock
|
||||
hideLineNumbers
|
||||
language="sql"
|
||||
value={getScheduleDeleteCronJobRunDetailsSql(selectedInterval)}
|
||||
className="py-3 px-4 text-xs"
|
||||
wrapperClassName="max-w-full"
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
className="mt-1"
|
||||
loading={isScheduling}
|
||||
disabled={isScheduling}
|
||||
onClick={onRunScheduleSql}
|
||||
>
|
||||
Schedule cleanup job
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
interface DeletionProgressProps {
|
||||
progress: BatchDeletionProgress
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const DeletionProgress = ({ progress, onCancel }: DeletionProgressProps) => {
|
||||
const { currentBatch, totalBatches, totalRowsDeleted } = progress
|
||||
const percentComplete =
|
||||
totalBatches > 0 ? Math.min(Math.round((currentBatch / totalBatches) * 100), 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Progress value={percentComplete} className="flex-1 h-2" />
|
||||
<span className="text-xs text-foreground-light whitespace-nowrap">
|
||||
{percentComplete}% ({currentBatch}/{totalBatches} batches)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-foreground-light">
|
||||
Deleted {totalRowsDeleted.toLocaleString()} rows so far...
|
||||
</span>
|
||||
<Button type="outline" size="tiny" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DeletionSuccessProps {
|
||||
totalRowsDeleted: number
|
||||
}
|
||||
|
||||
const DeletionSuccess = ({ totalRowsDeleted }: DeletionSuccessProps) => (
|
||||
<div className="flex items-center gap-2 text-brand">
|
||||
<CheckCircle2 size={16} />
|
||||
<span className="text-sm">Successfully deleted {totalRowsDeleted.toLocaleString()} rows.</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface DeletionErrorProps {
|
||||
error: string
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
const DeletionError = ({ error, onRetry }: DeletionErrorProps) => (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<XCircle size={16} />
|
||||
<span className="text-sm">Deletion failed: {error}</span>
|
||||
</div>
|
||||
<Button type="default" size="small" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface ScheduleSuccessProps {
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const ScheduleSuccess = ({ onRefresh }: ScheduleSuccessProps) => (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-brand">
|
||||
<CheckCircle2 size={16} />
|
||||
<span className="text-sm">Daily cleanup job scheduled successfully.</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-foreground-lighter text-xs">
|
||||
Refresh to reload the cron jobs and view the new cleanup job.
|
||||
</p>
|
||||
<Button type="default" size="tiny" icon={<RefreshCw size={14} />} onClick={onRefresh} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
import { type MouseEvent, type ReactNode, type UIEvent } from 'react'
|
||||
import DataGrid, { type Column, Row } from 'react-data-grid'
|
||||
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import type { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query'
|
||||
import type { ResponseError } from 'types'
|
||||
import { cn } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
interface CronJobsTabDataGridProps {
|
||||
columns: readonly Column<CronJob>[]
|
||||
rows: CronJob[]
|
||||
isLoading: boolean
|
||||
error: ResponseError | Error | null
|
||||
searchQuery?: string | null
|
||||
onScroll: (event: UIEvent<HTMLDivElement>) => void
|
||||
onRowClick: (row: CronJob, event: MouseEvent<HTMLDivElement>) => void
|
||||
overlay?: ReactNode
|
||||
}
|
||||
|
||||
export const CronJobsTabDataGrid = ({
|
||||
columns,
|
||||
rows,
|
||||
isLoading,
|
||||
error,
|
||||
searchQuery,
|
||||
onScroll,
|
||||
onRowClick,
|
||||
overlay,
|
||||
}: CronJobsTabDataGridProps) => {
|
||||
const fallbackContent = overlay ? (
|
||||
<div className="absolute top-20 px-6 w-full flex justify-center">{overlay}</div>
|
||||
) : isLoading ? (
|
||||
<div className="absolute top-12 px-6 w-full">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="absolute top-28 px-10 flex flex-col items-center justify-center w-full">
|
||||
<AlertError subject="Failed to retrieve cron jobs" error={error} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-32 px-6 w-full">
|
||||
<div className="text-center text-sm flex flex-col gap-y-1">
|
||||
<p className="text-foreground">
|
||||
{!!searchQuery ? 'No cron jobs found' : 'No cron jobs in your project'}
|
||||
</p>
|
||||
<p className="text-foreground-light">
|
||||
{!!searchQuery
|
||||
? 'There are currently no cron jobs based on the search applied'
|
||||
: 'There are currently no cron jobs created yet in your project'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
className="flex-grow border-t-0"
|
||||
rowHeight={44}
|
||||
headerRowHeight={36}
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
onScroll={onScroll}
|
||||
rowKeyGetter={(row) => row.jobid}
|
||||
rowClass={() => {
|
||||
return cn(
|
||||
'cursor-pointer',
|
||||
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
|
||||
'[&>.rdg-cell:first-child>div]:ml-8'
|
||||
)
|
||||
}}
|
||||
renderers={{
|
||||
renderRow(_, props) {
|
||||
return (
|
||||
<Row
|
||||
key={props.row.jobid}
|
||||
{...props}
|
||||
onClick={(event) => {
|
||||
onRowClick(props.row, event)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
noRowsFallback: fallbackContent,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
interface CronJobRunDetailsEstimateErrorNoticeProps {
|
||||
error?: Error | null
|
||||
isRetrying?: boolean
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
export const CronJobRunDetailsEstimateErrorNotice = ({
|
||||
error,
|
||||
isRetrying,
|
||||
onRetry,
|
||||
}: CronJobRunDetailsEstimateErrorNoticeProps) => {
|
||||
return (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Error displaying cron jobs"
|
||||
description="There was an error displaying cron jobs. Please try again."
|
||||
className="max-w-3xl w-full"
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
{error?.message && (
|
||||
<p className="text-foreground-light break-words">
|
||||
Error message: <code className="text-xs">{error.message}</code>
|
||||
</p>
|
||||
)}
|
||||
{onRetry && (
|
||||
<Button
|
||||
type="default"
|
||||
loading={isRetrying}
|
||||
disabled={isRetrying}
|
||||
className="mt-1"
|
||||
onClick={onRetry}
|
||||
>
|
||||
Retry check
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { RefreshCw, Search, X } from 'lucide-react'
|
||||
import type { KeyboardEvent } from 'react'
|
||||
|
||||
import { Button } from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
interface CronJobsTabHeaderProps {
|
||||
search: string
|
||||
isRefreshing: boolean
|
||||
onSearchChange: (value: string) => void
|
||||
onSearchSubmit: () => void
|
||||
onClearSearch: () => void
|
||||
onRefresh: () => void
|
||||
onCreateJob: () => void
|
||||
}
|
||||
|
||||
export const CronJobsTabHeader = ({
|
||||
search,
|
||||
isRefreshing,
|
||||
onSearchChange,
|
||||
onSearchSubmit,
|
||||
onClearSearch,
|
||||
onRefresh,
|
||||
onCreateJob,
|
||||
}: CronJobsTabHeaderProps) => {
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' || event.code === 'NumpadEnter') {
|
||||
onSearchSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface-200 py-3 px-10 flex items-center justify-between flex-wrap gap-y-2">
|
||||
<Input
|
||||
size="tiny"
|
||||
className="w-52"
|
||||
placeholder="Search for a job"
|
||||
icon={<Search />}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
actions={[
|
||||
search && (
|
||||
<Button
|
||||
key="clear-search"
|
||||
size="tiny"
|
||||
type="text"
|
||||
icon={<X />}
|
||||
onClick={onClearSearch}
|
||||
className="p-0 h-5 w-5"
|
||||
/>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button type="default" icon={<RefreshCw />} loading={isRefreshing} onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={onCreateJob}>Create job</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const JOB_RUN_DETAILS_THRESHOLD = 200_000
|
||||
export const CRON_JOBS_THRESHOLD = 200_000
|
||||
|
||||
export const CLEANUP_INTERVALS = [
|
||||
{ label: 'Older than 1 day', value: '1 day' },
|
||||
{ label: 'Older than 7 days', value: '7 days' },
|
||||
{ label: 'Older than 1 month', value: '1 month' },
|
||||
{ label: 'Older than 6 months', value: '6 months' },
|
||||
]
|
||||
@@ -1,32 +1,30 @@
|
||||
import { Loader2, RefreshCw, Search, X } from 'lucide-react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
|
||||
import { UIEvent, useMemo, useRef, useState } from 'react'
|
||||
import DataGrid, { DataGridHandle, Row } from 'react-data-grid'
|
||||
import { MouseEvent, UIEvent, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { useCronJobsCountQuery } from 'data/database-cron-jobs/database-cron-jobs-count-query'
|
||||
import {
|
||||
CronJob,
|
||||
useCronJobsInfiniteQuery,
|
||||
} from 'data/database-cron-jobs/database-cron-jobs-infinite-query'
|
||||
import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { createNavigationHandler } from 'lib/navigation'
|
||||
import { isGreaterThanOrEqual } from 'lib/semver'
|
||||
import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers'
|
||||
import { Button, cn, LoadingLine, Sheet, SheetContent } from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { LoadingLine, Sheet, SheetContent } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { formatCronJobColumns } from './CronJobs.utils'
|
||||
import { CronJobRunDetailsOverflowNotice } from './CronJobsTab.CleanupNotice'
|
||||
import { CronJobsTabDataGrid } from './CronJobsTab.DataGrid'
|
||||
import { CronJobRunDetailsEstimateErrorNotice } from './CronJobsTab.EstimateErrorNotice'
|
||||
import { CronJobsTabHeader } from './CronJobsTab.Header'
|
||||
import { useCronJobsCleanupActions } from './CronJobsTab.useCleanupActions'
|
||||
import { useCronJobsData } from './CronJobsTab.useCronJobsData'
|
||||
import { DeleteCronJob } from './DeleteCronJob'
|
||||
|
||||
const EMPTY_CRON_JOB = { jobname: '', schedule: '', active: true, command: '' }
|
||||
@@ -37,114 +35,179 @@ export const CronjobsTab = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
|
||||
const xScroll = useRef<number>(0)
|
||||
const gridRef = useRef<DataGridHandle>(null)
|
||||
|
||||
// Track the ID being deleted to exclude it from error checking
|
||||
const deletingCronJobIdRef = useRef<string | null>(null)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault(''))
|
||||
const [search, setSearch] = useState(searchQuery)
|
||||
const [createCronJobSheetShown, setCreateCronJobSheetShown] = useQueryState(
|
||||
'new',
|
||||
parseAsBoolean.withDefault(false).withOptions({ clearOnDefault: true })
|
||||
)
|
||||
|
||||
const handleSearchSubmit = () => {
|
||||
const trimmed = search.trim()
|
||||
setSearchQuery(trimmed.length > 0 ? trimmed : null)
|
||||
}
|
||||
const handleClearSearch = () => {
|
||||
setSearch('')
|
||||
setSearchQuery(null)
|
||||
}
|
||||
|
||||
const { dataStatus, isQueryEnabled, grid, count } = useCronJobsData({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
searchQuery,
|
||||
})
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
isRefetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
refetch,
|
||||
fetchNextPage,
|
||||
} = useCronJobsInfiniteQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
searchTerm: searchQuery,
|
||||
},
|
||||
{ placeholderData: Boolean(searchQuery) ? keepPreviousData : undefined, staleTime: Infinity }
|
||||
)
|
||||
const cronJobs = useMemo(() => data?.pages.flatMap((p) => p) || [], [data?.pages])
|
||||
cleanupInterval,
|
||||
setCleanupInterval,
|
||||
cleanupState,
|
||||
runBatchedDeletion,
|
||||
scheduleCleanup,
|
||||
cancelDeletion,
|
||||
} = useCronJobsCleanupActions({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const gridOverlay = useMemo(() => {
|
||||
switch (dataStatus.status) {
|
||||
case 'overflow-confirmed':
|
||||
return (
|
||||
<CronJobRunDetailsOverflowNotice
|
||||
mode="confirmed"
|
||||
estimatedRows={dataStatus.estimatedRows}
|
||||
selectedInterval={cleanupInterval}
|
||||
onIntervalChange={setCleanupInterval}
|
||||
cleanupState={cleanupState}
|
||||
onRunDeleteSql={() => runBatchedDeletion(cleanupInterval)}
|
||||
onRunScheduleSql={() => scheduleCleanup(cleanupInterval)}
|
||||
onCancelDeletion={cancelDeletion}
|
||||
onRetryDeletion={() => runBatchedDeletion(cleanupInterval)}
|
||||
onRefresh={grid.refetch}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'overflow-suspected':
|
||||
return (
|
||||
<CronJobRunDetailsOverflowNotice
|
||||
mode="suspected"
|
||||
estimatedRows={dataStatus.estimatedRows}
|
||||
selectedInterval={cleanupInterval}
|
||||
onIntervalChange={setCleanupInterval}
|
||||
cleanupState={cleanupState}
|
||||
onRunDeleteSql={() => runBatchedDeletion(cleanupInterval)}
|
||||
onRunScheduleSql={() => scheduleCleanup(cleanupInterval)}
|
||||
onCancelDeletion={cancelDeletion}
|
||||
onRetryDeletion={() => runBatchedDeletion(cleanupInterval)}
|
||||
onRefresh={grid.refetch}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'estimate-error':
|
||||
return (
|
||||
<CronJobRunDetailsEstimateErrorNotice
|
||||
error={dataStatus.error}
|
||||
isRetrying={dataStatus.isRetrying}
|
||||
onRetry={dataStatus.retry}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'loading':
|
||||
case 'ready':
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}, [
|
||||
dataStatus,
|
||||
cleanupInterval,
|
||||
setCleanupInterval,
|
||||
cleanupState,
|
||||
runBatchedDeletion,
|
||||
scheduleCleanup,
|
||||
cancelDeletion,
|
||||
grid.refetch,
|
||||
])
|
||||
|
||||
const deletingCronJobIdRef = useRef<string | null>(null)
|
||||
|
||||
const { setValue: setCronJobForEditing, value: cronJobForEditing } = useQueryStateWithSelect({
|
||||
urlKey: 'edit',
|
||||
select: (jobid: string) => {
|
||||
if (!jobid) return undefined
|
||||
const job = cronJobs?.find((j) => j.jobid.toString() === jobid)
|
||||
const job = grid.rows.find((j) => j.jobid.toString() === jobid)
|
||||
return job
|
||||
? { jobname: job.jobname, schedule: job.schedule, active: job.active, command: job.command }
|
||||
: undefined
|
||||
},
|
||||
enabled: !!cronJobs && cronJobs.length > 0 && !isLoading,
|
||||
enabled: grid.rows.length > 0 && !grid.isLoading,
|
||||
onError: () => toast.error(`Cron job not found`),
|
||||
})
|
||||
|
||||
const { setValue: setCronJobForDeletion, value: cronJobForDeletion } = useQueryStateWithSelect({
|
||||
urlKey: 'delete',
|
||||
select: (jobid: string) =>
|
||||
jobid ? cronJobs?.find((j) => j.jobid.toString() === jobid) : undefined,
|
||||
enabled: !!cronJobs && cronJobs.length > 0 && !isLoading,
|
||||
jobid ? grid.rows.find((j) => j.jobid.toString() === jobid) : undefined,
|
||||
enabled: grid.rows.length > 0 && !grid.isLoading,
|
||||
onError: (_error, selectedId) =>
|
||||
handleErrorOnDelete(deletingCronJobIdRef, selectedId, `Cron job not found`),
|
||||
})
|
||||
|
||||
const { data: count, isPending: isLoadingCount } = useCronJobsCountQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const { data: extensions = [] } = useDatabaseExtensionsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const pgCronExtension = extensions.find((ext) => ext.name === 'pg_cron')
|
||||
const supportsSeconds = pgCronExtension?.installed_version
|
||||
? isGreaterThanOrEqual(pgCronExtension.installed_version, '1.5')
|
||||
: false
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return formatCronJobColumns({
|
||||
onSelectEdit: (job: any) => {
|
||||
sendEvent({
|
||||
action: 'cron_job_update_clicked',
|
||||
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
|
||||
})
|
||||
setCronJobForEditing(job.jobid.toString())
|
||||
},
|
||||
onSelectDelete: (job: CronJob) => {
|
||||
sendEvent({
|
||||
action: 'cron_job_delete_clicked',
|
||||
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
|
||||
})
|
||||
setCronJobForDeletion(job.jobid.toString())
|
||||
},
|
||||
})
|
||||
}, [org?.slug, ref, sendEvent, setCronJobForEditing, setCronJobForDeletion])
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
formatCronJobColumns({
|
||||
onSelectEdit: (job: CronJob) => {
|
||||
sendEvent({
|
||||
action: 'cron_job_update_clicked',
|
||||
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
|
||||
})
|
||||
setCronJobForEditing(job.jobid.toString())
|
||||
},
|
||||
onSelectDelete: (job: CronJob) => {
|
||||
sendEvent({
|
||||
action: 'cron_job_delete_clicked',
|
||||
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
|
||||
})
|
||||
setCronJobForDeletion(job.jobid.toString())
|
||||
},
|
||||
}),
|
||||
[org?.slug, ref, sendEvent, setCronJobForEditing, setCronJobForDeletion]
|
||||
)
|
||||
|
||||
// check pg_cron version to see if it supports seconds
|
||||
const pgCronExtension = extensions.find((ext) => ext.name === 'pg_cron')
|
||||
const installedVersion = pgCronExtension?.installed_version
|
||||
const supportsSeconds = installedVersion ? parseFloat(installedVersion) >= 1.5 : false
|
||||
const xScroll = useRef<number>(0)
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
if (!isQueryEnabled) return
|
||||
|
||||
const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft
|
||||
xScroll.current = event.currentTarget.scrollLeft
|
||||
|
||||
if (
|
||||
isLoading ||
|
||||
isFetchingNextPage ||
|
||||
grid.isLoading ||
|
||||
grid.isFetchingNextPage ||
|
||||
isScrollingHorizontally ||
|
||||
!isAtBottom(event) ||
|
||||
!hasNextPage
|
||||
!grid.hasNextPage
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
fetchNextPage()
|
||||
grid.fetchNextPage()
|
||||
}
|
||||
|
||||
// Create job sheet
|
||||
const [createCronJobSheetShown, setCreateCronJobSheetShown] = useQueryState(
|
||||
'new',
|
||||
parseAsBoolean.withDefault(false).withOptions({ clearOnDefault: true })
|
||||
)
|
||||
|
||||
const onOpenCreateJobSheet = () => {
|
||||
sendEvent({
|
||||
action: 'cron_job_create_clicked',
|
||||
@@ -153,6 +216,21 @@ export const CronjobsTab = () => {
|
||||
setCreateCronJobSheetShown(true)
|
||||
}
|
||||
|
||||
// Row click handler
|
||||
const handleRowClick = (row: CronJob, event: MouseEvent<HTMLDivElement>) => {
|
||||
const { jobid, jobname } = row
|
||||
const url = `/project/${ref}/integrations/cron/jobs/${jobid}?child-label=${encodeURIComponent(
|
||||
jobname || `Job #${jobid}`
|
||||
)}`
|
||||
|
||||
sendEvent({
|
||||
action: 'cron_job_history_clicked',
|
||||
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
|
||||
})
|
||||
|
||||
createNavigationHandler(url, router)(event)
|
||||
}
|
||||
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const onClose = () => {
|
||||
setCronJobForEditing(null)
|
||||
@@ -171,129 +249,27 @@ export const CronjobsTab = () => {
|
||||
<>
|
||||
<div className="h-full w-full space-y-4">
|
||||
<div className="h-full w-full flex flex-col relative">
|
||||
<div className="bg-surface-200 py-3 px-10 flex items-center justify-between flex-wrap">
|
||||
<Input
|
||||
size="tiny"
|
||||
className="w-52"
|
||||
placeholder="Search for a job"
|
||||
icon={<Search />}
|
||||
value={search ?? ''}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.code === 'Enter' || e.code === 'NumpadEnter') setSearchQuery(search.trim())
|
||||
}}
|
||||
actions={[
|
||||
search && (
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
icon={<X />}
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setSearchQuery(null)
|
||||
}}
|
||||
className="p-0 h-5 w-5"
|
||||
/>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<RefreshCw />}
|
||||
loading={isRefetching && !isFetchingNextPage}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={onOpenCreateJobSheet}>Create job</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoadingLine loading={isLoading || isRefetching || isFetchingNextPage} />
|
||||
|
||||
<DataGrid
|
||||
ref={gridRef}
|
||||
className="flex-grow border-t-0"
|
||||
rowHeight={44}
|
||||
headerRowHeight={36}
|
||||
columns={columns}
|
||||
rows={cronJobs}
|
||||
rowKeyGetter={(row) => row.id}
|
||||
rowClass={() => {
|
||||
return cn(
|
||||
'cursor-pointer',
|
||||
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
|
||||
'[&>.rdg-cell:first-child>div]:ml-8'
|
||||
)
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
renderers={{
|
||||
renderRow(_, props) {
|
||||
return (
|
||||
<Row
|
||||
key={props.row.jobid}
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
const { jobid, jobname } = props.row
|
||||
const url = `/project/${ref}/integrations/cron/jobs/${jobid}?child-label=${encodeURIComponent(jobname || `Job #${jobid}`)}`
|
||||
|
||||
sendEvent({
|
||||
action: 'cron_job_history_clicked',
|
||||
groups: {
|
||||
project: ref ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
|
||||
if (e.metaKey) {
|
||||
window.open(`${BASE_PATH}/${url}`, '_blank')
|
||||
} else {
|
||||
router.push(url)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}}
|
||||
<CronJobsTabHeader
|
||||
search={search}
|
||||
isRefreshing={grid.isRefetching && !grid.isFetchingNextPage}
|
||||
onSearchChange={setSearch}
|
||||
onSearchSubmit={handleSearchSubmit}
|
||||
onClearSearch={handleClearSearch}
|
||||
onRefresh={grid.refetch}
|
||||
onCreateJob={onOpenCreateJobSheet}
|
||||
/>
|
||||
|
||||
{/* [Joshen] Render 0 rows state outside of the grid so that their position isn't relative to the grid scroll position */}
|
||||
{cronJobs.length === 0 ? (
|
||||
isLoading ? (
|
||||
<div className="absolute top-28 px-10 w-full">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="absolute top-28 px-10 flex flex-col items-center justify-center w-full">
|
||||
<AlertError subject="Failed to retrieve cron jobs" error={error} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-32 px-6 w-full">
|
||||
<div className="text-center text-sm flex flex-col gap-y-1">
|
||||
<p className="text-foreground">
|
||||
{!!searchQuery ? 'No cron jobs found' : 'No cron jobs in your project'}
|
||||
</p>
|
||||
<p className="text-foreground-light">
|
||||
{!!searchQuery
|
||||
? 'There are currently no cron jobs based on the search applied'
|
||||
: 'There are currently no cron jobs created yet in your project'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-between min-h-9 h-9 overflow-hidden items-center px-6 w-full border-t text-xs text-foreground-light">
|
||||
{isLoadingCount ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" /> Loading...
|
||||
</span>
|
||||
) : (
|
||||
`Total: ${count} jobs`
|
||||
)}
|
||||
</div>
|
||||
<LoadingLine loading={grid.isLoading || grid.isRefetching || grid.isFetchingNextPage} />
|
||||
<CronJobsTabDataGrid
|
||||
columns={columns}
|
||||
rows={grid.rows}
|
||||
isLoading={grid.isLoading}
|
||||
error={grid.error}
|
||||
searchQuery={searchQuery}
|
||||
onScroll={handleScroll}
|
||||
onRowClick={handleRowClick}
|
||||
overlay={gridOverlay}
|
||||
/>
|
||||
<CronJobsFooter count={count} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -327,6 +303,28 @@ export const CronjobsTab = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// Footer component for displaying job count
|
||||
interface CronJobsFooterProps {
|
||||
count: {
|
||||
value: number | undefined
|
||||
isEstimate: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const CronJobsFooter = ({ count }: CronJobsFooterProps) => (
|
||||
<div className="flex justify-between min-h-9 h-9 overflow-hidden items-center px-6 w-full border-t text-xs text-foreground-light">
|
||||
{count.isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" /> Loading...
|
||||
</span>
|
||||
) : (
|
||||
`Total: ${count.value ?? 0} jobs${count.isEstimate ? ' (estimate)' : ''}`
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Confirmation modal for unsaved changes
|
||||
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
|
||||
<ConfirmationModal
|
||||
visible={visible}
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { ConnectionVars } from '@/data/common.types'
|
||||
import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
|
||||
import {
|
||||
CTID_BATCH_PAGE_SIZE,
|
||||
getDeleteOldCronJobRunDetailsByCtidKey,
|
||||
getDeleteOldCronJobRunDetailsByCtidSql,
|
||||
getJobRunDetailsPageCountKey,
|
||||
getJobRunDetailsPageCountSql,
|
||||
getScheduleDeleteCronJobRunDetailsKey,
|
||||
getScheduleDeleteCronJobRunDetailsSql,
|
||||
} from 'data/sql/queries/delete-cron-job-run-details'
|
||||
import { CLEANUP_INTERVALS } from './CronJobsTab.constants'
|
||||
|
||||
// Delay between batches to allow other queries to proceed (in milliseconds)
|
||||
const BATCH_DELAY_MS = 100
|
||||
|
||||
type UseCronJobsCleanupActionsOptions = ConnectionVars
|
||||
|
||||
export interface BatchDeletionProgress {
|
||||
currentBatch: number
|
||||
totalBatches: number
|
||||
totalRowsDeleted: number
|
||||
}
|
||||
|
||||
export type CleanupState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'deleting'; progress: BatchDeletionProgress }
|
||||
| { status: 'delete-success'; totalRowsDeleted: number }
|
||||
| { status: 'delete-error'; error: string }
|
||||
| { status: 'scheduling' }
|
||||
| { status: 'schedule-success' }
|
||||
| { status: 'schedule-error'; error: string }
|
||||
|
||||
export const useCronJobsCleanupActions = ({
|
||||
projectRef,
|
||||
connectionString,
|
||||
}: UseCronJobsCleanupActionsOptions) => {
|
||||
const [cleanupInterval, setCleanupInterval] = useState(CLEANUP_INTERVALS[0].value)
|
||||
const [cleanupState, setCleanupState] = useState<CleanupState>({ status: 'idle' })
|
||||
|
||||
// Ref to track cancellation
|
||||
const cancelledRef = useRef(false)
|
||||
|
||||
const { mutateAsync: executeSql } = useExecuteSqlMutation({
|
||||
onError: () => {}, // Error handled inline
|
||||
})
|
||||
|
||||
/**
|
||||
* Run batched deletion using ctid ranges.
|
||||
* This approach scans the table in page chunks to avoid:
|
||||
* - Buffer cache pollution from full table scans
|
||||
* - Long-running transactions that block vacuum
|
||||
* - Lock accumulation from deleting millions of rows at once
|
||||
*/
|
||||
const runBatchedDeletion = useCallback(
|
||||
async (interval: string) => {
|
||||
if (!projectRef) {
|
||||
console.error('[CronJobsTab > batch deletion] Project reference is required')
|
||||
toast.error('There was an error running the cleanup. Please try again.')
|
||||
return
|
||||
}
|
||||
|
||||
cancelledRef.current = false
|
||||
|
||||
try {
|
||||
// Step 1: Get the total number of pages in the table
|
||||
setCleanupState({
|
||||
status: 'deleting',
|
||||
progress: { currentBatch: 0, totalBatches: 0, totalRowsDeleted: 0 },
|
||||
})
|
||||
|
||||
const pageCountResult = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getJobRunDetailsPageCountSql(),
|
||||
queryKey: getJobRunDetailsPageCountKey(projectRef),
|
||||
})
|
||||
|
||||
const rawTotalPages = pageCountResult.result?.[0]?.num_pages ?? 0
|
||||
const totalPages = Number(rawTotalPages)
|
||||
if (!Number.isFinite(totalPages) || totalPages < 0) {
|
||||
throw new Error(
|
||||
`[CronJobs > cleanup actions] Invalid page count returned: ${rawTotalPages}`
|
||||
)
|
||||
}
|
||||
|
||||
if (totalPages === 0) {
|
||||
setCleanupState({ status: 'delete-success', totalRowsDeleted: 0 })
|
||||
toast.success('The job_run_details table is empty.')
|
||||
return
|
||||
}
|
||||
|
||||
const totalBatches = Math.ceil(totalPages / CTID_BATCH_PAGE_SIZE)
|
||||
let totalRowsDeleted = 0
|
||||
|
||||
// Step 2: Iterate through pages in batches
|
||||
for (let batch = 0; batch < totalBatches; batch++) {
|
||||
// Check for cancellation
|
||||
if (cancelledRef.current) {
|
||||
setCleanupState({ status: 'idle' })
|
||||
toast.info('Deletion cancelled.')
|
||||
return
|
||||
}
|
||||
|
||||
const startPage = batch * CTID_BATCH_PAGE_SIZE
|
||||
const endPage = Math.min((batch + 1) * CTID_BATCH_PAGE_SIZE, totalPages + 1)
|
||||
|
||||
setCleanupState({
|
||||
status: 'deleting',
|
||||
progress: {
|
||||
currentBatch: batch + 1,
|
||||
totalBatches,
|
||||
totalRowsDeleted,
|
||||
},
|
||||
})
|
||||
|
||||
const deleteResult = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getDeleteOldCronJobRunDetailsByCtidSql(interval, startPage, endPage),
|
||||
queryKey: getDeleteOldCronJobRunDetailsByCtidKey(projectRef, interval, startPage),
|
||||
})
|
||||
|
||||
const deletedCount = deleteResult.result?.[0]?.deleted_count ?? 0
|
||||
totalRowsDeleted += deletedCount
|
||||
|
||||
if (cancelledRef.current) {
|
||||
setCleanupState({ status: 'idle' })
|
||||
toast.info('Deletion cancelled.')
|
||||
return
|
||||
}
|
||||
|
||||
if (batch < totalBatches - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS))
|
||||
}
|
||||
}
|
||||
|
||||
setCleanupState({ status: 'delete-success', totalRowsDeleted })
|
||||
toast.success(
|
||||
`Deleted ${totalRowsDeleted.toLocaleString()} cron job runs older than ${interval}.`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[CronJobs] Batch deletion failed with error: %O', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setCleanupState({ status: 'delete-error', error: errorMessage })
|
||||
toast.error('Running the cleanup failed. Please try again.')
|
||||
}
|
||||
},
|
||||
[projectRef, connectionString, executeSql]
|
||||
)
|
||||
|
||||
/**
|
||||
* Schedule a daily cleanup job.
|
||||
* This should only be called after a successful initial deletion.
|
||||
*/
|
||||
const scheduleCleanup = useCallback(
|
||||
async (interval: string) => {
|
||||
if (!projectRef) {
|
||||
console.error('[CronJobsTab > schedule cleanup] Project reference is required')
|
||||
toast.error('There was an error scheduling the cleanup. Please try again.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setCleanupState({ status: 'scheduling' })
|
||||
|
||||
await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getScheduleDeleteCronJobRunDetailsSql(interval),
|
||||
queryKey: getScheduleDeleteCronJobRunDetailsKey(projectRef, interval),
|
||||
})
|
||||
|
||||
setCleanupState({ status: 'schedule-success' })
|
||||
toast.success('Scheduled daily cleanup job.')
|
||||
} catch (error) {
|
||||
console.error('[CronJobs] Failed to schedule cleanup with error: %O', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setCleanupState({ status: 'schedule-error', error: errorMessage })
|
||||
toast.error('Scheduling the cleanup job failed. Please try again.')
|
||||
}
|
||||
},
|
||||
[projectRef, connectionString, executeSql]
|
||||
)
|
||||
|
||||
/**
|
||||
* Cancel an in-progress deletion.
|
||||
*/
|
||||
const cancelDeletion = useCallback(() => {
|
||||
cancelledRef.current = true
|
||||
setCleanupState({ status: 'idle' })
|
||||
}, [])
|
||||
|
||||
return {
|
||||
cleanupInterval,
|
||||
setCleanupInterval,
|
||||
cleanupState,
|
||||
runBatchedDeletion,
|
||||
scheduleCleanup,
|
||||
cancelDeletion,
|
||||
}
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { ConnectionVars } from '@/data/common.types'
|
||||
import { useCronJobRunDetailsEstimateQuery } from 'data/database-cron-jobs/database-cron-job-run-details-estimate-query'
|
||||
import { useCronJobsCountEstimateQuery } from 'data/database-cron-jobs/database-cron-jobs-count-estimate-query'
|
||||
import { useCronJobsCountQuery } from 'data/database-cron-jobs/database-cron-jobs-count-query'
|
||||
import {
|
||||
CronJob,
|
||||
useCronJobsInfiniteQuery,
|
||||
} from 'data/database-cron-jobs/database-cron-jobs-infinite-query'
|
||||
import type { ResponseError } from 'types'
|
||||
import { CRON_JOBS_THRESHOLD, JOB_RUN_DETAILS_THRESHOLD } from './CronJobsTab.constants'
|
||||
|
||||
// =============================================================================
|
||||
// Input types
|
||||
// =============================================================================
|
||||
|
||||
type UseCronJobsDataParams = ConnectionVars & {
|
||||
searchQuery: string | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Shared state types (available in all states)
|
||||
// =============================================================================
|
||||
|
||||
interface CronJobsGridState {
|
||||
rows: Array<CronJob>
|
||||
isLoading: boolean
|
||||
error: ResponseError | null
|
||||
isRefetching: boolean
|
||||
isFetchingNextPage: boolean
|
||||
hasNextPage: boolean
|
||||
refetch: () => void
|
||||
fetchNextPage: () => void
|
||||
}
|
||||
|
||||
interface CronJobsCountState {
|
||||
value: number | undefined
|
||||
isEstimate: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Discriminated union for data fetching status
|
||||
// =============================================================================
|
||||
|
||||
/** Still checking the size of the job_run_details table */
|
||||
interface StatusLoading {
|
||||
status: 'loading'
|
||||
}
|
||||
|
||||
/** Failed to check the table size - show error with retry option */
|
||||
interface StatusEstimateError {
|
||||
status: 'estimate-error'
|
||||
error: Error
|
||||
isRetrying: boolean
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
/** Table is confirmed to be too large - show cleanup notice */
|
||||
interface StatusOverflowConfirmed {
|
||||
status: 'overflow-confirmed'
|
||||
estimatedRows: number
|
||||
}
|
||||
|
||||
/** Query timed out - suspected large table, show cleanup notice */
|
||||
interface StatusOverflowSuspected {
|
||||
status: 'overflow-suspected'
|
||||
estimatedRows: number | undefined
|
||||
}
|
||||
|
||||
/** Normal state - queries can run */
|
||||
interface StatusReady {
|
||||
status: 'ready'
|
||||
}
|
||||
|
||||
type CronJobsDataStatus =
|
||||
| StatusLoading
|
||||
| StatusEstimateError
|
||||
| StatusOverflowConfirmed
|
||||
| StatusOverflowSuspected
|
||||
| StatusReady
|
||||
|
||||
// =============================================================================
|
||||
// Result type
|
||||
// =============================================================================
|
||||
|
||||
interface UseCronJobsDataResult {
|
||||
/** Discriminated union indicating the current data fetching status */
|
||||
dataStatus: CronJobsDataStatus
|
||||
/** Whether queries are enabled (not gated due to large table or loading) */
|
||||
isQueryEnabled: boolean
|
||||
/** State for the cron jobs grid */
|
||||
grid: CronJobsGridState
|
||||
/** State for the cron jobs count (exact or estimate) */
|
||||
count: CronJobsCountState
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Custom hook that encapsulates all data fetching logic for the CronJobsTab.
|
||||
*
|
||||
* This hook manages the complex query dependencies:
|
||||
* 1. First, estimate the size of the job_run_details table
|
||||
* 2. If the table is small enough, fetch the actual cron jobs
|
||||
* 3. If the table is too large, show estimates instead and display a cleanup notice
|
||||
*
|
||||
* The `dataStatus` discriminated union ensures type-safe handling of all states:
|
||||
* - 'loading': Still checking table size
|
||||
* - 'estimate-error': Failed to check, show retry option
|
||||
* - 'overflow-confirmed': Table too large (from estimate)
|
||||
* - 'overflow-suspected': Query timed out
|
||||
* - 'ready': Normal operation
|
||||
*/
|
||||
export function useCronJobsData({
|
||||
projectRef,
|
||||
connectionString,
|
||||
searchQuery,
|
||||
}: UseCronJobsDataParams): UseCronJobsDataResult {
|
||||
const isProjectReady = !!projectRef
|
||||
|
||||
const {
|
||||
data: runDetailsEstimateValue,
|
||||
error: runDetailsEstimateError,
|
||||
isError: isRunDetailsEstimateError,
|
||||
isPending: isRunDetailsEstimatePending,
|
||||
isRefetching: isRefetchingRunDetailsEstimate,
|
||||
refetch: refetchRunDetailsEstimate,
|
||||
} = useCronJobRunDetailsEstimateQuery(
|
||||
{ projectRef, connectionString },
|
||||
{ enabled: isProjectReady }
|
||||
)
|
||||
|
||||
const hasLargeRunDetailsTable =
|
||||
typeof runDetailsEstimateValue === 'number' &&
|
||||
runDetailsEstimateValue > JOB_RUN_DETAILS_THRESHOLD
|
||||
|
||||
// Queries are enabled only when:
|
||||
// - Project is ready
|
||||
// - Estimate query has completed successfully
|
||||
// - Table is not too large
|
||||
const isQueryEnabled =
|
||||
isProjectReady &&
|
||||
!isRunDetailsEstimatePending &&
|
||||
!isRunDetailsEstimateError &&
|
||||
!hasLargeRunDetailsTable
|
||||
|
||||
const {
|
||||
data: cronJobsData,
|
||||
error: cronJobsError,
|
||||
isLoading: isCronJobsLoading,
|
||||
isError: isCronJobsError,
|
||||
isRefetching: isCronJobsRefetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage = false,
|
||||
refetch: refetchCronJobs,
|
||||
fetchNextPage,
|
||||
} = useCronJobsInfiniteQuery(
|
||||
{ projectRef, connectionString, searchTerm: searchQuery ?? undefined },
|
||||
{
|
||||
placeholderData: Boolean(searchQuery) ? keepPreviousData : undefined,
|
||||
staleTime: Infinity,
|
||||
enabled: isQueryEnabled,
|
||||
}
|
||||
)
|
||||
|
||||
const cronJobs = useMemo(
|
||||
() => cronJobsData?.pages.flatMap((page) => page) ?? [],
|
||||
[cronJobsData?.pages]
|
||||
)
|
||||
|
||||
// Fetch count - gated on cron.job table size
|
||||
// Always fetch the estimate first (it's fast since it uses pg_stat)
|
||||
const {
|
||||
data: estimatedCount,
|
||||
isPending: isLoadingEstimatedCount,
|
||||
isError: isEstimatedCountError,
|
||||
} = useCronJobsCountEstimateQuery({ projectRef, connectionString }, { enabled: isProjectReady })
|
||||
|
||||
const hasLargeCronJobsTable =
|
||||
typeof estimatedCount === 'number' && estimatedCount > CRON_JOBS_THRESHOLD
|
||||
|
||||
// Exact count is enabled when the cron.job table is small enough
|
||||
const isCountQueryEnabled =
|
||||
isProjectReady && !isLoadingEstimatedCount && !isEstimatedCountError && !hasLargeCronJobsTable
|
||||
|
||||
const { data: exactCount, isPending: isLoadingExactCount } = useCronJobsCountQuery(
|
||||
{ projectRef, connectionString },
|
||||
{ enabled: isCountQueryEnabled }
|
||||
)
|
||||
|
||||
// Determine if we should show a timeout error as a suspected overflow
|
||||
const isTimeoutError =
|
||||
isCronJobsError &&
|
||||
typeof cronJobsError?.message === 'string' &&
|
||||
cronJobsError.message.toLowerCase().includes('timeout')
|
||||
|
||||
// Compute the discriminated status
|
||||
const dataStatus: CronJobsDataStatus = useMemo(() => {
|
||||
if (!isProjectReady || isRunDetailsEstimatePending) {
|
||||
return { status: 'loading' }
|
||||
}
|
||||
|
||||
if (isRunDetailsEstimateError) {
|
||||
return {
|
||||
status: 'estimate-error',
|
||||
error: runDetailsEstimateError,
|
||||
isRetrying: isRefetchingRunDetailsEstimate,
|
||||
retry: refetchRunDetailsEstimate,
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLargeRunDetailsTable) {
|
||||
return {
|
||||
status: 'overflow-confirmed',
|
||||
estimatedRows: runDetailsEstimateValue!,
|
||||
}
|
||||
}
|
||||
|
||||
if (isTimeoutError) {
|
||||
return {
|
||||
status: 'overflow-suspected',
|
||||
estimatedRows: runDetailsEstimateValue,
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'ready' }
|
||||
}, [
|
||||
isProjectReady,
|
||||
isRunDetailsEstimatePending,
|
||||
isRunDetailsEstimateError,
|
||||
runDetailsEstimateError,
|
||||
isRefetchingRunDetailsEstimate,
|
||||
refetchRunDetailsEstimate,
|
||||
hasLargeRunDetailsTable,
|
||||
runDetailsEstimateValue,
|
||||
isTimeoutError,
|
||||
])
|
||||
|
||||
// Compute derived grid state
|
||||
const isGridLoading =
|
||||
dataStatus.status === 'loading' || (dataStatus.status === 'ready' && isCronJobsLoading)
|
||||
const gridError =
|
||||
dataStatus.status === 'ready' && isCronJobsError && !isTimeoutError
|
||||
? cronJobsError ?? null
|
||||
: null
|
||||
|
||||
return {
|
||||
dataStatus,
|
||||
isQueryEnabled,
|
||||
|
||||
grid: {
|
||||
rows: cronJobs,
|
||||
isLoading: isGridLoading,
|
||||
error: gridError,
|
||||
isRefetching: isCronJobsRefetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
refetch: refetchCronJobs,
|
||||
fetchNextPage,
|
||||
},
|
||||
|
||||
count: {
|
||||
value: isCountQueryEnabled ? exactCount : estimatedCount,
|
||||
isEstimate: !isCountQueryEnabled,
|
||||
isLoading: isCountQueryEnabled ? isLoadingExactCount : isLoadingEstimatedCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Type exports for consumers
|
||||
// =============================================================================
|
||||
|
||||
export type { CronJobsCountState, CronJobsDataStatus, CronJobsGridState }
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ConnectionVars = {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
connectionString?: string | null
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { QueryClient, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { ConnectionVars } from 'data/common.types'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import {
|
||||
getLiveTupleEstimate,
|
||||
getLiveTupleEstimateKey,
|
||||
} from 'data/sql/queries/get-live-tuple-stats'
|
||||
import type { UseCustomQueryOptions } from 'types'
|
||||
|
||||
type DatabaseCronJobRunDetailsEstimateVariables = ConnectionVars
|
||||
|
||||
const cronJobRunDetailsEstimateSql = getLiveTupleEstimate('job_run_details', 'cron')
|
||||
const cronJobRunDetailsEstimateKey = (projectRef: string | undefined) =>
|
||||
getLiveTupleEstimateKey(projectRef, 'job_run_details', 'cron')
|
||||
|
||||
export async function getCronJobRunDetailsEstimate({
|
||||
projectRef,
|
||||
connectionString,
|
||||
}: DatabaseCronJobRunDetailsEstimateVariables) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
const { result } = await executeSql<{ live_tuple_estimate: number }[]>({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: cronJobRunDetailsEstimateSql,
|
||||
queryKey: cronJobRunDetailsEstimateKey(projectRef),
|
||||
})
|
||||
|
||||
return result?.[0]?.live_tuple_estimate
|
||||
}
|
||||
|
||||
export type DatabaseCronJobRunDetailsEstimateData = Awaited<
|
||||
ReturnType<typeof getCronJobRunDetailsEstimate>
|
||||
>
|
||||
export type DatabaseCronJobRunDetailsEstimateError = Error
|
||||
|
||||
export const useCronJobRunDetailsEstimateQuery = <TData = DatabaseCronJobRunDetailsEstimateData>(
|
||||
{ projectRef, connectionString }: DatabaseCronJobRunDetailsEstimateVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<
|
||||
DatabaseCronJobRunDetailsEstimateData,
|
||||
DatabaseCronJobRunDetailsEstimateError,
|
||||
TData
|
||||
> = {}
|
||||
) =>
|
||||
useQuery<DatabaseCronJobRunDetailsEstimateData, DatabaseCronJobRunDetailsEstimateError, TData>({
|
||||
queryKey: cronJobRunDetailsEstimateKey(projectRef),
|
||||
queryFn: () => getCronJobRunDetailsEstimate({ projectRef, connectionString }),
|
||||
enabled: enabled && projectRef !== undefined,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
...options,
|
||||
})
|
||||
|
||||
export function prefetchCronJobRunDetailsEstimate(
|
||||
client: QueryClient,
|
||||
{ projectRef, connectionString }: DatabaseCronJobRunDetailsEstimateVariables
|
||||
) {
|
||||
return client.fetchQuery({
|
||||
// Does not change if connection string changes
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
queryKey: cronJobRunDetailsEstimateKey(projectRef),
|
||||
queryFn: () => getCronJobRunDetailsEstimate({ projectRef, connectionString }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { ConnectionVars } from 'data/common.types'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import {
|
||||
getLiveTupleEstimate,
|
||||
getLiveTupleEstimateKey,
|
||||
} from 'data/sql/queries/get-live-tuple-stats'
|
||||
import type { UseCustomQueryOptions } from 'types'
|
||||
|
||||
type DatabaseCronJobsCountEstimateVariables = ConnectionVars
|
||||
|
||||
const cronJobsCountEstimateSql = getLiveTupleEstimate('job', 'cron')
|
||||
const cronJobsCountEstimateKey = (projectRef: string | undefined) =>
|
||||
getLiveTupleEstimateKey(projectRef, 'job', 'cron')
|
||||
|
||||
export async function getCronJobsCountEstimate({
|
||||
projectRef,
|
||||
connectionString,
|
||||
}: DatabaseCronJobsCountEstimateVariables) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
const { result } = await executeSql<Array<{ live_tuple_estimate: number }>>({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: cronJobsCountEstimateSql,
|
||||
queryKey: cronJobsCountEstimateKey(projectRef),
|
||||
})
|
||||
|
||||
return result?.[0]?.live_tuple_estimate
|
||||
}
|
||||
|
||||
export type DatabaseCronJobsCountEstimateData = Awaited<ReturnType<typeof getCronJobsCountEstimate>>
|
||||
export type DatabaseCronJobsCountEstimateError = Error
|
||||
|
||||
export const useCronJobsCountEstimateQuery = <TData = DatabaseCronJobsCountEstimateData>(
|
||||
{ projectRef, connectionString }: DatabaseCronJobsCountEstimateVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<
|
||||
DatabaseCronJobsCountEstimateData,
|
||||
DatabaseCronJobsCountEstimateError,
|
||||
TData
|
||||
> = {}
|
||||
) =>
|
||||
useQuery<DatabaseCronJobsCountEstimateData, DatabaseCronJobsCountEstimateError, TData>({
|
||||
queryKey: cronJobsCountEstimateKey(projectRef),
|
||||
queryFn: () => getCronJobsCountEstimate({ projectRef, connectionString }),
|
||||
enabled: enabled && projectRef !== undefined,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
...options,
|
||||
})
|
||||
@@ -31,16 +31,18 @@ export async function getDatabaseCronJobRuns({
|
||||
projectRef,
|
||||
connectionString,
|
||||
jobId,
|
||||
afterTimestamp,
|
||||
}: DatabaseCronJobRunsVariables & { afterTimestamp: string | undefined }) {
|
||||
afterRunId,
|
||||
}: DatabaseCronJobRunsVariables & { afterRunId: number | undefined }) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
// Use runid for ordering and pagination since it's the primary key (indexed)
|
||||
// and preserves chronological order (auto-incrementing)
|
||||
let query = `
|
||||
SELECT * FROM cron.job_run_details
|
||||
WHERE
|
||||
jobid = '${jobId}'
|
||||
${afterTimestamp ? `AND start_time < '${afterTimestamp}'` : ''}
|
||||
ORDER BY start_time DESC
|
||||
${typeof afterRunId === 'number' ? `AND runid < '${afterRunId}'` : ''}
|
||||
ORDER BY runid DESC
|
||||
LIMIT ${CRON_JOB_RUNS_PAGE_SIZE}`
|
||||
|
||||
const { result } = await executeSql({
|
||||
@@ -64,26 +66,25 @@ export const useCronJobRunsInfiniteQuery = <TData = DatabaseCronJobRunData>(
|
||||
DatabaseCronJobError,
|
||||
InfiniteData<TData>,
|
||||
readonly unknown[],
|
||||
string | undefined
|
||||
number | undefined
|
||||
> = {}
|
||||
) =>
|
||||
useInfiniteQuery({
|
||||
queryKey: databaseCronJobsKeys.runsInfinite(projectRef, jobId, { status }),
|
||||
queryKey: databaseCronJobsKeys.runsInfinite(projectRef, jobId),
|
||||
queryFn: ({ pageParam }) => {
|
||||
return getDatabaseCronJobRuns({
|
||||
projectRef,
|
||||
connectionString,
|
||||
jobId,
|
||||
afterTimestamp: pageParam,
|
||||
afterRunId: pageParam,
|
||||
})
|
||||
},
|
||||
staleTime: 0,
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam(lastPage) {
|
||||
const hasNextPage = lastPage.length <= CRON_JOB_RUNS_PAGE_SIZE
|
||||
if (!hasNextPage) return undefined
|
||||
return last(lastPage)?.start_time
|
||||
if (lastPage.length < CRON_JOB_RUNS_PAGE_SIZE) return undefined
|
||||
return last(lastPage)?.runid
|
||||
},
|
||||
...options,
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { literal } from '@supabase/pg-meta/src/pg-format'
|
||||
|
||||
import { sqlKeys } from '../keys'
|
||||
|
||||
const CRON_CLEANUP_SCHEDULE_NAME = 'delete-job-run-details'
|
||||
const CRON_CLEANUP_SCHEDULE_EXPRESSION = '0 12 * * *'
|
||||
|
||||
// Number of pages to process in each batch for ctid-based deletion
|
||||
// Based on default Postgres shared buffer size of 128 MB, which fits ~16k pages
|
||||
export const CTID_BATCH_PAGE_SIZE = 5_000
|
||||
|
||||
/**
|
||||
* Get the total number of pages in the job_run_details table.
|
||||
* This is used to iterate through the table in batches using ctid ranges.
|
||||
*/
|
||||
export const getJobRunDetailsPageCountSql = () =>
|
||||
`
|
||||
SELECT pg_relation_size(oid) / current_setting('block_size')::int8 AS num_pages
|
||||
FROM pg_class
|
||||
WHERE relname = 'job_run_details'
|
||||
AND relnamespace = 'cron'::regnamespace;
|
||||
`.trim()
|
||||
|
||||
export const getJobRunDetailsPageCountKey = (projectRef: string | undefined) =>
|
||||
sqlKeys.query(projectRef, ['cron-job-run-details', 'page-count'])
|
||||
|
||||
/**
|
||||
* Validates that a value is a finite non-negative integer.
|
||||
*/
|
||||
function validatePageNumber(value: number, name: string): void {
|
||||
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`${name} must be a finite non-negative integer, got: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old cron job run details using ctid range filtering.
|
||||
* This approach:
|
||||
* 1. Only scans a bounded range of pages (not the full table)
|
||||
* 2. Avoids buffer cache pollution by processing in chunks
|
||||
* 3. Allows other queries to proceed between batches
|
||||
*
|
||||
* @param interval - The age threshold (e.g., '7 days')
|
||||
* @param startPage - The starting page number (inclusive)
|
||||
* @param endPage - The ending page number (exclusive)
|
||||
* @returns SQL that deletes matching rows and returns the count of deleted rows
|
||||
*/
|
||||
export const getDeleteOldCronJobRunDetailsByCtidSql = (
|
||||
interval: string,
|
||||
startPage: number,
|
||||
endPage: number
|
||||
) => {
|
||||
validatePageNumber(startPage, 'startPage')
|
||||
validatePageNumber(endPage, 'endPage')
|
||||
|
||||
// After validation, these are guaranteed to be safe integers
|
||||
// Using literal() on the string representation ensures proper escaping
|
||||
const safeCtidStart = literal(`(${startPage},0)`)
|
||||
const safeCtidEnd = literal(`(${endPage},0)`)
|
||||
|
||||
return `
|
||||
WITH deleted AS (
|
||||
DELETE FROM cron.job_run_details
|
||||
WHERE ctid >= ${safeCtidStart}::tid
|
||||
AND ctid < ${safeCtidEnd}::tid
|
||||
AND end_time < now() - interval ${literal(interval)}
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) as deleted_count FROM deleted;
|
||||
`.trim()
|
||||
}
|
||||
|
||||
export const getDeleteOldCronJobRunDetailsByCtidKey = (
|
||||
projectRef: string | undefined,
|
||||
interval: string,
|
||||
startPage: number
|
||||
) => sqlKeys.query(projectRef, ['cron-job-run-details', 'delete-batch', interval, startPage])
|
||||
|
||||
export const getScheduleDeleteCronJobRunDetailsSql = (interval: string) =>
|
||||
`
|
||||
SELECT cron.schedule(
|
||||
${literal(CRON_CLEANUP_SCHEDULE_NAME)},
|
||||
${literal(CRON_CLEANUP_SCHEDULE_EXPRESSION)},
|
||||
$$DELETE FROM cron.job_run_details WHERE end_time < now() - interval ${literal(interval)}$$
|
||||
);
|
||||
`.trim()
|
||||
|
||||
export const getScheduleDeleteCronJobRunDetailsKey = (
|
||||
projectRef: string | undefined,
|
||||
interval: string
|
||||
) => sqlKeys.query(projectRef, ['cron-job-run-details', 'schedule', interval])
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { prefetchCronJobRunDetailsEstimate } from 'data/database-cron-jobs/database-cron-job-run-details-estimate-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useStaticEffectEvent } from 'hooks/useStaticEffectEvent'
|
||||
|
||||
/**
|
||||
* Prefetches the cron job run details estimate when on a Cron integration page.
|
||||
* This avoids a query waterfall when navigating to the Jobs tab, since the
|
||||
* Jobs tab gates loading cron jobs on this estimate query completing first.
|
||||
*/
|
||||
export function useCronJobsEstimatePrefetch(integrationId: string | undefined) {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const prefetch = useStaticEffectEvent(() => {
|
||||
prefetchCronJobRunDetailsEstimate(queryClient, {
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (integrationId === 'cron' && project?.ref) {
|
||||
prefetch()
|
||||
}
|
||||
}, [integrationId, project?.ref, prefetch])
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseSemver,
|
||||
compareSemver,
|
||||
isGreaterThan,
|
||||
isLessThan,
|
||||
isEqual,
|
||||
isGreaterThan,
|
||||
isGreaterThanOrEqual,
|
||||
isLessThan,
|
||||
isLessThanOrEqual,
|
||||
isValidSemver,
|
||||
parseSemver,
|
||||
} from './semver'
|
||||
|
||||
describe('parseSemver', () => {
|
||||
@@ -17,14 +17,22 @@ describe('parseSemver', () => {
|
||||
expect(parseSemver('10.20.30')).toEqual({ major: 10, minor: 20, patch: 30 })
|
||||
})
|
||||
|
||||
it('should parse versions with fewer than 3 parts', () => {
|
||||
expect(parseSemver('1')).toEqual({ major: 1, minor: 0, patch: 0 })
|
||||
expect(parseSemver('1.5')).toEqual({ major: 1, minor: 5, patch: 0 })
|
||||
})
|
||||
|
||||
it('should return null for versions with more than 3 parts', () => {
|
||||
expect(parseSemver('1.2.3.4')).toBeNull()
|
||||
expect(parseSemver('1.2.3.4.5')).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle strings with extra whitespace', () => {
|
||||
expect(parseSemver(' 1.2.3 ')).toEqual({ major: 1, minor: 2, patch: 3 })
|
||||
expect(parseSemver(' 1.2.3 ')).toEqual({ major: 1, minor: 2, patch: 3 })
|
||||
})
|
||||
|
||||
it('should return null for invalid semver strings', () => {
|
||||
expect(parseSemver('1.2')).toBeNull()
|
||||
expect(parseSemver('1.2.3.4')).toBeNull()
|
||||
expect(parseSemver('1.2.x')).toBeNull()
|
||||
expect(parseSemver('a.b.c')).toBeNull()
|
||||
expect(parseSemver('')).toBeNull()
|
||||
@@ -68,7 +76,6 @@ describe('compareSemver', () => {
|
||||
it('should return null for invalid versions', () => {
|
||||
expect(compareSemver('1.2.3', 'invalid')).toBeNull()
|
||||
expect(compareSemver('invalid', '1.2.3')).toBeNull()
|
||||
expect(compareSemver('1.2', '1.2.3')).toBeNull()
|
||||
})
|
||||
|
||||
it('should prioritize major version differences', () => {
|
||||
@@ -80,6 +87,21 @@ describe('compareSemver', () => {
|
||||
expect(compareSemver('1.3.0', '1.2.99')).toBe(1)
|
||||
expect(compareSemver('1.2.99', '1.3.0')).toBe(-1)
|
||||
})
|
||||
|
||||
it('should compare variable-length versions treating missing parts as 0', () => {
|
||||
expect(compareSemver('1.5', '1.5.0')).toBe(0)
|
||||
expect(compareSemver('1.5.0', '1.5')).toBe(0)
|
||||
expect(compareSemver('1', '1.0.0')).toBe(0)
|
||||
expect(compareSemver('1.5', '1.5.1')).toBe(-1)
|
||||
expect(compareSemver('1.5.1', '1.5')).toBe(1)
|
||||
expect(compareSemver('2', '1.9.9')).toBe(1)
|
||||
expect(compareSemver('1.9.9', '2')).toBe(-1)
|
||||
})
|
||||
|
||||
it('should return null for versions with more than 3 parts', () => {
|
||||
expect(compareSemver('1.2.3.4', '1.2.3')).toBeNull()
|
||||
expect(compareSemver('1.2.3', '1.2.3.4')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGreaterThan', () => {
|
||||
@@ -135,6 +157,16 @@ describe('isGreaterThanOrEqual', () => {
|
||||
expect(isGreaterThanOrEqual('1.0.0', '2.0.0')).toBe(false)
|
||||
expect(isGreaterThanOrEqual('1.2.3', 'invalid')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle variable-length versions', () => {
|
||||
expect(isGreaterThanOrEqual('1.5', '1.5')).toBe(true)
|
||||
expect(isGreaterThanOrEqual('1.6', '1.5')).toBe(true)
|
||||
expect(isGreaterThanOrEqual('1.4', '1.5')).toBe(false)
|
||||
expect(isGreaterThanOrEqual('1.5.0', '1.5')).toBe(true)
|
||||
expect(isGreaterThanOrEqual('1.5.1', '1.5')).toBe(true)
|
||||
expect(isGreaterThanOrEqual('2', '1.5')).toBe(true)
|
||||
expect(isGreaterThanOrEqual('1', '1.5')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLessThanOrEqual', () => {
|
||||
@@ -157,10 +189,18 @@ describe('isValidSemver', () => {
|
||||
expect(isValidSemver('10.20.30')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for invalid semver strings', () => {
|
||||
expect(isValidSemver('1.2')).toBe(false)
|
||||
it('should return true for versions with 1-3 parts', () => {
|
||||
expect(isValidSemver('1')).toBe(true)
|
||||
expect(isValidSemver('1.2')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for versions with more than 3 parts', () => {
|
||||
expect(isValidSemver('1.2.3.4')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for invalid semver strings', () => {
|
||||
expect(isValidSemver('invalid')).toBe(false)
|
||||
expect(isValidSemver('')).toBe(false)
|
||||
expect(isValidSemver('1.x.3')).toBe(false)
|
||||
})
|
||||
})
|
||||
+15
-10
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Semantic versioning utility for comparing version strings in the format "x.x.x"
|
||||
* Semantic versioning utility for comparing version strings.
|
||||
* Accepts 1-3 parts (e.g., "1", "1.5", "1.5.0"). Missing parts default to 0.
|
||||
*/
|
||||
|
||||
export interface SemverVersion {
|
||||
@@ -9,7 +10,8 @@ export interface SemverVersion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a semver string in the format "x.x.x" into its components
|
||||
* Parses a semver string into its components.
|
||||
* Accepts 1-3 parts (e.g., "1", "1.5", "1.5.0"). Missing parts default to 0.
|
||||
* @param version - The version string to parse (e.g., "1.2.3")
|
||||
* @returns The parsed version components or null if invalid
|
||||
*/
|
||||
@@ -20,27 +22,30 @@ export function parseSemver(version: string): SemverVersion | null {
|
||||
|
||||
const parts = version.trim().split('.')
|
||||
|
||||
if (parts.length !== 3) {
|
||||
if (parts.length === 0 || parts.length > 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
const major = parseInt(parts[0], 10)
|
||||
const minor = parseInt(parts[1], 10)
|
||||
const patch = parseInt(parts[2], 10)
|
||||
const numbers = parts.map((p) => parseInt(p, 10))
|
||||
|
||||
if (isNaN(major) || isNaN(minor) || isNaN(patch)) {
|
||||
if (numbers.some(isNaN)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (major < 0 || minor < 0 || patch < 0) {
|
||||
if (numbers.some((n) => n < 0)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { major, minor, patch }
|
||||
return {
|
||||
major: numbers[0],
|
||||
minor: numbers[1] ?? 0,
|
||||
patch: numbers[2] ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two semver version strings
|
||||
* Compares two semver version strings.
|
||||
* Missing parts are treated as 0 (e.g., "1.5" equals "1.5.0").
|
||||
* @param a - First version string
|
||||
* @param b - Second version string
|
||||
* @returns -1 if a < b, 0 if a === b, 1 if a > b, or null if either version is invalid
|
||||
|
||||
@@ -6,6 +6,7 @@ import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integra
|
||||
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
|
||||
import { DefaultLayout } from 'components/layouts/DefaultLayout'
|
||||
import IntegrationsLayout from 'components/layouts/Integrations/layout'
|
||||
import { useCronJobsEstimatePrefetch } from 'hooks/misc/useCronJobsEstimatePrefetch'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
@@ -23,6 +24,8 @@ const IntegrationPage: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
const { ref, id, pageId, childId } = useParams()
|
||||
|
||||
useCronJobsEstimatePrefetch(id)
|
||||
|
||||
const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } =
|
||||
useInstalledIntegrations()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useInstalledIntegrations } from 'components/interfaces/Integrations/Lan
|
||||
import { DefaultLayout } from 'components/layouts/DefaultLayout'
|
||||
import IntegrationsLayout from 'components/layouts/Integrations/layout'
|
||||
import { UnknownInterface } from 'components/ui/UnknownInterface'
|
||||
import { useCronJobsEstimatePrefetch } from 'hooks/misc/useCronJobsEstimatePrefetch'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import {
|
||||
@@ -43,6 +44,8 @@ const IntegrationPage: NextPageWithLayout = () => {
|
||||
const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
|
||||
const stripeSyncEnabled = useFlag('enableStripeSyncEngineIntegration')
|
||||
|
||||
useCronJobsEstimatePrefetch(id)
|
||||
|
||||
const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } =
|
||||
useInstalledIntegrations()
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { expect, Page } from '@playwright/test'
|
||||
import { test } from '../utils/test.js'
|
||||
import { toUrl } from '../utils/to-url.js'
|
||||
|
||||
const cronJobName = 'pw_cron_test_job'
|
||||
|
||||
/**
|
||||
* Helper to enable the pg_cron extension if it's not already enabled.
|
||||
* Must be called when on the cron overview page.
|
||||
*/
|
||||
const ensurePgCronEnabled = async (page: Page) => {
|
||||
// Wait for the page content to load and the Required extensions section to appear
|
||||
await page.waitForSelector('h1:has-text("Cron")', { timeout: 30000 })
|
||||
await expect(page.getByRole('heading', { name: 'Required extensions' })).toBeVisible({
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// Wait for the page to finish loading the extension status
|
||||
// Look for the listitem containing pg_cron - it will have either "Enable pg_cron" button or "Installed" text
|
||||
const pgCronListItem = page
|
||||
.getByRole('listitem')
|
||||
.filter({ has: page.locator('code:has-text("pg_cron")') })
|
||||
const enableButton = pgCronListItem.getByRole('button', { name: 'Enable pg_cron' })
|
||||
const installedText = pgCronListItem.getByText('Installed')
|
||||
await expect(enableButton.or(installedText)).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// Check if we need to enable pg_cron
|
||||
if ((await enableButton.count()) > 0) {
|
||||
await enableButton.click()
|
||||
|
||||
// Wait for the extension enable modal
|
||||
await expect(page.getByRole('heading', { name: 'Enable pg_cron' })).toBeVisible({
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
await page.getByRole('button', { name: 'Enable extension' }).click()
|
||||
|
||||
// Wait for success toast
|
||||
await expect(page.getByText(/Extension.*pg_cron.*is now enabled/)).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
}
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Jobs' })).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to navigate to the cron overview page and ensure pg_cron is enabled.
|
||||
*/
|
||||
const navigateToCronOverviewAndEnable = async (page: Page, ref: string) => {
|
||||
await page.goto(toUrl(`/project/${ref}/integrations/cron/overview`))
|
||||
await ensurePgCronEnabled(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to navigate to the cron jobs page and wait for it to load.
|
||||
* Assumes pg_cron is already enabled.
|
||||
*/
|
||||
const navigateToCronJobsPage = async (page: Page, ref: string) => {
|
||||
await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
|
||||
await expect(page.getByRole('grid')).toBeVisible({ timeout: 30000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to delete a cron job by name.
|
||||
* The delete modal requires typing the job name for confirmation.
|
||||
* Uses right-click context menu to access the delete option.
|
||||
* Waits for the row to be removed from the grid after deletion.
|
||||
*/
|
||||
const deleteCronJob = async (page: Page, jobName: string) => {
|
||||
// Find the row and right-click to open context menu
|
||||
const jobRow = page.getByRole('row', { name: new RegExp(jobName) })
|
||||
await jobRow.click({ button: 'right' })
|
||||
|
||||
// Click "Delete job" in the context menu
|
||||
await page.getByRole('menuitem', { name: 'Delete job' }).click()
|
||||
|
||||
// The modal requires typing the job name - fill it in
|
||||
await expect(page.getByRole('heading', { name: 'Delete this cron job' })).toBeVisible()
|
||||
await page.getByPlaceholder('Type in name of cron job').fill(jobName)
|
||||
|
||||
// Click the delete button
|
||||
await page.getByRole('button', { name: `Delete cron job ${jobName}` }).click()
|
||||
|
||||
// Wait for success toast and for the row to be removed from the grid
|
||||
await expect(page.getByText(/Successfully removed cron job/)).toBeVisible({ timeout: 10000 })
|
||||
await expect(jobRow).not.toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
// Run all cron tests serially since they share database state (pg_cron extension)
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.describe('Cron Jobs Integration', () => {
|
||||
test.describe('Cron Jobs CRUD Operations', () => {
|
||||
let page: Page
|
||||
|
||||
test.beforeAll(async ({ browser, ref }) => {
|
||||
page = await browser.newPage()
|
||||
|
||||
// Navigate to cron overview first to ensure extension is enabled
|
||||
await navigateToCronOverviewAndEnable(page, ref)
|
||||
|
||||
// Navigate to jobs page
|
||||
await navigateToCronJobsPage(page, ref)
|
||||
|
||||
// Clean up any existing test jobs
|
||||
while ((await page.getByRole('row', { name: new RegExp(cronJobName) }).count()) > 0) {
|
||||
await deleteCronJob(page, cronJobName)
|
||||
}
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Clean up test jobs
|
||||
try {
|
||||
while ((await page.getByRole('row', { name: new RegExp(cronJobName) }).count()) > 0) {
|
||||
await deleteCronJob(page, cronJobName)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
test('can view cron jobs page', async ({ ref }) => {
|
||||
await navigateToCronJobsPage(page, ref)
|
||||
|
||||
// Verify the page elements
|
||||
await expect(page.getByRole('button', { name: 'Create job' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Refresh' })).toBeVisible()
|
||||
await expect(page.getByPlaceholder('Search for a job')).toBeVisible()
|
||||
})
|
||||
|
||||
test('can create a new cron job', async ({ ref }) => {
|
||||
await navigateToCronJobsPage(page, ref)
|
||||
|
||||
// Click create job button
|
||||
await page.getByRole('button', { name: 'Create job' }).click()
|
||||
|
||||
// Wait for the dialog to open
|
||||
await expect(page.getByRole('heading', { name: 'Create a new cron job' })).toBeVisible()
|
||||
|
||||
// Fill in job name using the input name attribute
|
||||
await page.locator('input[name="name"]').fill(cronJobName)
|
||||
|
||||
// Click the "Every minute" preset button to set schedule
|
||||
await page.getByRole('button', { name: 'Every minute' }).click()
|
||||
|
||||
// Fill in the SQL command using the Monaco editor
|
||||
await page.getByRole('code').click()
|
||||
await page.getByRole('textbox', { name: /Editor content/ }).fill("SELECT 'test';")
|
||||
|
||||
// Save the job
|
||||
await page.getByRole('button', { name: 'Create cron job' }).click()
|
||||
|
||||
// Wait for success toast
|
||||
await expect(page.getByText(/Successfully created cron job/)).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Verify the job appears in the list
|
||||
await expect(page.getByRole('row', { name: new RegExp(cronJobName) })).toBeVisible()
|
||||
})
|
||||
|
||||
test('can search for cron jobs', async ({ ref }) => {
|
||||
await navigateToCronJobsPage(page, ref)
|
||||
|
||||
// Search for the test job
|
||||
const searchInput = page.getByPlaceholder('Search for a job')
|
||||
await searchInput.fill(cronJobName)
|
||||
await searchInput.press('Enter')
|
||||
|
||||
// Should find the job
|
||||
await expect(page.getByRole('row', { name: new RegExp(cronJobName) })).toBeVisible()
|
||||
|
||||
// Search for non-existent job
|
||||
await searchInput.fill('nonexistent_job_xyz_12345')
|
||||
await searchInput.press('Enter')
|
||||
|
||||
// Should show empty state
|
||||
await expect(page.getByText('No cron jobs found')).toBeVisible()
|
||||
|
||||
// Clear search by clearing the input
|
||||
await searchInput.clear()
|
||||
await searchInput.press('Enter')
|
||||
})
|
||||
|
||||
test('can edit a cron job', async ({ ref }) => {
|
||||
await navigateToCronJobsPage(page, ref)
|
||||
|
||||
// Find the test job row and right-click to open context menu
|
||||
const jobRow = page.getByRole('row', { name: new RegExp(cronJobName) })
|
||||
await jobRow.click({ button: 'right' })
|
||||
|
||||
// Click "Edit job" in the context menu
|
||||
await page.getByRole('menuitem', { name: 'Edit job' }).click()
|
||||
|
||||
// Wait for the edit sheet to open
|
||||
await expect(page.getByRole('heading', { name: `Edit ${cronJobName}` })).toBeVisible()
|
||||
|
||||
// Note: Job names cannot be changed after creation, so we'll verify we can change the schedule
|
||||
// Click a different schedule preset
|
||||
await page.getByRole('button', { name: 'Every 5 minutes' }).click()
|
||||
|
||||
// Save the changes
|
||||
await page.getByRole('button', { name: 'Save cron job' }).click()
|
||||
|
||||
// Wait for success toast
|
||||
await expect(page.getByText(/Successfully updated cron job/)).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('can view cron job run history', async ({ ref }) => {
|
||||
await navigateToCronJobsPage(page, ref)
|
||||
|
||||
// Click on the job row to view history (click on the name cell, not the action button)
|
||||
const jobRow = page.getByRole('row', { name: new RegExp(cronJobName) })
|
||||
await jobRow.getByRole('gridcell', { name: cronJobName }).click()
|
||||
|
||||
// Should navigate to the job detail page
|
||||
await page.waitForURL(/.*\/integrations\/cron\/jobs\/\d+/)
|
||||
|
||||
// Verify we're on the job history page - should show the job name in the heading
|
||||
await expect(page.getByRole('heading', { name: cronJobName })).toBeVisible()
|
||||
|
||||
// Go back to jobs list
|
||||
await page.goBack()
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('can delete a cron job', async ({ ref }) => {
|
||||
await navigateToCronJobsPage(page, ref)
|
||||
|
||||
// Delete the test job
|
||||
await deleteCronJob(page, cronJobName)
|
||||
|
||||
// Verify the job is no longer in the list
|
||||
await expect(page.getByRole('row', { name: new RegExp(cronJobName) })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cron Jobs Page Features', () => {
|
||||
test('refresh button reloads cron jobs', async ({ page, ref }) => {
|
||||
await navigateToCronOverviewAndEnable(page, ref)
|
||||
await navigateToCronJobsPage(page, ref)
|
||||
|
||||
// Click refresh - it should not throw an error
|
||||
await page.getByRole('button', { name: 'Refresh' }).click()
|
||||
|
||||
// The grid should still be visible after refresh
|
||||
await expect(page.getByRole('grid')).toBeVisible()
|
||||
})
|
||||
|
||||
test('navigation tabs work correctly', async ({ page, ref }) => {
|
||||
await navigateToCronOverviewAndEnable(page, ref)
|
||||
|
||||
// Click on Jobs tab
|
||||
await page.getByRole('link', { name: 'Jobs' }).click()
|
||||
await page.waitForURL(/.*\/integrations\/cron\/jobs/)
|
||||
|
||||
// Verify we're on the jobs page
|
||||
await expect(page.getByRole('button', { name: 'Create job' })).toBeVisible()
|
||||
|
||||
// Click on Overview tab (use exact match to avoid ambiguity)
|
||||
await page.getByRole('link', { name: 'Overview', exact: true }).click()
|
||||
await page.waitForURL(/.*\/integrations\/cron\/overview/)
|
||||
|
||||
// Verify we're on the overview page
|
||||
await expect(page.getByText('Schedule recurring Jobs in Postgres')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Large Table Overflow Notice', () => {
|
||||
const cleanupJobName = 'delete-job-run-details'
|
||||
|
||||
test('shows cleanup notice and allows cleanup workflow', async ({ page, ref }) => {
|
||||
// First ensure pg_cron is enabled (without any mocks)
|
||||
await navigateToCronOverviewAndEnable(page, ref)
|
||||
|
||||
// Now set up the mock for the row count estimate
|
||||
await page.route('**/pg-meta/*/query**', async (route) => {
|
||||
const request = route.request()
|
||||
const postData = request.postDataJSON()
|
||||
|
||||
// Only intercept the live tuple estimate query for job_run_details
|
||||
if (postData?.query?.includes('n_live_tup') && postData?.query?.includes('job_run_details')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([{ live_tuple_estimate: 500000 }]),
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
// Navigate to the cron jobs page (this will trigger the mocked estimate query)
|
||||
await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
|
||||
|
||||
// Should show the overflow notice with the warning title
|
||||
await expect(page.getByText('cron.job_run_details is too large to load')).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// Should show the estimated row count in the description
|
||||
await expect(page.getByText(/approximately 500,000 rows/)).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 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')).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
|
||||
await expect(page.getByText(/Successfully deleted \d+ rows/)).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// Step 2 should now be enabled - 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 and delete the scheduled cleanup job
|
||||
await page.unroute('**/pg-meta/*/query**')
|
||||
|
||||
// Click refresh to reload the page with real data
|
||||
await page.getByRole('button', { name: 'Refresh' }).last().click()
|
||||
|
||||
// Wait for the grid to appear and verify the cleanup job was created, then delete it
|
||||
await expect(page.getByRole('grid')).toBeVisible({ timeout: 30000 })
|
||||
await expect(page.getByRole('row', { name: new RegExp(cleanupJobName) })).toBeVisible()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user