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 { 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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user