show cron errors
This commit is contained in:
parent
d4e8975200
commit
1685cc135d
|
|
@ -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 (
|
||||
<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>
|
||||
|
|
@ -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(
|
||||
<Layout title="Cron Jobs">
|
||||
<Layout title="Cron Jobs" refresh={anyRunning}>
|
||||
<ActionRow>
|
||||
<NewButton href={`/new?app=${appFilter || ''}`}>New Job</NewButton>
|
||||
</ActionRow>
|
||||
|
|
@ -272,7 +371,11 @@ app.get('/', async c => {
|
|||
{jobs.map(job => (
|
||||
<JobItem>
|
||||
<StatusDot style={{ backgroundColor: statusColor(job) }} />
|
||||
<JobName>{job.app}/{job.name}</JobName>
|
||||
<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>
|
||||
|
|
@ -296,6 +399,81 @@ app.get('/', async c => {
|
|||
)
|
||||
})
|
||||
|
||||
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 `${(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(
|
||||
<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> {formatRelative(job.lastRun)}</MetaItem>
|
||||
<MetaItem><MetaLabel>Duration</MetaLabel> {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>{job.lastOutput}</OutputBlock>
|
||||
</OutputSection>
|
||||
)}
|
||||
{!job.lastError && !job.lastOutput && job.lastRun && (
|
||||
<OutputSection>
|
||||
<EmptyState>No output</EmptyState>
|
||||
</OutputSection>
|
||||
)}
|
||||
</Layout>
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
if (job.state === 'disabled') return
|
||||
|
|
@ -14,7 +15,7 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
|
|||
const cwd = join(APPS_DIR, job.app, 'current')
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(['bun', 'run', job.file], {
|
||||
const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], {
|
||||
cwd,
|
||||
env: { ...process.env },
|
||||
stdout: 'pipe',
|
||||
|
|
@ -31,6 +32,7 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
|
|||
job.lastDuration = Date.now() - job.lastRun
|
||||
job.lastExitCode = code
|
||||
job.lastError = code !== 0 ? stderr || 'Non-zero exit' : undefined
|
||||
job.lastOutput = stdout || undefined
|
||||
job.state = 'idle'
|
||||
job.nextRun = getNextRun(job.id)
|
||||
|
||||
|
|
|
|||
16
apps/cron/20260201-000000/lib/runner.ts
Normal file
16
apps/cron/20260201-000000/lib/runner.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export {}
|
||||
|
||||
Error.stackTraceLimit = 50
|
||||
|
||||
const file = process.argv[2]!
|
||||
const { default: fn } = await import(file)
|
||||
try {
|
||||
await fn()
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
console.error(e.stack || e.message)
|
||||
} else {
|
||||
console.error(e)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ export type CronJob = {
|
|||
lastDuration?: number
|
||||
lastExitCode?: number
|
||||
lastError?: string
|
||||
lastOutput?: string
|
||||
nextRun?: number
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,9 +98,15 @@ export async function pushApp() {
|
|||
}
|
||||
}
|
||||
|
||||
// Note: We don't delete files in versioned deployments - new version is separate directory
|
||||
// Files to delete (exist on server but not locally)
|
||||
const toDelete: string[] = []
|
||||
for (const file of remoteFiles) {
|
||||
if (!localFiles.has(file)) {
|
||||
toDelete.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (toUpload.length === 0) {
|
||||
if (toUpload.length === 0 && toDelete.length === 0) {
|
||||
console.log('Already up to date')
|
||||
return
|
||||
}
|
||||
|
|
@ -141,7 +147,20 @@ export async function pushApp() {
|
|||
}
|
||||
}
|
||||
|
||||
// 3. Activate new version (updates symlink and restarts app)
|
||||
// 3. Delete files that no longer exist locally
|
||||
if (toDelete.length > 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<ActivateResponse>(`/api/sync/apps/${appName}/activate?version=${version}`)
|
||||
if (!activateRes?.ok) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user