mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Update cron job overview to opt to skip JOIN depending on query cost (#42492)
## Context
The Integrations -> Cron overview page renders a list of cron jobs by
doing a join on both `cron.jobs` and `cron.job_run_details` in order to
pull the data for "Latest run" and "Status" ("Next run" is just derived
from "Latest Run" and the job's schedule)
In the past, we've had users run into issues where this query would time
out, likely due to the join on `job_run_details` especially if that
table is really big (and the columns on that table aren't indexed), and
our solution was to inform users to clear out that table + schedule a
job to clean the `job_run_details` table on a certain cadence. This CTA
however blocks the UI from rendering the cron jobs overview entirely
<img width="1644" height="590" alt="image"
src="https://github.com/user-attachments/assets/1a0458e9-f686-452b-88bc-44459bc7c615"
/>
## Changes involved
Am opting to use the new preflight check introduced
[here](https://github.com/supabase/supabase/pulls?q=is%3Apr+author%3Ajoshenlim+is%3Aclosed)
- Attempt to fetch the data for the cron jobs overview normally
- If preflight check rejects (due to high cost), then opt to fetch the
data while omitting the join
- Show a banner instead to inform the user accordingly
- So that way users can still view their cron jobs (the latest run
column is a nice to have convenience really)
<img width="1151" height="379" alt="image"
src="https://github.com/user-attachments/assets/72cfaafb-e951-4601-b54e-c36a6f36332d"
/>
- Clicking learn more will then open a dialog to explain what this is
all about
- The steps to clear the `job_run_details` table + schedule a clean up
job still remains
<img width="450" height="484" alt="image"
src="https://github.com/user-attachments/assets/4491e766-64a1-40c9-9703-2e8b0da11c3f"
/>
<img width="450" height="610" alt="Screenshot 2026-02-05 at 12 56 39"
src="https://github.com/user-attachments/assets/644c9a7b-5afb-46bb-8c1e-9ece3986c2fb"
/>
<img width="450" height="507" alt="Screenshot 2026-02-05 at 13 01 37"
src="https://github.com/user-attachments/assets/9f0f40f2-7376-4c28-90f1-0b21a1a4560a"
/>
- Non-UI related changes include
- Automatically refresh cron jobs after scheduling a job, remove manual
CTA to refresh the jobs after scheduling the clean up job
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Previous Runs tab on cron job pages
* Interactive "Learn more" cleanup dialog with guided delete and
schedule steps
* Close-confirmation modal when editing cron jobs
* High-cost banner that enables a lightweight "minimal" list mode
* **Bug Fixes**
* Clearer placeholder (minus icon/message) when a job has no runs
* **Performance**
* Safer, more efficient fetching and pagination for large cron job
lists; reduced retries on high-cost queries
* **Tests**
* Expanded end-to-end tests covering high-cost flows and the cleanup
dialog
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
e8bb51ec84
commit
a6541d725e
17 files changed
+644
-611
No files matched your search
@@ -1,14 +1,13 @@
|
||||
import { useParams } from 'common'
|
||||
import { toString as CronToString } from 'cronstrue'
|
||||
import { useCronJobQuery } from 'data/database-cron-jobs/database-cron-job-query'
|
||||
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { type ConfirmOnCloseModalProps, useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'
|
||||
import { Edit3, List } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useCronJobQuery } from 'data/database-cron-jobs/database-cron-job-query'
|
||||
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose'
|
||||
import {
|
||||
BreadcrumbItem_Shadcn_ as BreadcrumbItem,
|
||||
BreadcrumbLink_Shadcn_ as BreadcrumbLink,
|
||||
@@ -16,13 +15,13 @@ import {
|
||||
BreadcrumbPage_Shadcn_ as BreadcrumbPage,
|
||||
BreadcrumbSeparator_Shadcn_ as BreadcrumbSeparator,
|
||||
Button,
|
||||
cn,
|
||||
CodeBlock,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import {
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
PageHeaderTitle,
|
||||
} from 'ui-patterns/PageHeader'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { CreateCronJobSheet } from './CreateCronJobSheet/CreateCronJobSheet'
|
||||
import { isSecondsFormat, parseCronJobCommand } from './CronJobs.utils'
|
||||
import { PreviousRunsTab } from './PreviousRunsTab'
|
||||
@@ -189,6 +189,7 @@ export const CronJobPage = () => {
|
||||
{secondaryActions.length > 0 && <PageHeaderAside>{secondaryActions}</PageHeaderAside>}
|
||||
</PageHeaderMeta>
|
||||
</PageHeader>
|
||||
|
||||
<PreviousRunsTab />
|
||||
|
||||
<Sheet open={isEditSheetOpen} onOpenChange={setIsEditSheetOpen}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import parser from 'cron-parser'
|
||||
import dayjs from 'dayjs'
|
||||
import { Copy, Edit, MoreVertical, Play, Trash } from 'lucide-react'
|
||||
import { Copy, Edit, Minus, MoreVertical, Play, Trash } from 'lucide-react'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -90,6 +90,7 @@ export const CronJobTableCell = ({
|
||||
const [showToggleModal, setShowToggleModal] = useState(false)
|
||||
|
||||
const value = row?.[col.id]
|
||||
const hasValue = col.id in row
|
||||
const { jobid, schedule, latest_run, status, active, jobname } = row
|
||||
|
||||
const formattedValue =
|
||||
@@ -241,7 +242,9 @@ export const CronJobTableCell = ({
|
||||
<ContextMenuTrigger_Shadcn_ asChild>
|
||||
<div className={cn('w-full flex items-center text-xs')}>
|
||||
{['latest_run', 'next_run'].includes(col.id) ? (
|
||||
col.id === 'latest_run' && formattedValue === null ? (
|
||||
!hasValue ? (
|
||||
<Minus size={14} className="text-foreground-lighter" />
|
||||
) : col.id === 'latest_run' && formattedValue === null ? (
|
||||
<p className="text-foreground-lighter">Job has not been run yet</p>
|
||||
) : col.id === 'next_run' && !formattedValue ? (
|
||||
<p className="text-foreground-lighter">Unable to parse next run for job</p>
|
||||
|
||||
+166
-124
@@ -1,8 +1,15 @@
|
||||
import { CheckCircle2, RefreshCw, XCircle } from 'lucide-react'
|
||||
|
||||
import { getScheduleDeleteCronJobRunDetailsSql } from 'data/sql/queries/delete-cron-job-run-details'
|
||||
import { CheckCircle2, XCircle } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
CodeBlock,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Progress,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
@@ -12,138 +19,178 @@ import {
|
||||
} 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'
|
||||
import {
|
||||
useCronJobsCleanupActions,
|
||||
type BatchDeletionProgress,
|
||||
} from './CronJobsTab.useCleanupActions'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
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
|
||||
interface CronJobRunDetailsOverflowNoticeV2Props {
|
||||
refetchJobs: () => 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.`
|
||||
export const CronJobRunDetailsOverflowNoticeV2 = (
|
||||
props: CronJobRunDetailsOverflowNoticeV2Props
|
||||
) => {
|
||||
return (
|
||||
<Admonition
|
||||
type="note"
|
||||
className="rounded-none border-x-0 border-t-0 py-2 [&>svg]:top-[0.6rem] [&>svg]:left-10 pl-10 pr-10"
|
||||
layout="horizontal"
|
||||
actions={<CronJobRunDetailsOverflowDialog {...props} />}
|
||||
>
|
||||
<p className="text-xs">Last run for each cron job omitted due to high query cost</p>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
const CronJobRunDetailsOverflowDialog = ({
|
||||
refetchJobs,
|
||||
}: CronJobRunDetailsOverflowNoticeV2Props) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const {
|
||||
cleanupInterval,
|
||||
cleanupState,
|
||||
isScheduling,
|
||||
isScheduleSuccess,
|
||||
setCleanupInterval,
|
||||
runBatchedDeletion,
|
||||
scheduleCleanup,
|
||||
cancelDeletion,
|
||||
} = useCronJobsCleanupActions({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
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>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="default">Learn more</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Last run for cron jobs omitted for overview</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection className="flex flex-col gap-y-2">
|
||||
<p className="text-sm">
|
||||
The dashboard fetches data for the cron jobs overview by running a join between the{' '}
|
||||
<code className="text-code-inline">cron.job</code> and{' '}
|
||||
<code className="text-code-inline !break-keep">cron.job_run_details</code> tables to
|
||||
show each cron job's latest run.
|
||||
</p>
|
||||
|
||||
{/* Step 1: Delete older entries */}
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-foreground">Step 1: Delete older entries</p>
|
||||
<p className="text-sm">
|
||||
However, the join was skipped as the estimated query cost exceeds safety thresholds,
|
||||
likely due to the size of{' '}
|
||||
<code className="text-code-inline !break-keep">cron.job_run_details</code> table.
|
||||
</p>
|
||||
</DialogSection>
|
||||
|
||||
{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>
|
||||
<DialogSectionSeparator />
|
||||
|
||||
{/* 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>
|
||||
<DialogSection className="flex flex-col gap-y-4">
|
||||
<p className="font-mono text-foreground-lighter uppercase tracking-tight text-sm">
|
||||
Suggested steps
|
||||
</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"
|
||||
<p className="text-sm">
|
||||
We recommend removing the old run history now, then scheduling a cron job that keeps
|
||||
trimming the <code className="text-code-inline">cron.job_run_details</code> table
|
||||
automatically. This also prevents unnecessary bloat on the database.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-y-2 text-sm">
|
||||
<p className="text-foreground">Step 1: Delete older entries</p>
|
||||
|
||||
{isDeleting ? (
|
||||
<DeletionProgress progress={cleanupState.progress} onCancel={cancelDeletion} />
|
||||
) : isDeleteSuccess ? (
|
||||
<DeletionSuccess totalRowsDeleted={cleanupState.totalRowsDeleted} />
|
||||
) : isDeleteError ? (
|
||||
<DeletionError
|
||||
error={cleanupState.error}
|
||||
onRetry={() => runBatchedDeletion(cleanupInterval)}
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
className="mt-1"
|
||||
loading={isScheduling}
|
||||
disabled={isScheduling}
|
||||
onClick={onRunScheduleSql}
|
||||
>
|
||||
Schedule cleanup job
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Admonition>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="sm:w-64">
|
||||
<Select_Shadcn_
|
||||
disabled={isBusy}
|
||||
value={cleanupInterval}
|
||||
onValueChange={setCleanupInterval}
|
||||
>
|
||||
<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={() => runBatchedDeletion(cleanupInterval)}
|
||||
>
|
||||
Delete rows now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2 text-sm">
|
||||
<p className="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 />
|
||||
) : (
|
||||
<>
|
||||
<CodeBlock
|
||||
hideLineNumbers
|
||||
language="sql"
|
||||
value={getScheduleDeleteCronJobRunDetailsSql(cleanupInterval)}
|
||||
className="py-3 px-4 text-xs"
|
||||
wrapperClassName="max-w-full"
|
||||
/>
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
type="default"
|
||||
className="mt-1"
|
||||
loading={isScheduling}
|
||||
disabled={isScheduling}
|
||||
onClick={async () => {
|
||||
await scheduleCleanup({
|
||||
interval: cleanupInterval,
|
||||
onSuccess: () => refetchJobs(),
|
||||
})
|
||||
}}
|
||||
>
|
||||
Schedule cleanup job
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogSection>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,11 +252,7 @@ const DeletionError = ({ error, onRetry }: DeletionErrorProps) => (
|
||||
</div>
|
||||
)
|
||||
|
||||
interface ScheduleSuccessProps {
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const ScheduleSuccess = ({ onRefresh }: ScheduleSuccessProps) => (
|
||||
const ScheduleSuccess = () => (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-brand">
|
||||
<CheckCircle2 size={16} />
|
||||
@@ -217,9 +260,8 @@ const ScheduleSuccess = ({ onRefresh }: ScheduleSuccessProps) => (
|
||||
</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.
|
||||
New cleanup job should now be visible in the cron jobs overview.
|
||||
</p>
|
||||
<Button type="default" size="tiny" icon={<RefreshCw size={14} />} onClick={onRefresh} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type MouseEvent, type ReactNode, type UIEvent } from 'react'
|
||||
import { type MouseEvent, type UIEvent } from 'react'
|
||||
import DataGrid, { type Column, Row } from 'react-data-grid'
|
||||
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
@@ -15,7 +15,6 @@ interface CronJobsTabDataGridProps {
|
||||
searchQuery?: string | null
|
||||
onScroll: (event: UIEvent<HTMLDivElement>) => void
|
||||
onRowClick: (row: CronJob, event: MouseEvent<HTMLDivElement>) => void
|
||||
overlay?: ReactNode
|
||||
}
|
||||
|
||||
export const CronJobsTabDataGrid = ({
|
||||
@@ -26,16 +25,13 @@ export const CronJobsTabDataGrid = ({
|
||||
searchQuery,
|
||||
onScroll,
|
||||
onRowClick,
|
||||
overlay,
|
||||
}: CronJobsTabDataGridProps) => {
|
||||
const fallbackContent = overlay ? (
|
||||
<div className="absolute top-20 px-6 w-full flex justify-center">{overlay}</div>
|
||||
) : isLoading ? (
|
||||
const fallbackContent = 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">
|
||||
<div className="absolute top-14 px-10 flex flex-col items-center justify-center w-full">
|
||||
<AlertError subject="Failed to retrieve cron jobs" error={error} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
|
||||
import { MouseEvent, UIEvent, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet'
|
||||
import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query'
|
||||
@@ -13,17 +7,21 @@ import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQuer
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose'
|
||||
import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers'
|
||||
import { createNavigationHandler } from 'lib/navigation'
|
||||
import { isGreaterThanOrEqual } from 'lib/semver'
|
||||
import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
|
||||
import { MouseEvent, UIEvent, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { LoadingLine, Sheet, SheetContent } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { formatCronJobColumns } from './CronJobs.utils'
|
||||
import { CronJobRunDetailsOverflowNotice } from './CronJobsTab.CleanupNotice'
|
||||
import { CronJobRunDetailsOverflowNoticeV2 } 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'
|
||||
|
||||
@@ -47,83 +45,12 @@ export const CronjobsTab = () => {
|
||||
setSearchQuery(null)
|
||||
}
|
||||
|
||||
const { dataStatus, isQueryEnabled, grid, count } = useCronJobsData({
|
||||
const { grid, count } = useCronJobsData({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
searchQuery,
|
||||
})
|
||||
|
||||
const {
|
||||
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({
|
||||
@@ -184,8 +111,6 @@ export const CronjobsTab = () => {
|
||||
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
|
||||
|
||||
@@ -259,6 +184,7 @@ export const CronjobsTab = () => {
|
||||
onCreateJob={onOpenCreateJobSheet}
|
||||
/>
|
||||
<LoadingLine loading={grid.isLoading || grid.isRefetching || grid.isFetchingNextPage} />
|
||||
{grid.isMinimal && <CronJobRunDetailsOverflowNoticeV2 refetchJobs={grid.refetch} />}
|
||||
<CronJobsTabDataGrid
|
||||
columns={columns}
|
||||
rows={grid.rows}
|
||||
@@ -267,7 +193,6 @@ export const CronjobsTab = () => {
|
||||
searchQuery={searchQuery}
|
||||
onScroll={handleScroll}
|
||||
onRowClick={handleRowClick}
|
||||
overlay={gridOverlay}
|
||||
/>
|
||||
<CronJobsFooter count={count} />
|
||||
</div>
|
||||
|
||||
+25
-30
@@ -1,7 +1,3 @@
|
||||
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,
|
||||
@@ -9,10 +5,13 @@ import {
|
||||
getDeleteOldCronJobRunDetailsByCtidSql,
|
||||
getJobRunDetailsPageCountKey,
|
||||
getJobRunDetailsPageCountSql,
|
||||
getScheduleDeleteCronJobRunDetailsKey,
|
||||
getScheduleDeleteCronJobRunDetailsSql,
|
||||
} from 'data/sql/queries/delete-cron-job-run-details'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { CLEANUP_INTERVALS } from './CronJobsTab.constants'
|
||||
import type { ConnectionVars } from '@/data/common.types'
|
||||
import { useScheduleCronJobRunDetailsCleanupMutation } from '@/data/database-cron-jobs/schedule-clean-up-mutation'
|
||||
|
||||
// Delay between batches to allow other queries to proceed (in milliseconds)
|
||||
const BATCH_DELAY_MS = 100
|
||||
@@ -30,9 +29,6 @@ export type CleanupState =
|
||||
| { 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,
|
||||
@@ -48,6 +44,12 @@ export const useCronJobsCleanupActions = ({
|
||||
onError: () => {}, // Error handled inline
|
||||
})
|
||||
|
||||
const {
|
||||
mutate: scheduleCronJobCleanup,
|
||||
isPending: isScheduling,
|
||||
isSuccess: isScheduleSuccess,
|
||||
} = useScheduleCronJobRunDetailsCleanupMutation()
|
||||
|
||||
/**
|
||||
* Run batched deletion using ctid ranges.
|
||||
* This approach scans the table in page chunks to avoid:
|
||||
@@ -157,33 +159,24 @@ export const useCronJobsCleanupActions = ({
|
||||
* This should only be called after a successful initial deletion.
|
||||
*/
|
||||
const scheduleCleanup = useCallback(
|
||||
async (interval: string) => {
|
||||
async ({ interval, onSuccess }: { interval: string; onSuccess?: () => void }) => {
|
||||
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.')
|
||||
}
|
||||
scheduleCronJobCleanup(
|
||||
{ projectRef, connectionString, interval },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Scheduled daily cleanup job.')
|
||||
onSuccess?.()
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[projectRef, connectionString, executeSql]
|
||||
[connectionString, projectRef, scheduleCronJobCleanup]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -196,8 +189,10 @@ export const useCronJobsCleanupActions = ({
|
||||
|
||||
return {
|
||||
cleanupInterval,
|
||||
setCleanupInterval,
|
||||
cleanupState,
|
||||
isScheduling,
|
||||
isScheduleSuccess,
|
||||
setCleanupInterval,
|
||||
runBatchedDeletion,
|
||||
scheduleCleanup,
|
||||
cancelDeletion,
|
||||
|
||||
+41
-175
@@ -1,16 +1,17 @@
|
||||
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 { useMemo } from 'react'
|
||||
import type { ResponseError } from 'types'
|
||||
import { CRON_JOBS_THRESHOLD, JOB_RUN_DETAILS_THRESHOLD } from './CronJobsTab.constants'
|
||||
|
||||
import { CRON_JOBS_THRESHOLD } from './CronJobsTab.constants'
|
||||
import type { ConnectionVars } from '@/data/common.types'
|
||||
import { useCronJobsMinimalInfiniteQuery } from '@/data/database-cron-jobs/database-cron-jobs-minimal-infinite-query'
|
||||
import { COST_THRESHOLD_ERROR } from '@/data/sql/execute-sql-query'
|
||||
|
||||
// =============================================================================
|
||||
// Input types
|
||||
@@ -31,6 +32,7 @@ interface CronJobsGridState {
|
||||
isRefetching: boolean
|
||||
isFetchingNextPage: boolean
|
||||
hasNextPage: boolean
|
||||
isMinimal: boolean
|
||||
refetch: () => void
|
||||
fetchNextPage: () => void
|
||||
}
|
||||
@@ -41,81 +43,16 @@ interface CronJobsCountState {
|
||||
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,
|
||||
@@ -123,37 +60,10 @@ export function useCronJobsData({
|
||||
}: 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,
|
||||
@@ -164,14 +74,35 @@ export function useCronJobsData({
|
||||
{
|
||||
placeholderData: Boolean(searchQuery) ? keepPreviousData : undefined,
|
||||
staleTime: Infinity,
|
||||
enabled: isQueryEnabled,
|
||||
}
|
||||
)
|
||||
const useMinimalQuery = cronJobsError?.message === COST_THRESHOLD_ERROR
|
||||
|
||||
const {
|
||||
data: cronJobsMinimalData,
|
||||
error: cronJobsMinimalError,
|
||||
isLoading: isCronJobsMinimalLoading,
|
||||
isRefetching: isCronJobsMinimalRefetching,
|
||||
isFetchingNextPage: isFetchingNextPageMinimal,
|
||||
hasNextPage: hasNextPageMinimal = false,
|
||||
refetch: refetchCronJobsMinimal,
|
||||
fetchNextPage: fetchNextPageMinimal,
|
||||
} = useCronJobsMinimalInfiniteQuery(
|
||||
{ projectRef, connectionString, searchTerm: searchQuery ?? undefined },
|
||||
{
|
||||
placeholderData: Boolean(searchQuery) ? keepPreviousData : undefined,
|
||||
staleTime: Infinity,
|
||||
enabled: useMinimalQuery,
|
||||
}
|
||||
)
|
||||
|
||||
const cronJobs = useMemo(
|
||||
() => cronJobsData?.pages.flatMap((page) => page) ?? [],
|
||||
[cronJobsData?.pages]
|
||||
)
|
||||
const cronJobs = useMemo(() => {
|
||||
if (useMinimalQuery) {
|
||||
return cronJobsMinimalData?.pages.flatMap((page) => page) ?? []
|
||||
} else {
|
||||
return cronJobsData?.pages.flatMap((page) => page) ?? []
|
||||
}
|
||||
}, [useMinimalQuery, cronJobsData?.pages, cronJobsMinimalData?.pages])
|
||||
|
||||
// Fetch count - gated on cron.job table size
|
||||
// Always fetch the estimate first (it's fast since it uses pg_stat)
|
||||
@@ -193,77 +124,18 @@ export function useCronJobsData({
|
||||
{ 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,
|
||||
error: useMinimalQuery ? cronJobsMinimalError : cronJobsError,
|
||||
isLoading: useMinimalQuery ? isCronJobsMinimalLoading : isCronJobsLoading,
|
||||
isRefetching: useMinimalQuery ? isCronJobsMinimalRefetching : isCronJobsRefetching,
|
||||
isFetchingNextPage: useMinimalQuery ? isFetchingNextPageMinimal : isFetchingNextPage,
|
||||
hasNextPage: useMinimalQuery ? hasNextPageMinimal : hasNextPage,
|
||||
isMinimal: useMinimalQuery,
|
||||
refetch: useMinimalQuery ? refetchCronJobsMinimal : refetchCronJobs,
|
||||
fetchNextPage: useMinimalQuery ? fetchNextPageMinimal : fetchNextPage,
|
||||
},
|
||||
|
||||
count: {
|
||||
value: isCountQueryEnabled ? exactCount : estimatedCount,
|
||||
isEstimate: !isCountQueryEnabled,
|
||||
@@ -271,9 +143,3 @@ export function useCronJobsData({
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Type exports for consumers
|
||||
// =============================================================================
|
||||
|
||||
export type { CronJobsCountState, CronJobsDataStatus, CronJobsGridState }
|
||||
@@ -1,67 +0,0 @@
|
||||
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 }),
|
||||
})
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { COST_THRESHOLD_ERROR, executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError, UseCustomInfiniteQueryOptions } from 'types'
|
||||
|
||||
import { getCronJobsSql } from '../sql/queries/get-cron-jobs'
|
||||
import { databaseCronJobsKeys } from './keys'
|
||||
|
||||
const CRON_JOBS_PAGE_LIMIT = 20
|
||||
export const CRON_JOBS_PAGE_LIMIT = 20
|
||||
|
||||
type DatabaseCronJobRunsVariables = {
|
||||
export type DatabaseCronJobRunsVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string | null
|
||||
searchTerm?: string
|
||||
@@ -17,50 +18,11 @@ export type CronJob = {
|
||||
jobname: string | null
|
||||
active: boolean
|
||||
command: string
|
||||
latest_run: string
|
||||
schedule: string
|
||||
status: string
|
||||
latest_run?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
// [Joshen] Just to call out that I had AI help me with this, so please let me know if this can be optimized
|
||||
const getCronJobSql = ({ searchTerm, page }: { searchTerm?: string; page: number }) =>
|
||||
`
|
||||
WITH latest_runs AS (
|
||||
SELECT
|
||||
jobid,
|
||||
status,
|
||||
MAX(start_time) AS latest_run
|
||||
FROM cron.job_run_details
|
||||
GROUP BY jobid, status
|
||||
), most_recent_runs AS (
|
||||
SELECT
|
||||
jobid,
|
||||
status,
|
||||
latest_run
|
||||
FROM latest_runs lr1
|
||||
WHERE latest_run = (
|
||||
SELECT MAX(latest_run)
|
||||
FROM latest_runs lr2
|
||||
WHERE lr2.jobid = lr1.jobid
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
job.jobid,
|
||||
job.jobname,
|
||||
job.schedule,
|
||||
job.command,
|
||||
job.active,
|
||||
mr.latest_run,
|
||||
mr.status
|
||||
FROM
|
||||
cron.job job
|
||||
LEFT JOIN most_recent_runs mr ON job.jobid = mr.jobid
|
||||
${!!searchTerm ? `WHERE job.jobname ILIKE '%${searchTerm}%'` : ''}
|
||||
ORDER BY job.jobid
|
||||
LIMIT ${CRON_JOBS_PAGE_LIMIT}
|
||||
OFFSET ${page * CRON_JOBS_PAGE_LIMIT};
|
||||
`.trim()
|
||||
|
||||
export async function getDatabaseCronJobs({
|
||||
projectRef,
|
||||
connectionString,
|
||||
@@ -72,8 +34,9 @@ export async function getDatabaseCronJobs({
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getCronJobSql({ searchTerm, page }),
|
||||
sql: getCronJobsSql({ searchTerm, page, limit: CRON_JOBS_PAGE_LIMIT }),
|
||||
queryKey: ['cron-jobs'],
|
||||
preflightCheck: true,
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -114,5 +77,9 @@ export const useCronJobsInfiniteQuery = <TData = DatabaseCronJobsInfiniteData>(
|
||||
if (!hasNextPage) return undefined
|
||||
return page
|
||||
},
|
||||
retry: (failureCount, error) => {
|
||||
if (error.message === COST_THRESHOLD_ERROR) return false
|
||||
return failureCount < 3
|
||||
},
|
||||
...options,
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError, UseCustomInfiniteQueryOptions } from 'types'
|
||||
|
||||
import { getCronJobsMinimalSql } from '../sql/queries/get-cron-jobs'
|
||||
import {
|
||||
CRON_JOBS_PAGE_LIMIT,
|
||||
CronJob,
|
||||
DatabaseCronJobRunsVariables,
|
||||
} from './database-cron-jobs-infinite-query'
|
||||
import { databaseCronJobsKeys } from './keys'
|
||||
|
||||
export async function getDatabaseCronJobsMinimal({
|
||||
projectRef,
|
||||
connectionString,
|
||||
searchTerm,
|
||||
page = 0,
|
||||
}: DatabaseCronJobRunsVariables & { page: number }) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getCronJobsMinimalSql({ searchTerm, page, limit: CRON_JOBS_PAGE_LIMIT }),
|
||||
queryKey: ['cron-jobs-minimal'],
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type DatabaseCronJobsMinimalInfiniteData = CronJob[]
|
||||
type DatabaseCronJobsMinimalInfiniteError = ResponseError
|
||||
|
||||
export const useCronJobsMinimalInfiniteQuery = <TData = DatabaseCronJobsMinimalInfiniteData>(
|
||||
{ projectRef, connectionString, searchTerm }: DatabaseCronJobRunsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomInfiniteQueryOptions<
|
||||
DatabaseCronJobsMinimalInfiniteData,
|
||||
DatabaseCronJobsMinimalInfiniteError,
|
||||
InfiniteData<TData>,
|
||||
readonly unknown[],
|
||||
number
|
||||
> = {}
|
||||
) =>
|
||||
useInfiniteQuery({
|
||||
queryKey: databaseCronJobsKeys.listInfiniteMinimal(projectRef, searchTerm),
|
||||
queryFn: ({ pageParam }) => {
|
||||
return getDatabaseCronJobsMinimal({
|
||||
projectRef,
|
||||
connectionString,
|
||||
searchTerm,
|
||||
page: pageParam,
|
||||
})
|
||||
},
|
||||
staleTime: 0,
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
initialPageParam: 0,
|
||||
getNextPageParam(lastPage, pages) {
|
||||
const page = pages.length
|
||||
const hasNextPage = lastPage.length >= CRON_JOBS_PAGE_LIMIT
|
||||
if (!hasNextPage) return undefined
|
||||
return page
|
||||
},
|
||||
...options,
|
||||
})
|
||||
@@ -6,6 +6,8 @@ export const databaseCronJobsKeys = {
|
||||
['projects', projectRef, 'cron-jobs', identifier] as const,
|
||||
listInfinite: (projectRef: string | undefined, searchTerm: string | undefined) =>
|
||||
['projects', projectRef, 'cron-jobs', { searchTerm }] as const,
|
||||
listInfiniteMinimal: (projectRef: string | undefined, searchTerm: string | undefined) =>
|
||||
['projects', projectRef, 'cron-jobs-mininal', { searchTerm }] as const,
|
||||
count: (projectRef: string | undefined) =>
|
||||
['projects', projectRef, 'cron-jobs', 'count'] as const,
|
||||
run: (projectRef: string | undefined, jobId: number) => [
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { toast } from 'sonner'
|
||||
import type { ResponseError, UseCustomMutationOptions } from 'types'
|
||||
|
||||
import {
|
||||
getScheduleDeleteCronJobRunDetailsKey,
|
||||
getScheduleDeleteCronJobRunDetailsSql,
|
||||
} from '../sql/queries/delete-cron-job-run-details'
|
||||
|
||||
export type ScheduleCronJobRunDetailsCleanupVariables = {
|
||||
projectRef: string
|
||||
connectionString?: string | null
|
||||
interval: string
|
||||
}
|
||||
|
||||
export async function scheduleCronJobRunDetailsCleanup({
|
||||
projectRef,
|
||||
connectionString,
|
||||
interval,
|
||||
}: ScheduleCronJobRunDetailsCleanupVariables) {
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getScheduleDeleteCronJobRunDetailsSql(interval),
|
||||
queryKey: getScheduleDeleteCronJobRunDetailsKey(projectRef, interval),
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type ScheduleCronJobRunDetailsCleanupData = Awaited<
|
||||
ReturnType<typeof scheduleCronJobRunDetailsCleanup>
|
||||
>
|
||||
|
||||
export const useScheduleCronJobRunDetailsCleanupMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<
|
||||
ScheduleCronJobRunDetailsCleanupData,
|
||||
ResponseError,
|
||||
ScheduleCronJobRunDetailsCleanupVariables
|
||||
>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
return useMutation<
|
||||
ScheduleCronJobRunDetailsCleanupData,
|
||||
ResponseError,
|
||||
ScheduleCronJobRunDetailsCleanupVariables
|
||||
>({
|
||||
mutationFn: (vars) => scheduleCronJobRunDetailsCleanup(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to schedule clean up job: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { literal } from '@supabase/pg-meta/src/pg-format'
|
||||
|
||||
// [Joshen] Just omits the LEFT JOIN as that's the heavy part
|
||||
export const getCronJobsMinimalSql = ({
|
||||
searchTerm,
|
||||
page,
|
||||
limit,
|
||||
}: {
|
||||
searchTerm?: string
|
||||
page: number
|
||||
limit: number
|
||||
}) =>
|
||||
`
|
||||
SELECT
|
||||
job.jobid,
|
||||
job.jobname,
|
||||
job.schedule,
|
||||
job.command,
|
||||
job.active
|
||||
FROM
|
||||
cron.job job
|
||||
${!!searchTerm ? `WHERE job.jobname ILIKE ${literal(`%${searchTerm}%`)}` : ''}
|
||||
ORDER BY job.jobid
|
||||
LIMIT ${limit}
|
||||
OFFSET ${page * limit};
|
||||
`.trim()
|
||||
|
||||
export const getCronJobsSql = ({
|
||||
searchTerm,
|
||||
page,
|
||||
limit,
|
||||
}: {
|
||||
searchTerm?: string
|
||||
page: number
|
||||
limit: number
|
||||
}) =>
|
||||
`
|
||||
WITH latest_runs AS (
|
||||
SELECT
|
||||
jobid,
|
||||
status,
|
||||
MAX(start_time) AS latest_run
|
||||
FROM cron.job_run_details
|
||||
GROUP BY jobid, status
|
||||
), most_recent_runs AS (
|
||||
SELECT
|
||||
jobid,
|
||||
status,
|
||||
latest_run
|
||||
FROM latest_runs lr1
|
||||
WHERE latest_run = (
|
||||
SELECT MAX(latest_run)
|
||||
FROM latest_runs lr2
|
||||
WHERE lr2.jobid = lr1.jobid
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
job.jobid,
|
||||
job.jobname,
|
||||
job.schedule,
|
||||
job.command,
|
||||
job.active,
|
||||
mr.latest_run,
|
||||
mr.status
|
||||
FROM
|
||||
cron.job job
|
||||
LEFT JOIN most_recent_runs mr ON job.jobid = mr.jobid
|
||||
${!!searchTerm ? `WHERE job.jobname ILIKE ${literal(`%${searchTerm}%`)}` : ''}
|
||||
ORDER BY job.jobid
|
||||
LIMIT ${limit}
|
||||
OFFSET ${page * limit};
|
||||
`.trim()
|
||||
@@ -1,29 +0,0 @@
|
||||
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,12 +1,10 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
|
||||
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 { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
@@ -24,8 +22,6 @@ const IntegrationPage: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
const { ref, id, pageId, childId } = useParams()
|
||||
|
||||
useCronJobsEstimatePrefetch(id)
|
||||
|
||||
const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } =
|
||||
useInstalledIntegrations()
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { useFlag, useParams } from 'common'
|
||||
import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
|
||||
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
|
||||
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 Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import {
|
||||
BreadcrumbItem_Shadcn_ as BreadcrumbItem,
|
||||
@@ -44,8 +42,6 @@ const IntegrationPage: NextPageWithLayout = () => {
|
||||
const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
|
||||
const stripeSyncEnabled = useFlag('enableStripeSyncEngineIntegration')
|
||||
|
||||
useCronJobsEstimatePrefetch(id)
|
||||
|
||||
const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } =
|
||||
useInstalledIntegrations()
|
||||
|
||||
|
||||
@@ -87,10 +87,10 @@ const deleteCronJob = async (page: Page, jobName: string) => {
|
||||
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', () => {
|
||||
// Run CRUD tests serially since they share database state (pg_cron extension)
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.describe('Cron Jobs CRUD Operations', () => {
|
||||
let page: Page
|
||||
|
||||
@@ -266,56 +266,194 @@ test.describe('Cron Jobs Integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Large Table Overflow Notice', () => {
|
||||
const cleanupJobName = 'delete-job-run-details'
|
||||
test.describe('High Query Cost Banner', () => {
|
||||
const testJobName = 'pw_high_cost_test_job'
|
||||
|
||||
test('shows cleanup notice and allows cleanup workflow', async ({ page, ref }) => {
|
||||
// First ensure pg_cron is enabled (without any mocks)
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('shows banner and still displays cron jobs when query cost exceeds threshold', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
// First ensure pg_cron is enabled
|
||||
await navigateToCronOverviewAndEnable(page, ref)
|
||||
await navigateToCronJobsPage(page, ref)
|
||||
|
||||
// Now set up the mock for the row count estimate
|
||||
// Check if test job already exists (from previous test run)
|
||||
const existingTestJob = page.getByRole('row', { name: new RegExp(testJobName) })
|
||||
const jobExists = (await existingTestJob.count()) > 0
|
||||
|
||||
// Create a test cron job only if it doesn't exist
|
||||
if (!jobExists) {
|
||||
await page.getByRole('button', { name: 'Create job' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create a new cron job' })).toBeVisible()
|
||||
await page.locator('input[name="name"]').fill(testJobName)
|
||||
await page.getByRole('button', { name: 'Every minute' }).click()
|
||||
await page.getByRole('code').click()
|
||||
await page.getByRole('textbox', { name: /Editor content/ }).fill("SELECT 'high_cost_test';")
|
||||
await page.getByRole('button', { name: 'Create cron job' }).click()
|
||||
await expect(page.getByText(/Successfully created cron job/)).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
// Now set up the mock for the EXPLAIN query (preflight check) to return high cost
|
||||
// This simulates the scenario where cron.job_run_details is too large
|
||||
await page.route('**/pg-meta/*/query**', async (route) => {
|
||||
const request = route.request()
|
||||
const postData = request.postDataJSON()
|
||||
|
||||
// Only intercept the live tuple estimate query for job_run_details
|
||||
if (postData?.query?.includes('n_live_tup') && postData?.query?.includes('job_run_details')) {
|
||||
// Intercept EXPLAIN queries for the cron jobs query (the preflight check)
|
||||
if (
|
||||
postData?.query?.toLowerCase().startsWith('explain') &&
|
||||
postData?.query?.includes('cron.job')
|
||||
) {
|
||||
// Return a mock EXPLAIN result with very high cost (over 100,000 threshold)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([{ live_tuple_estimate: 500000 }]),
|
||||
body: JSON.stringify([
|
||||
{ 'QUERY PLAN': 'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)' },
|
||||
]),
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
// Navigate to the cron jobs page (this will trigger the mocked estimate query)
|
||||
// Navigate to the cron jobs page - this will trigger the mocked preflight check
|
||||
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({
|
||||
// Wait for the grid to load - this verifies cron jobs are still visible in minimal mode
|
||||
await expect(page.getByRole('grid')).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// Should show the overflow banner (minimal mode indicator)
|
||||
await expect(
|
||||
page.getByText('Last run for each cron job omitted due to high query cost')
|
||||
).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// Should show the estimated row count in the description
|
||||
await expect(page.getByText(/approximately 500,000 rows/)).toBeVisible()
|
||||
// The test job should still be visible in the grid (minimal mode shows jobs without last run)
|
||||
await expect(page.getByRole('row', { name: new RegExp(testJobName) })).toBeVisible()
|
||||
|
||||
// The "Learn more" button should be visible
|
||||
await expect(page.getByRole('button', { name: 'Learn more' })).toBeVisible()
|
||||
|
||||
// Remove the route mock for subsequent tests
|
||||
await page.unroute('**/pg-meta/*/query**')
|
||||
})
|
||||
|
||||
test('Learn more dialog shows cleanup options', async ({ page, ref }) => {
|
||||
// Set up the mock again for this test
|
||||
await page.route('**/pg-meta/*/query**', async (route) => {
|
||||
const request = route.request()
|
||||
const postData = request.postDataJSON()
|
||||
|
||||
if (
|
||||
postData?.query?.toLowerCase().startsWith('explain') &&
|
||||
postData?.query?.includes('cron.job')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ 'QUERY PLAN': 'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)' },
|
||||
]),
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
|
||||
|
||||
// Wait for the banner to appear
|
||||
await expect(
|
||||
page.getByText('Last run for each cron job omitted due to high query cost')
|
||||
).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// Click the "Learn more" button to open the dialog
|
||||
await page.getByRole('button', { name: 'Learn more' }).click()
|
||||
|
||||
// The dialog should open with the explanation
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Last run for cron jobs omitted for overview' })
|
||||
).toBeVisible()
|
||||
|
||||
// Should explain the issue
|
||||
await expect(
|
||||
page.getByText(/the estimated query cost exceeds safety thresholds/)
|
||||
).toBeVisible()
|
||||
|
||||
// Should show Step 1 with cleanup options
|
||||
await expect(page.getByText('Step 1: Delete older entries')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Delete rows now' })).toBeVisible()
|
||||
|
||||
// Should show the interval selector
|
||||
await expect(page.getByRole('combobox')).toBeVisible()
|
||||
|
||||
// Should show Step 2 (disabled until step 1 is complete)
|
||||
await expect(page.getByText('Step 2: Schedule an automated cleanup')).toBeVisible()
|
||||
await expect(page.getByText('Complete step 1 to enable scheduling')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Complete step 1 to enable scheduling a daily cleanup job')
|
||||
).toBeVisible()
|
||||
|
||||
// Remove the route mock
|
||||
await page.unroute('**/pg-meta/*/query**')
|
||||
})
|
||||
|
||||
test('cleanup workflow: delete rows and schedule cleanup job', async ({ page, ref }) => {
|
||||
// Set up the mock for the high cost scenario
|
||||
await page.route('**/pg-meta/*/query**', async (route) => {
|
||||
const request = route.request()
|
||||
const postData = request.postDataJSON()
|
||||
|
||||
if (
|
||||
postData?.query?.toLowerCase().startsWith('explain') &&
|
||||
postData?.query?.includes('cron.job')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ 'QUERY PLAN': 'Nested Loop Left Join (cost=0.00..500000.00 rows=1000000 width=100)' },
|
||||
]),
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/integrations/cron/jobs`))
|
||||
|
||||
// Wait for the banner and click Learn more
|
||||
await expect(
|
||||
page.getByText('Last run for each cron job omitted due to high query cost')
|
||||
).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await page.getByRole('button', { name: 'Learn more' }).click()
|
||||
|
||||
// Wait for dialog to open
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Last run for cron jobs omitted for overview' })
|
||||
).toBeVisible()
|
||||
|
||||
// Step 1: Click "Delete rows now" to run the cleanup
|
||||
await page.getByRole('button', { name: 'Delete rows now' }).click()
|
||||
|
||||
// Wait for Step 1 to complete - should show success message
|
||||
// Note: In a fresh DB, there might be 0 rows deleted, which is still success
|
||||
await expect(page.getByText(/Successfully deleted \d+ rows/)).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// Step 2 should now be enabled - click "Schedule cleanup job"
|
||||
// Step 2 should now be enabled - the "Schedule cleanup job" button should be visible
|
||||
await expect(page.getByRole('button', { name: 'Schedule cleanup job' })).toBeVisible()
|
||||
|
||||
// Should show the SQL preview for the cleanup job
|
||||
await expect(page.getByText(/cron\.schedule/)).toBeVisible()
|
||||
|
||||
// Click "Schedule cleanup job"
|
||||
await page.getByRole('button', { name: 'Schedule cleanup job' }).click()
|
||||
|
||||
// Wait for Step 2 to complete - should show success message
|
||||
@@ -323,14 +461,11 @@ test.describe('Large Table Overflow Notice', () => {
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// Clean up: remove the route mock and delete the scheduled cleanup job
|
||||
// Clean up: remove the route mock
|
||||
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()
|
||||
// Note: Test jobs (pw_high_cost_test_job, delete-job-run-details) will be cleaned up
|
||||
// in the next test run by the CRUD tests' beforeAll cleanup, or manually.
|
||||
// Attempting to delete here causes pointer events issues with Radix dialogs.
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user