From 1685cc135df416f5107a7f817f5477946f505118 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Mon, 9 Feb 2026 20:36:46 -0800 Subject: [PATCH] show cron errors --- apps/cron/20260201-000000/index.tsx | 192 ++++++++++++++++++++- apps/cron/20260201-000000/lib/executor.ts | 4 +- apps/cron/20260201-000000/lib/runner.ts | 16 ++ apps/cron/20260201-000000/lib/schedules.ts | 1 + src/cli/commands/sync.ts | 25 ++- src/server/api/sync.ts | 5 +- 6 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 apps/cron/20260201-000000/lib/runner.ts diff --git a/apps/cron/20260201-000000/index.tsx b/apps/cron/20260201-000000/index.tsx index f8640b8..344b452 100644 --- a/apps/cron/20260201-000000/index.tsx +++ b/apps/cron/20260201-000000/index.tsx @@ -190,13 +190,111 @@ const CancelButton = define('CancelButton', { }, }) +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 }: { title: string; children: Child }) { +function Layout({ title, children, refresh }: { title: string; children: Child; refresh?: boolean }) { return ( + {refresh && } {title} @@ -255,9 +353,10 @@ app.get('/', async c => { 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( - + New Job @@ -272,7 +371,11 @@ app.get('/', async c => { {jobs.map(job => ( - {job.app}/{job.name} + + + {job.app}/{job.name} + + {job.schedule} @@ -296,6 +399,81 @@ app.get('/', async c => { ) }) +function statusBadgeStyle(job: CronJob): Record { + 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 `${(ms / 1000).toFixed(1)}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( + + ← Back + Job not found: {id} + + ) + } + + return c.html( + + ← Back + + + {job.app}/{job.name} + {statusLabel(job)} +
+ + {job.state === 'running' ? 'Running...' : 'Run Now'} + +
+
+ + Schedule {job.schedule} + Last run {formatRelative(job.lastRun)} + Duration {formatDuration(job.lastDuration)} + Next run {formatRelative(job.nextRun)} + + {job.lastError && ( + + Error + {job.lastError} + + )} + {job.lastOutput && ( + + Output + {job.lastOutput} + + )} + {!job.lastError && !job.lastOutput && job.lastRun && ( + + No output + + )} +
+ ) +}) + app.get('/new', async c => { const appName = c.req.query('app') || '' @@ -387,9 +565,14 @@ app.post('/run/:app/:name', async c => { return c.redirect('/?error=not-found') } - await executeJob(job, broadcast) + // 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}` : '/') }) @@ -438,6 +621,7 @@ async function rediscover() { job.lastDuration = old.lastDuration job.lastExitCode = old.lastExitCode job.lastError = old.lastError + job.lastOutput = old.lastOutput job.nextRun = old.nextRun } } diff --git a/apps/cron/20260201-000000/lib/executor.ts b/apps/cron/20260201-000000/lib/executor.ts index 83092db..dfae391 100644 --- a/apps/cron/20260201-000000/lib/executor.ts +++ b/apps/cron/20260201-000000/lib/executor.ts @@ -3,6 +3,7 @@ import type { CronJob } from './schedules' import { getNextRun } from './scheduler' const APPS_DIR = process.env.APPS_DIR! +const RUNNER = join(import.meta.dir, 'runner.ts') export async function executeJob(job: CronJob, onUpdate: () => void): Promise { if (job.state === 'disabled') return @@ -14,7 +15,7 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise void): Promise 0) { + console.log(`Deleting ${toDelete.length} files...`) + for (const file of toDelete) { + const success = await del(`/api/sync/apps/${appName}/files/${file}?version=${version}`) + if (success) { + console.log(` ${color.red('✗')} ${file}`) + } else { + console.log(` ${color.red('✗')} ${file} (failed)`) + } + } + } + + // 4. Activate new version (updates symlink and restarts app) type ActivateResponse = { ok: boolean } const activateRes = await post(`/api/sync/apps/${appName}/activate?version=${version}`) if (!activateRes?.ok) { diff --git a/src/server/api/sync.ts b/src/server/api/sync.ts index bb58536..8c74045 100644 --- a/src/server/api/sync.ts +++ b/src/server/api/sync.ts @@ -126,10 +126,13 @@ router.delete('/apps/:app', c => { router.delete('/apps/:app/files/:path{.+}', c => { const appName = c.req.param('app') const filePath = c.req.param('path') + const version = c.req.query('version') if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) - const basePath = join(APPS_DIR, appName, 'current') + const basePath = version + ? join(APPS_DIR, appName, version) + : join(APPS_DIR, appName, 'current') const fullPath = safePath(basePath, filePath) if (!fullPath) return c.json({ error: 'Invalid path' }, 400)