Skip to content

Commit

Permalink
feat: Cron tab for previous runs (supabase#30690)
Browse files Browse the repository at this point in the history
* Redo cron ui to use a data table

* Add component for form header

* Add next run column

* add cron jobs page

* Load jobs from url, add runs footer, link to logs

* Type issue

* Fix height of tab area

* Use nuqs with history

* improve pgcron logs, add severity filters

* Check for v1.5 to see if seconds are supported

* fix cron jobs logs table name

* Add type to the table

* Move expression warning to own function

* Fit to new layout

* Types

* Fix long code blocks

* Revert some of the changes.

* Use job name as a tab name. Other minor fixes.

* Use Infinite query for the cron runs.

* Revert some extra changes. Will be added to another PR.

* Rename pg functions

---------

Co-authored-by: Terry Sutton <[email protected]>
Co-authored-by: Jordi Enric <[email protected]>
  • Loading branch information
3 people authored Nov 27, 2024
1 parent 2195581 commit 492c81c
Show file tree
Hide file tree
Showing 19 changed files with 493 additions and 46 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ interface CronJobCardProps {
onDeleteCronJob: (job: CronJob) => void
}

const generateJobDetailsSQL = (jobId: number) => {
return `select * from cron.job_run_details where jobid = '${jobId}' order by start_time desc limit 10`
}

export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCardProps) => {
const [toggleConfirmationModalShown, showToggleConfirmationModal] = useState(false)
const { ref } = useParams()
Expand Down Expand Up @@ -81,9 +77,7 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
Edit cron job
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/project/${ref}/sql/new?content=${encodeURIComponent(generateJobDetailsSQL(job.jobid))}`}
>
<Link href={`/project/${ref}/integrations/cron-jobs/cron-jobs/${job.jobname}`}>
View previous runs
</Link>
</DropdownMenuItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export const CRONJOB_DEFINITIONS = [
{
value: 'sql_function',
icon: <ScrollText strokeWidth={1} />,
label: 'Postgres SQL Function',
description: 'Choose a Postgres SQL functions to run.',
label: 'Database function',
description: 'Choose a database function to run.',
},

{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,36 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
return DEFAULT_CRONJOB_COMMAND
}

export function calculateDuration(start: string, end: string): string {
const startTime = new Date(start).getTime()
const endTime = new Date(end).getTime()
const duration = endTime - startTime
return isNaN(duration) ? 'Invalid Date' : `${duration} ms`
}

export function formatDate(dateString: string): string {
const date = new Date(dateString)
if (isNaN(date.getTime())) {
return 'Invalid Date'
}
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short', // Use 'long' for full month name
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false, // Use 12-hour format if preferred
timeZoneName: 'short', // Optional: to include timezone
}
return date.toLocaleString(undefined, options)
}

// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
export const secondsPattern = /^\d+\s+seconds$/
export const cronPattern =
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/

export function isSecondsFormat(schedule: string): boolean {
return secondsPattern.test(schedule.trim())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export default function CronJobsEmptyState({ page }: { page: string }) {
return (
<div className=" text-center h-full w-full items-center justify-center rounded-md px-4 py-12 ">
<p className="text-sm text-foreground">
{page === 'jobs' ? 'No cron jobs created yet' : 'No runs for this cron job yet'}
</p>
<p className="text-sm text-foreground-lighter">
{page === 'jobs'
? 'Create one by clicking "Create a new cron job"'
: 'Check the schedule of your cron jobs to see when they run'}
</p>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export const CronjobsTab = () => {
projectRef: project?.ref,
connectionString: project?.connectionString,
})

if (isLoading)
return (
<div className="p-10">
Expand Down Expand Up @@ -95,9 +94,14 @@ export const CronjobsTab = () => {
Your search for "{searchQuery}" did not return any results
</p>
</div>
) : isLoading ? (
<div className="p-10">
<GenericSkeletonLoader />
</div>
) : (
filteredCronJobs.map((job) => (
<CronJobCard
key={job.jobid}
job={job}
onEditCronJob={(job) => setCreateCronJobSheetShown(job)}
onDeleteCronJob={(job) => setCronJobForDeletion(job)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import { toString as CronToString } from 'cronstrue'
import { List } from 'lucide-react'
import Link from 'next/link'
import { UIEvent, useCallback, useMemo } from 'react'
import DataGrid, { Column, Row } from 'react-data-grid'

import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query'
import {
CronJobRun,
useCronJobRunsInfiniteQuery,
} from 'data/database-cron-jobs/database-cron-jobs-runs-infinite-query'
import {
Badge,
Button,
cn,
LoadingLine,
SimpleCodeBlock,
Tooltip_Shadcn_,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
} from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { calculateDuration, formatDate, isSecondsFormat } from './CronJobs.utils'
import CronJobsEmptyState from './CronJobsEmptyState'

const cronJobColumns = [
{
id: 'runid',
name: 'RunID',
minWidth: 60,
value: (row: CronJobRun) => (
<div className="flex items-center gap-1.5">
<h3 className="text-xs">{row.runid}</h3>
</div>
),
},
{
id: 'message',
name: 'Message',
minWidth: 200,
value: (row: CronJobRun) => (
<div className="flex items-center gap-1.5">
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<span className="text-xs cursor-pointer truncate max-w-[300px]">
{row.return_message}
</span>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" align="center" className="max-w-[300px] text-wrap">
<SimpleCodeBlock
showCopy={true}
className="sql"
parentClassName="!p-0 [&>div>span]:text-xs"
>
{row.return_message}
</SimpleCodeBlock>
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</div>
),
},

{
id: 'status',
name: 'Status',
minWidth: 75,
value: (row: CronJobRun) => (
<Badge variant={row.status === 'succeeded' ? 'success' : 'warning'}>{row.status}</Badge>
),
},
{
id: 'start_time',
name: 'Start Time',
minWidth: 120,
value: (row: CronJobRun) => <div className="text-xs">{formatDate(row.start_time)}</div>,
},
{
id: 'end_time',
name: 'End Time',
minWidth: 120,
value: (row: CronJobRun) => <div className="text-xs">{formatDate(row.end_time)}</div>,
},

{
id: 'duration',
name: 'Duration',
minWidth: 100,
value: (row: CronJobRun) => (
<div className="text-xs">{calculateDuration(row.start_time, row.end_time)}</div>
),
},
]

const columns = cronJobColumns.map((col) => {
const result: Column<CronJobRun> = {
key: col.id,
name: col.name,
resizable: true,
minWidth: col.minWidth ?? 120,
headerCellClass: 'first:pl-6 cursor-pointer',
renderHeaderCell: () => {
return (
<div className="flex items-center justify-between font-mono font-normal text-xs w-full">
<div className="flex items-center gap-x-2">
<p className="!text-foreground">{col.name}</p>
</div>
</div>
)
},
renderCell: (props) => {
const value = col.value(props.row)

return (
<div
className={cn(
'w-full flex flex-col justify-center font-mono text-xs',
typeof value === 'number' ? 'text-right' : ''
)}
>
<span>{value}</span>
</div>
)
},
}
return result
})

function isAtBottom({ currentTarget }: UIEvent<HTMLDivElement>): boolean {
return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight
}

export const PreviousRunsTab = () => {
const { childId: jobName } = useParams()
const { project } = useProjectContext()

const { data: cronJobs, isLoading: isLoadingCronJobs } = useCronJobsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})

const currentJobState = cronJobs?.find((job) => job.jobname === jobName)

const {
data,
isLoading: isLoadingCronJobRuns,
fetchNextPage,
isFetching,
} = useCronJobRunsInfiniteQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
jobId: Number(currentJobState?.jobid),
},
{ enabled: !!currentJobState?.jobid, staleTime: 30 }
)

const handleScroll = useCallback(
(event: UIEvent<HTMLDivElement>) => {
if (isLoadingCronJobRuns || !isAtBottom(event)) return
// the cancelRefetch is to prevent the query from being refetched when the user scrolls back up and down again,
// resulting in multiple fetchNextPage calls
fetchNextPage({ cancelRefetch: false })
},
[fetchNextPage, isLoadingCronJobRuns]
)

const cronJobRuns = useMemo(() => data?.pages.flatMap((p) => p) || [], [data?.pages])

return (
<div className="h-full flex flex-col">
<div className="mt-4 h-full">
<LoadingLine loading={isFetching} />
<DataGrid
className="flex-grow h-full"
rowHeight={44}
headerRowHeight={36}
onScroll={handleScroll}
columns={columns}
rows={cronJobRuns ?? []}
rowClass={() => {
const isSelected = false
return cn([
`${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} `,
`${isSelected ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-foreground' : ''}`,
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
'[&>.rdg-cell:first-child>div]:ml-4',
])
}}
renderers={{
renderRow(_idx, props) {
return <Row key={props.row.job_pid} {...props} />
},
noRowsFallback: isLoadingCronJobRuns ? (
<div className="absolute top-14 px-6 w-full">
<GenericSkeletonLoader />
</div>
) : (
<div className="flex items-center justify-center w-full col-span-6">
<CronJobsEmptyState page="runs" />
</div>
),
}}
/>
</div>

<div className="px-6 py-6 flex gap-12 border-t">
{isLoadingCronJobs ? (
<GenericSkeletonLoader />
) : (
<>
<div className="grid gap-2 w-56">
<h3 className="text-sm">Schedule</h3>
<p className="text-xs text-foreground-light">
{currentJobState?.schedule ? (
<>
<span className="font-mono text-lg">{currentJobState.schedule}</span>
<p>
{isSecondsFormat(currentJobState.schedule)
? ''
: CronToString(currentJobState.schedule.toLowerCase())}
</p>
</>
) : (
<span>Loading schedule...</span>
)}
</p>
</div>

<div className="grid gap-2">
<h3 className="text-sm">Command</h3>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ className=" text-left p-0! cursor-pointer truncate max-w-[300px] h-12 relative">
<SimpleCodeBlock
showCopy={false}
className="sql"
parentClassName=" [&>div>span]:text-xs bg-alternative-200 !p-2 rounded-md"
>
{currentJobState?.command}
</SimpleCodeBlock>
<div className="bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-background-200 to-transparent absolute " />
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_
side="bottom"
align="center"
className="max-w-[400px] text-wrap"
>
<SimpleCodeBlock
showCopy={false}
className="sql"
parentClassName=" [&>div>span]:text-xs bg-alternative-200 !p-2 rounded-md"
>
{currentJobState?.command}
</SimpleCodeBlock>
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
{/* <div className="text-xs text-foreground-light">
<SimpleCodeBlock
showCopy={false}
className="sql"
parentClassName=" [&>div>span]:text-xs bg-alternative-200 !p-2 rounded-md"
>
{currentJobState?.command}
</SimpleCodeBlock>
</div> */}
</div>

<div className="grid gap-2">
<h3 className="text-sm">Explore</h3>
<Button asChild type="outline" icon={<List strokeWidth={1.5} size="14" />}>
{/* [Terry] need to link to the exact jobid, but not currently supported */}
<Link target="_blank" href={`/project/${project?.ref}/logs/pgcron-logs/`}>
View logs
</Link>
</Button>
</div>
</>
)}
</div>
</div>
)
}
Loading

0 comments on commit 492c81c

Please sign in to comment.