show invalid cron jobs w/ feedback
This commit is contained in:
parent
4d3083764a
commit
f96c599f49
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user