697 lines
19 KiB
TypeScript
697 lines
19 KiB
TypeScript
import { Hype } from '@because/hype'
|
|
import { define, stylesToCSS } from '@because/forge'
|
|
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, 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'
|
|
import { existsSync, watch } from 'fs'
|
|
|
|
const APPS_DIR = process.env.APPS_DIR!
|
|
|
|
const app = new Hype({ prettyHTML: false })
|
|
|
|
// Styles (follow versions tool pattern)
|
|
const Container = define('Container', {
|
|
fontFamily: theme('fonts-sans'),
|
|
padding: '20px',
|
|
paddingTop: 0,
|
|
maxWidth: '900px',
|
|
margin: '0 auto',
|
|
color: theme('colors-text'),
|
|
})
|
|
|
|
const JobList = define('JobList', {
|
|
listStyle: 'none',
|
|
padding: 0,
|
|
margin: '20px 0',
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
overflow: 'hidden',
|
|
})
|
|
|
|
const JobItem = define('JobItem', {
|
|
padding: '12px 15px',
|
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '15px',
|
|
states: {
|
|
':last-child': { borderBottom: 'none' },
|
|
':hover': { backgroundColor: theme('colors-bgHover') },
|
|
},
|
|
})
|
|
|
|
const StatusDot = define('StatusDot', {
|
|
width: '10px',
|
|
height: '10px',
|
|
borderRadius: '50%',
|
|
flexShrink: 0,
|
|
})
|
|
|
|
const JobName = define('JobName', {
|
|
fontFamily: theme('fonts-mono'),
|
|
fontSize: '14px',
|
|
flex: 1,
|
|
})
|
|
|
|
const Schedule = define('Schedule', {
|
|
fontSize: '13px',
|
|
color: theme('colors-textMuted'),
|
|
minWidth: '80px',
|
|
})
|
|
|
|
const Time = define('Time', {
|
|
fontSize: '12px',
|
|
color: theme('colors-textMuted'),
|
|
minWidth: '100px',
|
|
})
|
|
|
|
const RunButton = define('RunButton', {
|
|
base: 'button',
|
|
padding: '4px 10px',
|
|
marginTop: '10px',
|
|
fontSize: '12px',
|
|
backgroundColor: theme('colors-primary'),
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: theme('radius-md'),
|
|
cursor: 'pointer',
|
|
states: {
|
|
':hover': { opacity: 0.9 },
|
|
':disabled': { opacity: 0.5, cursor: 'not-allowed' },
|
|
},
|
|
})
|
|
|
|
const EmptyState = define('EmptyState', {
|
|
padding: '40px 20px',
|
|
textAlign: 'center',
|
|
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',
|
|
marginBottom: '15px',
|
|
})
|
|
|
|
const NewButton = define('NewButton', {
|
|
base: 'a',
|
|
padding: '8px 16px',
|
|
fontSize: '14px',
|
|
backgroundColor: theme('colors-primary'),
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: theme('radius-md'),
|
|
cursor: 'pointer',
|
|
textDecoration: 'none',
|
|
display: 'inline-block',
|
|
states: {
|
|
':hover': { opacity: 0.9 },
|
|
},
|
|
})
|
|
|
|
const buttonStyles = {
|
|
padding: '8px 16px',
|
|
fontSize: '14px',
|
|
backgroundColor: theme('colors-primary'),
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: theme('radius-md'),
|
|
cursor: 'pointer',
|
|
}
|
|
|
|
const Form = define('Form', {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '16px',
|
|
maxWidth: '400px',
|
|
})
|
|
|
|
const FormGroup = define('FormGroup', {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '6px',
|
|
})
|
|
|
|
const Label = define('Label', {
|
|
fontSize: '14px',
|
|
fontWeight: 500,
|
|
})
|
|
|
|
const inputStyles = {
|
|
padding: '8px 12px',
|
|
fontSize: '14px',
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
backgroundColor: theme('colors-bg'),
|
|
color: theme('colors-text'),
|
|
}
|
|
|
|
const ButtonRow = define('ButtonRow', {
|
|
display: 'flex',
|
|
gap: '10px',
|
|
marginTop: '10px',
|
|
})
|
|
|
|
const CancelButton = define('CancelButton', {
|
|
base: 'a',
|
|
padding: '8px 16px',
|
|
fontSize: '14px',
|
|
backgroundColor: 'transparent',
|
|
color: theme('colors-text'),
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
cursor: 'pointer',
|
|
textDecoration: 'none',
|
|
states: {
|
|
':hover': { backgroundColor: theme('colors-bgHover') },
|
|
},
|
|
})
|
|
|
|
const BackLink = define('BackLink', {
|
|
base: 'a',
|
|
fontSize: '13px',
|
|
color: theme('colors-textMuted'),
|
|
textDecoration: 'none',
|
|
states: {
|
|
':hover': { color: theme('colors-text') },
|
|
},
|
|
})
|
|
|
|
const DetailHeader = define('DetailHeader', {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '12px',
|
|
marginBottom: '20px',
|
|
})
|
|
|
|
const DetailTitle = define('DetailTitle', {
|
|
base: 'h1',
|
|
fontFamily: theme('fonts-mono'),
|
|
fontSize: '18px',
|
|
fontWeight: 600,
|
|
margin: 0,
|
|
flex: 1,
|
|
})
|
|
|
|
const DetailMeta = define('DetailMeta', {
|
|
display: 'flex',
|
|
gap: '20px',
|
|
marginBottom: '20px',
|
|
fontSize: '13px',
|
|
color: theme('colors-textMuted'),
|
|
})
|
|
|
|
const MetaItem = define('MetaItem', {
|
|
display: 'flex',
|
|
gap: '6px',
|
|
})
|
|
|
|
const MetaLabel = define('MetaLabel', {
|
|
fontWeight: 500,
|
|
color: theme('colors-text'),
|
|
})
|
|
|
|
const OutputSection = define('OutputSection', {
|
|
marginTop: '20px',
|
|
})
|
|
|
|
const OutputLabel = define('OutputLabel', {
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
marginBottom: '8px',
|
|
})
|
|
|
|
const OutputBlock = define('OutputBlock', {
|
|
base: 'pre',
|
|
fontFamily: theme('fonts-mono'),
|
|
fontSize: '12px',
|
|
lineHeight: 1.5,
|
|
padding: '12px',
|
|
backgroundColor: theme('colors-bgElement'),
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
overflowX: 'auto',
|
|
overflowY: 'auto',
|
|
maxHeight: '60vh',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
margin: 0,
|
|
})
|
|
|
|
const ErrorBlock = define('ErrorBlock', {
|
|
base: 'pre',
|
|
fontFamily: theme('fonts-mono'),
|
|
fontSize: '12px',
|
|
lineHeight: 1.5,
|
|
padding: '12px',
|
|
backgroundColor: theme('colors-bgElement'),
|
|
border: `1px solid ${theme('colors-error')}`,
|
|
borderRadius: theme('radius-md'),
|
|
color: theme('colors-error'),
|
|
overflowX: 'auto',
|
|
overflowY: 'auto',
|
|
maxHeight: '60vh',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
margin: 0,
|
|
})
|
|
|
|
const StatusBadge = define('StatusBadge', {
|
|
base: 'span',
|
|
fontSize: '12px',
|
|
padding: '2px 8px',
|
|
borderRadius: '9999px',
|
|
fontWeight: 500,
|
|
})
|
|
|
|
// Layout
|
|
function Layout({ title, children, refresh }: { title: string; children: Child; refresh?: boolean }) {
|
|
return (
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
{refresh && <meta http-equiv="refresh" content="2" />}
|
|
<title>{title}</title>
|
|
<link rel="stylesheet" href="/styles.css" />
|
|
</head>
|
|
<body>
|
|
<ToolScript />
|
|
<Container>
|
|
{children}
|
|
</Container>
|
|
</body>
|
|
</html>
|
|
)
|
|
}
|
|
|
|
function formatRelative(ts?: number): string {
|
|
if (!ts) return '-'
|
|
const diff = Date.now() - ts
|
|
if (diff < 0) {
|
|
const mins = Math.round(-diff / 60000)
|
|
if (mins < 60) return `in ${mins}m`
|
|
const hours = Math.round(mins / 60)
|
|
if (hours < 24) return `in ${hours}h`
|
|
return `in ${Math.round(hours / 24)}d`
|
|
}
|
|
const mins = Math.round(diff / 60000)
|
|
if (mins < 60) return `${mins}m ago`
|
|
const hours = Math.round(mins / 60)
|
|
if (hours < 24) return `${hours}h ago`
|
|
return `${Math.round(hours / 24)}d ago`
|
|
}
|
|
|
|
function statusColor(job: CronJob): string {
|
|
if (job.state === 'running') return theme('colors-statusRunning')
|
|
if (job.state === 'disabled') return theme('colors-textMuted')
|
|
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return theme('colors-error')
|
|
return theme('colors-statusRunning')
|
|
}
|
|
|
|
// Routes
|
|
app.get('/ok', c => c.text('ok'))
|
|
|
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
|
'Content-Type': 'text/css; charset=utf-8',
|
|
}))
|
|
|
|
// JSON API
|
|
app.get('/api/jobs', c => {
|
|
const appFilter = c.req.query('app')
|
|
let jobs = getAllJobs()
|
|
if (appFilter) jobs = jobs.filter(j => j.app === appFilter)
|
|
jobs.sort((a, b) => a.id.localeCompare(b.id))
|
|
return c.json(jobs.map(j => ({
|
|
app: j.app,
|
|
name: j.name,
|
|
schedule: j.schedule,
|
|
state: j.state,
|
|
status: statusLabel(j),
|
|
lastRun: j.lastRun,
|
|
lastDuration: j.lastDuration,
|
|
lastExitCode: j.lastExitCode,
|
|
nextRun: j.nextRun,
|
|
})))
|
|
})
|
|
|
|
app.get('/api/jobs/:app/:name', c => {
|
|
const id = `${c.req.param('app')}:${c.req.param('name')}`
|
|
const job = getJob(id)
|
|
if (!job) return c.json({ error: 'Job not found' }, 404)
|
|
return c.json({
|
|
app: job.app,
|
|
name: job.name,
|
|
schedule: job.schedule,
|
|
state: job.state,
|
|
status: statusLabel(job),
|
|
lastRun: job.lastRun,
|
|
lastDuration: job.lastDuration,
|
|
lastExitCode: job.lastExitCode,
|
|
lastError: job.lastError,
|
|
lastOutput: job.lastOutput,
|
|
nextRun: job.nextRun,
|
|
})
|
|
})
|
|
|
|
app.post('/api/jobs/:app/:name/run', async c => {
|
|
const id = `${c.req.param('app')}:${c.req.param('name')}`
|
|
const job = getJob(id)
|
|
if (!job) return c.json({ error: 'Job not found' }, 404)
|
|
if (job.state === 'running') return c.json({ error: 'Job is already running' }, 409)
|
|
executeJob(job, broadcast)
|
|
return c.json({ ok: true, message: `Started ${id}` })
|
|
})
|
|
|
|
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
|
|
const anyRunning = jobs.some(j => j.state === 'running')
|
|
|
|
return c.html(
|
|
<Layout title="Cron Jobs" refresh={anyRunning}>
|
|
<ActionRow>
|
|
<NewButton href={`/new?app=${appFilter || ''}`}>New Job</NewButton>
|
|
</ActionRow>
|
|
{!hasAny ? (
|
|
<EmptyState>
|
|
No cron jobs found.
|
|
<br />
|
|
Create a cron/*.ts file in any app to get started.
|
|
</EmptyState>
|
|
) : (
|
|
<JobList>
|
|
{jobs.map(job => (
|
|
<JobItem>
|
|
<StatusDot style={{ backgroundColor: statusColor(job) }} />
|
|
<JobName>
|
|
<a href={`/job/${job.app}/${job.name}${appFilter ? `?app=${appFilter}` : ''}`} style={{ color: 'inherit', textDecoration: 'none' }}>
|
|
{job.app}/{job.name}
|
|
</a>
|
|
</JobName>
|
|
<Schedule>{job.schedule}</Schedule>
|
|
<Time title="Last run">{formatRelative(job.lastRun)}</Time>
|
|
<Time title="Next run">{formatRelative(job.nextRun)}</Time>
|
|
<form method="post" action={`/run/${job.app}/${job.name}`}>
|
|
<RunButton type="submit" disabled={job.state === 'running'}>
|
|
{job.state === 'running' ? 'Running...' : 'Run Now'}
|
|
</RunButton>
|
|
</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>
|
|
)
|
|
})
|
|
|
|
function statusBadgeStyle(job: CronJob): Record<string, string> {
|
|
if (job.state === 'running') return { backgroundColor: theme('colors-statusRunning'), color: 'white' }
|
|
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return { backgroundColor: theme('colors-error'), color: 'white' }
|
|
return { backgroundColor: theme('colors-bgElement'), color: theme('colors-textMuted') }
|
|
}
|
|
|
|
function statusLabel(job: CronJob): string {
|
|
if (job.state === 'running') return 'running'
|
|
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return `exit ${job.lastExitCode}`
|
|
if (job.lastRun) return 'ok'
|
|
return 'idle'
|
|
}
|
|
|
|
function formatDuration(ms?: number): string {
|
|
if (!ms) return '-'
|
|
if (ms < 1000) return `${ms}ms`
|
|
if (ms < 60000) return `${Math.round(ms / 1000)}s`
|
|
return `${Math.round(ms / 60000)}m`
|
|
}
|
|
|
|
app.get('/job/:app/:name', async c => {
|
|
const id = `${c.req.param('app')}:${c.req.param('name')}`
|
|
const job = getJob(id)
|
|
const appFilter = c.req.query('app')
|
|
const backUrl = appFilter ? `/?app=${appFilter}` : '/'
|
|
|
|
if (!job) {
|
|
return c.html(
|
|
<Layout title="Job Not Found">
|
|
<BackLink href={backUrl}>← Back</BackLink>
|
|
<EmptyState>Job not found: {id}</EmptyState>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
return c.html(
|
|
<Layout title={`${job.app}/${job.name}`} refresh={job.state === 'running'}>
|
|
<BackLink href={backUrl}>← Back</BackLink>
|
|
<DetailHeader>
|
|
<StatusDot style={{ backgroundColor: statusColor(job) }} />
|
|
<DetailTitle>{job.app}/{job.name}</DetailTitle>
|
|
<StatusBadge style={statusBadgeStyle(job)}>{statusLabel(job)}</StatusBadge>
|
|
<form method="post" action={`/run/${job.app}/${job.name}?return=detail&app=${appFilter || ''}`}>
|
|
<RunButton type="submit" disabled={job.state === 'running'}>
|
|
{job.state === 'running' ? 'Running...' : 'Run Now'}
|
|
</RunButton>
|
|
</form>
|
|
</DetailHeader>
|
|
<DetailMeta>
|
|
<MetaItem><MetaLabel>Schedule</MetaLabel> {job.schedule}</MetaItem>
|
|
<MetaItem><MetaLabel>Last run</MetaLabel> {job.state === 'running' ? 'now' : formatRelative(job.lastRun)}</MetaItem>
|
|
<MetaItem><MetaLabel>Duration</MetaLabel> {job.state === 'running' ? formatDuration(Date.now() - job.lastRun!) : formatDuration(job.lastDuration)}</MetaItem>
|
|
<MetaItem><MetaLabel>Next run</MetaLabel> {formatRelative(job.nextRun)}</MetaItem>
|
|
</DetailMeta>
|
|
{job.lastError && (
|
|
<OutputSection>
|
|
<OutputLabel>Error</OutputLabel>
|
|
<ErrorBlock>{job.lastError}</ErrorBlock>
|
|
</OutputSection>
|
|
)}
|
|
{job.lastOutput ? (
|
|
<OutputSection>
|
|
<OutputLabel>Output</OutputLabel>
|
|
<OutputBlock id="output">{job.lastOutput}</OutputBlock>
|
|
</OutputSection>
|
|
) : job.state === 'running' ? (
|
|
<OutputSection>
|
|
<OutputLabel>Output</OutputLabel>
|
|
<OutputBlock id="output" style={{ color: theme('colors-textMuted') }}>Waiting for output...</OutputBlock>
|
|
</OutputSection>
|
|
) : job.lastRun && !job.lastError ? (
|
|
<OutputSection>
|
|
<EmptyState>No output</EmptyState>
|
|
</OutputSection>
|
|
) : null}
|
|
<script dangerouslySetInnerHTML={{ __html: `var o=document.getElementById('output');if(o)o.scrollTop=o.scrollHeight` }} />
|
|
</Layout>
|
|
)
|
|
})
|
|
|
|
app.get('/new', async c => {
|
|
const appName = c.req.query('app') || ''
|
|
|
|
return c.html(
|
|
<Layout title="New Cron Job">
|
|
<form method="post" action="/new">
|
|
<Form>
|
|
<input type="hidden" name="app" value={appName} />
|
|
<FormGroup>
|
|
<Label>Job Name</Label>
|
|
<input name="name" placeholder="my-job" required pattern="[a-z0-9\-]+" title="lowercase letters, numbers, and hyphens only" style={inputStyles} />
|
|
</FormGroup>
|
|
<FormGroup>
|
|
<Label>Run every</Label>
|
|
<select name="schedule" style={inputStyles}>
|
|
{SCHEDULES.map(s => (
|
|
<option value={s} selected={s === 'day'}>{s}</option>
|
|
))}
|
|
</select>
|
|
</FormGroup>
|
|
<ButtonRow>
|
|
<CancelButton href="/">Cancel</CancelButton>
|
|
<button type="submit" style={buttonStyles}>Create Job</button>
|
|
</ButtonRow>
|
|
</Form>
|
|
</form>
|
|
</Layout>
|
|
)
|
|
})
|
|
|
|
app.post('/new', async c => {
|
|
const body = await c.req.parseBody()
|
|
const appName = body.app as string
|
|
const name = body.name as string
|
|
const schedule = body.schedule as string
|
|
|
|
if (!appName || !name || !schedule) {
|
|
return c.redirect('/new?error=missing-fields')
|
|
}
|
|
|
|
// Validate name (lowercase, numbers, hyphens)
|
|
if (!/^[a-z0-9-]+$/.test(name)) {
|
|
return c.redirect('/new?error=invalid-name')
|
|
}
|
|
|
|
const cronDir = join(APPS_DIR, appName, 'current', 'cron')
|
|
const filePath = join(cronDir, `${name}.ts`)
|
|
|
|
// Check if file already exists
|
|
if (existsSync(filePath)) {
|
|
return c.redirect('/new?error=already-exists')
|
|
}
|
|
|
|
// Create cron directory if needed
|
|
if (!existsSync(cronDir)) {
|
|
await mkdir(cronDir, { recursive: true })
|
|
}
|
|
|
|
// Write the cron file
|
|
const content = `export const schedule = "${schedule}"
|
|
|
|
export default async function() {
|
|
console.log("${appName}/${name} executed at", new Date().toISOString())
|
|
}
|
|
`
|
|
|
|
await writeFile(filePath, content)
|
|
console.log(`[cron] Created ${appName}:${name}`)
|
|
|
|
// Trigger rediscovery
|
|
const { jobs, invalid } = await discoverCronJobs()
|
|
setJobs(jobs)
|
|
setInvalidJobs(invalid)
|
|
for (const job of jobs) {
|
|
if (job.id === `${appName}:${name}`) {
|
|
scheduleJob(job, broadcast)
|
|
console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
|
|
}
|
|
}
|
|
|
|
return c.redirect('/')
|
|
})
|
|
|
|
app.post('/run/:app/:name', async c => {
|
|
const id = `${c.req.param('app')}:${c.req.param('name')}`
|
|
const job = getJob(id)
|
|
|
|
if (!job) {
|
|
return c.redirect('/?error=not-found')
|
|
}
|
|
|
|
// Fire-and-forget so the redirect happens immediately
|
|
executeJob(job, broadcast)
|
|
|
|
const returnTo = c.req.query('return')
|
|
const appFilter = c.req.query('app')
|
|
if (returnTo === 'detail') {
|
|
return c.redirect(`/job/${job.app}/${job.name}${appFilter ? `?app=${appFilter}` : ''}`)
|
|
}
|
|
return c.redirect(appFilter ? `/?app=${appFilter}` : '/')
|
|
})
|
|
|
|
// Initialize
|
|
async function init() {
|
|
const { jobs, invalid } = await discoverCronJobs()
|
|
setJobs(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, invalid } = await discoverCronJobs()
|
|
const existing = getAllJobs()
|
|
|
|
// Stop removed jobs
|
|
for (const old of existing) {
|
|
if (!jobs.find(j => j.id === old.id)) {
|
|
stopJob(old.id)
|
|
console.log(`[cron] Removed ${old.id}`)
|
|
}
|
|
}
|
|
|
|
// Add/update jobs
|
|
for (const job of jobs) {
|
|
const old = existing.find(j => j.id === job.id)
|
|
if (!old || old.cronExpr !== job.cronExpr) {
|
|
scheduleJob(job, broadcast)
|
|
console.log(`[cron] Updated ${job.id}: ${job.schedule}`)
|
|
} else {
|
|
// Preserve runtime state
|
|
job.state = old.state
|
|
job.lastRun = old.lastRun
|
|
job.lastDuration = old.lastDuration
|
|
job.lastExitCode = old.lastExitCode
|
|
job.lastError = old.lastError
|
|
job.lastOutput = old.lastOutput
|
|
job.nextRun = old.nextRun
|
|
}
|
|
}
|
|
|
|
setJobs(jobs)
|
|
setInvalidJobs(invalid)
|
|
}
|
|
|
|
watch(APPS_DIR, { recursive: true }, (_event, filename) => {
|
|
if (!filename?.includes('/cron/') && !filename?.includes('\\cron\\')) return
|
|
if (!filename.endsWith('.ts')) return
|
|
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(rediscover, 100)
|
|
})
|
|
|
|
init()
|
|
|
|
export default app.defaults
|