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

View File

@ -1,7 +1,7 @@
import { readdir, readFile } from 'fs/promises' import { readdir, readFile } from 'fs/promises'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { join } from 'path' 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! const APPS_DIR = process.env.APPS_DIR!
@ -22,8 +22,14 @@ export async function getApps(): Promise<string[]> {
return apps.sort() 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 jobs: CronJob[] = []
const invalid: InvalidJob[] = []
const apps = await readdir(APPS_DIR, { withFileTypes: true }) const apps = await readdir(APPS_DIR, { withFileTypes: true })
for (const app of apps) { for (const app of apps) {
@ -38,25 +44,26 @@ export async function discoverCronJobs(): Promise<CronJob[]> {
const filePath = join(cronDir, file) const filePath = join(cronDir, file)
const name = file.replace(/\.ts$/, '') const name = file.replace(/\.ts$/, '')
const id = `${app.name}:${name}`
try { try {
const source = await readFile(filePath, 'utf-8') const source = await readFile(filePath, 'utf-8')
const match = source.match(SCHEDULE_RE) const match = source.match(SCHEDULE_RE)
if (!match) { if (!match) {
console.error(`No schedule export found in ${filePath}`) invalid.push({ id, app: app.name, name, file: filePath, error: 'Missing schedule export' })
continue continue
} }
const schedule = match[1] as Schedule const schedule = match[1] as Schedule
if (!isValidSchedule(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 continue
} }
jobs.push({ jobs.push({
id: `${app.name}:${name}`, id,
app: app.name, app: app.name,
name, name,
file: filePath, file: filePath,
@ -65,10 +72,11 @@ export async function discoverCronJobs(): Promise<CronJob[]> {
state: 'idle', state: 'idle',
}) })
} catch (e) { } 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 nextRun?: number
} }
export type InvalidJob = {
id: string
app: string
name: string
file: string
error: string
}
export const SCHEDULES = [ export const SCHEDULES = [
'1 minute', '1 minute',
'5 minutes', '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 jobs = new Map<string, CronJob>()
const listeners = new Set<() => void>() const listeners = new Set<() => void>()
let invalidJobs: InvalidJob[] = []
export function setJobs(newJobs: CronJob[]) { export function setJobs(newJobs: CronJob[]) {
jobs.clear() jobs.clear()
for (const job of newJobs) { for (const job of newJobs) {
@ -11,6 +13,10 @@ export function setJobs(newJobs: CronJob[]) {
broadcast() broadcast()
} }
export function setInvalidJobs(newInvalid: InvalidJob[]) {
invalidJobs = newInvalid
}
export function getJob(id: string): CronJob | undefined { export function getJob(id: string): CronJob | undefined {
return jobs.get(id) return jobs.get(id)
} }
@ -19,6 +25,10 @@ export function getAllJobs(): CronJob[] {
return Array.from(jobs.values()) return Array.from(jobs.values())
} }
export function getInvalidJobs(): InvalidJob[] {
return invalidJobs
}
export function broadcast() { export function broadcast() {
listeners.forEach(cb => cb()) listeners.forEach(cb => cb())
} }