show invalid cron jobs w/ feedback

This commit is contained in:
Chris Wanstrath 2026-02-09 16:46:38 -08:00
parent 4d3083764a
commit f96c599f49
4 changed files with 78 additions and 15 deletions

View File

@ -4,8 +4,8 @@ import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { discoverCronJobs } from './lib/discovery'
import { scheduleJob, stopJob } from './lib/scheduler'
import { executeJob } from './lib/executor'
import { setJobs, getJob, getAllJobs, broadcast } from './lib/state'
import { SCHEDULES, type CronJob } from './lib/schedules'
import { setJobs, setInvalidJobs, getJob, getAllJobs, getInvalidJobs, broadcast } from './lib/state'
import { SCHEDULES, type CronJob, type InvalidJob } from './lib/schedules'
import type { Child } from 'hono/jsx'
import { join } from 'path'
import { mkdir, writeFile } from 'fs/promises'
@ -92,6 +92,24 @@ const EmptyState = define('EmptyState', {
color: theme('colors-textMuted'),
})
const InvalidItem = define('InvalidItem', {
padding: '12px 15px',
borderBottom: `1px solid ${theme('colors-border')}`,
display: 'flex',
alignItems: 'center',
gap: '15px',
opacity: 0.7,
states: {
':last-child': { borderBottom: 'none' },
},
})
const ErrorText = define('ErrorText', {
fontSize: '12px',
color: theme('colors-error'),
flex: 1,
})
const ActionRow = define('ActionRow', {
display: 'flex',
justifyContent: 'flex-end',
@ -226,19 +244,24 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
app.get('/', async c => {
const appFilter = c.req.query('app')
let jobs = getAllJobs()
let invalid = getInvalidJobs()
if (appFilter) {
jobs = jobs.filter(j => j.app === appFilter)
invalid = invalid.filter(j => j.app === appFilter)
}
jobs.sort((a, b) => a.id.localeCompare(b.id))
invalid.sort((a, b) => a.id.localeCompare(b.id))
const hasAny = jobs.length > 0 || invalid.length > 0
return c.html(
<Layout title="Cron Jobs">
<ActionRow>
<NewButton href={`/new?app=${appFilter || ''}`}>New Job</NewButton>
</ActionRow>
{jobs.length === 0 ? (
{!hasAny ? (
<EmptyState>
No cron jobs found.
<br />
@ -260,6 +283,13 @@ app.get('/', async c => {
</form>
</JobItem>
))}
{invalid.map(job => (
<InvalidItem>
<StatusDot style={{ backgroundColor: theme('colors-error') }} />
<JobName>{job.app}/{job.name}</JobName>
<ErrorText>{job.error}</ErrorText>
</InvalidItem>
))}
</JobList>
)}
</Layout>
@ -336,8 +366,9 @@ export default async function() {
console.log(`[cron] Created ${appName}:${name}`)
// Trigger rediscovery
const jobs = await discoverCronJobs()
const { jobs, invalid } = await discoverCronJobs()
setJobs(jobs)
setInvalidJobs(invalid)
for (const job of jobs) {
if (job.id === `${appName}:${name}`) {
scheduleJob(job, broadcast)
@ -364,21 +395,26 @@ app.post('/run/:app/:name', async c => {
// Initialize
async function init() {
const jobs = await discoverCronJobs()
const { jobs, invalid } = await discoverCronJobs()
setJobs(jobs)
console.log(`[cron] Discovered ${jobs.length} jobs`)
setInvalidJobs(invalid)
console.log(`[cron] Discovered ${jobs.length} jobs, ${invalid.length} invalid`)
for (const job of jobs) {
scheduleJob(job, broadcast)
console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
}
for (const job of invalid) {
console.log(`[cron] Invalid ${job.id}: ${job.error}`)
}
}
// Watch for cron file changes
let debounceTimer: Timer | null = null
async function rediscover() {
const jobs = await discoverCronJobs()
const { jobs, invalid } = await discoverCronJobs()
const existing = getAllJobs()
// Stop removed jobs
@ -407,6 +443,7 @@ async function rediscover() {
}
setJobs(jobs)
setInvalidJobs(invalid)
}
watch(APPS_DIR, { recursive: true }, (_event, filename) => {

View File

@ -1,7 +1,7 @@
import { readdir, readFile } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { isValidSchedule, toCronExpr, type CronJob, type Schedule } from './schedules'
import { isValidSchedule, toCronExpr, type CronJob, type InvalidJob, type Schedule } from './schedules'
const APPS_DIR = process.env.APPS_DIR!
@ -22,8 +22,14 @@ export async function getApps(): Promise<string[]> {
return apps.sort()
}
export async function discoverCronJobs(): Promise<CronJob[]> {
export type DiscoveryResult = {
jobs: CronJob[]
invalid: InvalidJob[]
}
export async function discoverCronJobs(): Promise<DiscoveryResult> {
const jobs: CronJob[] = []
const invalid: InvalidJob[] = []
const apps = await readdir(APPS_DIR, { withFileTypes: true })
for (const app of apps) {
@ -38,25 +44,26 @@ export async function discoverCronJobs(): Promise<CronJob[]> {
const filePath = join(cronDir, file)
const name = file.replace(/\.ts$/, '')
const id = `${app.name}:${name}`
try {
const source = await readFile(filePath, 'utf-8')
const match = source.match(SCHEDULE_RE)
if (!match) {
console.error(`No schedule export found in ${filePath}`)
invalid.push({ id, app: app.name, name, file: filePath, error: 'Missing schedule export' })
continue
}
const schedule = match[1] as Schedule
if (!isValidSchedule(schedule)) {
console.error(`Invalid schedule in ${filePath}: ${schedule}`)
invalid.push({ id, app: app.name, name, file: filePath, error: `Invalid schedule: "${match[1]}"` })
continue
}
jobs.push({
id: `${app.name}:${name}`,
id,
app: app.name,
name,
file: filePath,
@ -65,10 +72,11 @@ export async function discoverCronJobs(): Promise<CronJob[]> {
state: 'idle',
})
} catch (e) {
console.error(`Failed to load cron file ${filePath}:`, e)
const msg = e instanceof Error ? e.message : String(e)
invalid.push({ id, app: app.name, name, file: filePath, error: msg })
}
}
}
return jobs
return { jobs, invalid }
}

View File

@ -20,6 +20,14 @@ export type CronJob = {
nextRun?: number
}
export type InvalidJob = {
id: string
app: string
name: string
file: string
error: string
}
export const SCHEDULES = [
'1 minute',
'5 minutes',

View File

@ -1,8 +1,10 @@
import type { CronJob } from './schedules'
import type { CronJob, InvalidJob } from './schedules'
const jobs = new Map<string, CronJob>()
const listeners = new Set<() => void>()
let invalidJobs: InvalidJob[] = []
export function setJobs(newJobs: CronJob[]) {
jobs.clear()
for (const job of newJobs) {
@ -11,6 +13,10 @@ export function setJobs(newJobs: CronJob[]) {
broadcast()
}
export function setInvalidJobs(newInvalid: InvalidJob[]) {
invalidJobs = newInvalid
}
export function getJob(id: string): CronJob | undefined {
return jobs.get(id)
}
@ -19,6 +25,10 @@ export function getAllJobs(): CronJob[] {
return Array.from(jobs.values())
}
export function getInvalidJobs(): InvalidJob[] {
return invalidJobs
}
export function broadcast() {
listeners.forEach(cb => cb())
}