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)}
+
+
+
+ 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)