stream cron output

This commit is contained in:
Chris Wanstrath 2026-02-09 21:13:05 -08:00
parent 081c728d12
commit 47030d7d36
2 changed files with 44 additions and 17 deletions

View File

@ -438,9 +438,11 @@ app.get('/job/:app/:name', async c => {
<Layout title={`${job.app}/${job.name}`} refresh={job.state === 'running'}> <Layout title={`${job.app}/${job.name}`} refresh={job.state === 'running'}>
<BackLink href={backUrl}>&#8592; Back</BackLink> <BackLink href={backUrl}>&#8592; Back</BackLink>
<DetailHeader> <DetailHeader>
<StatusDot style={{ backgroundColor: statusColor(job) }} /> <div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
<DetailTitle>{job.app}/{job.name}</DetailTitle> <StatusDot style={{ backgroundColor: statusColor(job) }} />
<StatusBadge style={statusBadgeStyle(job)}>{statusLabel(job)}</StatusBadge> <DetailTitle>{job.app}/{job.name}</DetailTitle>
<StatusBadge style={statusBadgeStyle(job)}>{statusLabel(job)}</StatusBadge>
</div>
<form method="post" action={`/run/${job.app}/${job.name}?return=detail&app=${appFilter || ''}`}> <form method="post" action={`/run/${job.app}/${job.name}?return=detail&app=${appFilter || ''}`}>
<RunButton type="submit" disabled={job.state === 'running'}> <RunButton type="submit" disabled={job.state === 'running'}>
{job.state === 'running' ? 'Running...' : 'Run Now'} {job.state === 'running' ? 'Running...' : 'Run Now'}
@ -449,8 +451,8 @@ app.get('/job/:app/:name', async c => {
</DetailHeader> </DetailHeader>
<DetailMeta> <DetailMeta>
<MetaItem><MetaLabel>Schedule</MetaLabel> {job.schedule}</MetaItem> <MetaItem><MetaLabel>Schedule</MetaLabel> {job.schedule}</MetaItem>
<MetaItem><MetaLabel>Last run</MetaLabel> {formatRelative(job.lastRun)}</MetaItem> <MetaItem><MetaLabel>Last run</MetaLabel> {job.state === 'running' ? 'now' : formatRelative(job.lastRun)}</MetaItem>
<MetaItem><MetaLabel>Duration</MetaLabel> {formatDuration(job.lastDuration)}</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> <MetaItem><MetaLabel>Next run</MetaLabel> {formatRelative(job.nextRun)}</MetaItem>
</DetailMeta> </DetailMeta>
{job.lastError && ( {job.lastError && (
@ -459,17 +461,21 @@ app.get('/job/:app/:name', async c => {
<ErrorBlock>{job.lastError}</ErrorBlock> <ErrorBlock>{job.lastError}</ErrorBlock>
</OutputSection> </OutputSection>
)} )}
{job.lastOutput && ( {job.lastOutput ? (
<OutputSection> <OutputSection>
<OutputLabel>Output</OutputLabel> <OutputLabel>Output</OutputLabel>
<OutputBlock>{job.lastOutput}</OutputBlock> <OutputBlock>{job.lastOutput}</OutputBlock>
</OutputSection> </OutputSection>
)} ) : job.state === 'running' ? (
{!job.lastError && !job.lastOutput && job.lastRun && ( <OutputSection>
<OutputLabel>Output</OutputLabel>
<OutputBlock style={{ color: theme('colors-textMuted') }}>Waiting for output...</OutputBlock>
</OutputSection>
) : job.lastRun && !job.lastError ? (
<OutputSection> <OutputSection>
<EmptyState>No output</EmptyState> <EmptyState>No output</EmptyState>
</OutputSection> </OutputSection>
)} ) : null}
</Layout> </Layout>
) )
}) })

View File

@ -3,13 +3,28 @@ import type { CronJob } from './schedules'
import { getNextRun } from './scheduler' import { getNextRun } from './scheduler'
const APPS_DIR = process.env.APPS_DIR! const APPS_DIR = process.env.APPS_DIR!
const TOES_DIR = process.env.TOES_DIR!
const RUNNER = join(import.meta.dir, 'runner.ts') const RUNNER = join(import.meta.dir, 'runner.ts')
async function readStream(stream: ReadableStream<Uint8Array>, append: (text: string) => void) {
const reader = stream.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
append(decoder.decode(value, { stream: true }))
}
}
export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> { export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> {
if (job.state === 'disabled') return if (job.state === 'disabled') return
job.state = 'running' job.state = 'running'
job.lastRun = Date.now() job.lastRun = Date.now()
job.lastOutput = undefined
job.lastError = undefined
job.lastExitCode = undefined
job.lastDuration = undefined
onUpdate() onUpdate()
const cwd = join(APPS_DIR, job.app, 'current') const cwd = join(APPS_DIR, job.app, 'current')
@ -17,29 +32,35 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
try { try {
const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], { const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], {
cwd, cwd,
env: { ...process.env }, env: { ...process.env, DATA_DIR: join(TOES_DIR, job.app) },
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',
}) })
const [stdout, stderr] = await Promise.all([ // Stream output incrementally into job fields
new Response(proc.stdout).text(), await Promise.all([
new Response(proc.stderr).text(), readStream(proc.stdout as ReadableStream<Uint8Array>, text => {
job.lastOutput = (job.lastOutput || '') + text
}),
readStream(proc.stderr as ReadableStream<Uint8Array>, text => {
job.lastError = (job.lastError || '') + text
}),
]) ])
const code = await proc.exited const code = await proc.exited
job.lastDuration = Date.now() - job.lastRun job.lastDuration = Date.now() - job.lastRun
job.lastExitCode = code job.lastExitCode = code
job.lastError = code !== 0 ? stderr || 'Non-zero exit' : undefined if (!job.lastError && code !== 0) job.lastError = 'Non-zero exit'
job.lastOutput = stdout || undefined if (code === 0) job.lastError = undefined
if (!job.lastOutput) job.lastOutput = undefined
job.state = 'idle' job.state = 'idle'
job.nextRun = getNextRun(job.id) job.nextRun = getNextRun(job.id)
// Log result // Log result
console.log(`[cron] ${job.id} finished: code=${code} duration=${job.lastDuration}ms`) console.log(`[cron] ${job.id} finished: code=${code} duration=${job.lastDuration}ms`)
if (stdout) console.log(stdout) if (job.lastOutput) console.log(job.lastOutput)
if (stderr) console.error(stderr) if (job.lastError) console.error(job.lastError)
} catch (e) { } catch (e) {
job.lastDuration = Date.now() - job.lastRun job.lastDuration = Date.now() - job.lastRun
job.lastExitCode = 1 job.lastExitCode = 1