integrated cron logs, cron cli

This commit is contained in:
Chris Wanstrath 2026-02-10 11:12:57 -08:00
parent c9986277ab
commit d94a4421f9
7 changed files with 360 additions and 2 deletions

View File

@ -340,6 +340,53 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8', '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 => { app.get('/', async c => {
const appFilter = c.req.query('app') const appFilter = c.req.query('app')
let jobs = getAllJobs() let jobs = getAllJobs()

View File

@ -5,8 +5,17 @@ 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 TOES_DIR = process.env.TOES_DIR!
const TOES_URL = process.env.TOES_URL!
const RUNNER = join(import.meta.dir, 'runner.ts') const RUNNER = join(import.meta.dir, 'runner.ts')
function forwardLog(app: string, text: string, stream: 'stdout' | 'stderr' = 'stdout') {
fetch(`${TOES_URL}/api/apps/${app}/logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, stream }),
}).catch(() => {})
}
async function readStream(stream: ReadableStream<Uint8Array>, append: (text: string) => void) { async function readStream(stream: ReadableStream<Uint8Array>, append: (text: string) => void) {
const reader = stream.getReader() const reader = stream.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
@ -30,6 +39,8 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
const cwd = join(APPS_DIR, job.app, 'current') const cwd = join(APPS_DIR, job.app, 'current')
forwardLog(job.app, `[cron] Running ${job.name}`)
try { try {
const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], { const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], {
cwd, cwd,
@ -42,9 +53,15 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
await Promise.all([ await Promise.all([
readStream(proc.stdout as ReadableStream<Uint8Array>, text => { readStream(proc.stdout as ReadableStream<Uint8Array>, text => {
job.lastOutput = (job.lastOutput || '') + text job.lastOutput = (job.lastOutput || '') + text
for (const line of text.split('\n').filter(Boolean)) {
forwardLog(job.app, `[cron:${job.name}] ${line}`)
}
}), }),
readStream(proc.stderr as ReadableStream<Uint8Array>, text => { readStream(proc.stderr as ReadableStream<Uint8Array>, text => {
job.lastError = (job.lastError || '') + text job.lastError = (job.lastError || '') + text
for (const line of text.split('\n').filter(Boolean)) {
forwardLog(job.app, `[cron:${job.name}] ${line}`, 'stderr')
}
}), }),
]) ])
@ -59,7 +76,11 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
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`) const status = code === 0 ? 'ok' : `failed (code=${code})`
const summary = `[cron] ${job.name} finished: ${status} duration=${job.lastDuration}ms`
console.log(summary)
forwardLog(job.app, summary, code === 0 ? 'stdout' : 'stderr')
if (job.lastOutput) console.log(job.lastOutput) if (job.lastOutput) console.log(job.lastOutput)
if (job.lastError) console.error(job.lastError) if (job.lastError) console.error(job.lastError)
} catch (e) { } catch (e) {
@ -68,6 +89,7 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
job.lastError = e instanceof Error ? e.message : String(e) job.lastError = e instanceof Error ? e.message : String(e)
job.state = 'idle' job.state = 'idle'
console.error(`[cron] ${job.id} failed:`, e) console.error(`[cron] ${job.id} failed:`, e)
forwardLog(job.app, `[cron] ${job.name} failed: ${job.lastError}`, 'stderr')
} }
onUpdate() onUpdate()

227
src/cli/commands/cron.ts Normal file
View File

@ -0,0 +1,227 @@
import type { LogLine } from '@types'
import color from 'kleur'
import { get, handleError, makeUrl, post } from '../http'
import { resolveAppName } from '../name'
interface CronJobSummary {
app: string
name: string
schedule: string
state: string
status: string
lastRun?: number
lastDuration?: number
lastExitCode?: number
nextRun?: number
}
interface CronJobDetail extends CronJobSummary {
lastError?: string
lastOutput?: string
}
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 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`
}
function pad(str: string, len: number, right = false): string {
if (right) return str.padStart(len)
return str.padEnd(len)
}
function statusColor(status: string): (s: string) => string {
if (status === 'running') return color.green
if (status === 'ok') return color.green
if (status === 'idle') return color.gray
return color.red
}
function parseJobArg(arg: string): { app: string; name: string } | undefined {
const parts = arg.split(':')
if (parts.length !== 2 || !parts[0] || !parts[1]) {
console.error(`Invalid job format: ${arg}`)
console.error('Use app:name format (e.g., myapp:backup)')
return undefined
}
return { app: parts[0]!, name: parts[1]! }
}
export async function cronList(app?: string) {
const appName = app ? resolveAppName(app) : undefined
if (app && !appName) return
const url = appName
? `/api/tools/cron/api/jobs?app=${appName}`
: '/api/tools/cron/api/jobs'
const jobs = await get<CronJobSummary[]>(url)
if (!jobs || jobs.length === 0) {
console.log('No cron jobs found')
return
}
const jobWidth = Math.max(3, ...jobs.map(j => `${j.app}:${j.name}`.length))
const schedWidth = Math.max(8, ...jobs.map(j => String(j.schedule).length))
const statusWidth = Math.max(6, ...jobs.map(j => j.status.length))
console.log(
color.gray(
`${pad('JOB', jobWidth)} ${pad('SCHEDULE', schedWidth)} ${pad('STATUS', statusWidth)} ${pad('LAST RUN', 10)} ${pad('NEXT RUN', 10)}`
)
)
for (const j of jobs) {
const id = `${j.app}:${j.name}`
const colorFn = statusColor(j.status)
console.log(
`${pad(id, jobWidth)} ${pad(String(j.schedule), schedWidth)} ${colorFn(pad(j.status, statusWidth))} ${pad(formatRelative(j.lastRun), 10)} ${pad(formatRelative(j.nextRun), 10)}`
)
}
}
export async function cronStatus(arg: string) {
const parsed = parseJobArg(arg)
if (!parsed) return
const job = await get<CronJobDetail>(`/api/tools/cron/api/jobs/${parsed.app}/${parsed.name}`)
if (!job) return
const colorFn = statusColor(job.status)
console.log(`${color.bold(`${job.app}:${job.name}`)} ${colorFn(job.status)}`)
console.log()
console.log(` Schedule: ${job.schedule}`)
console.log(` State: ${job.state}`)
console.log(` Last run: ${formatRelative(job.lastRun)}`)
console.log(` Duration: ${formatDuration(job.lastDuration)}`)
if (job.lastExitCode !== undefined) {
console.log(` Exit code: ${job.lastExitCode === 0 ? color.green('0') : color.red(String(job.lastExitCode))}`)
}
console.log(` Next run: ${formatRelative(job.nextRun)}`)
if (job.lastError) {
console.log()
console.log(color.red('Error:'))
console.log(job.lastError)
}
if (job.lastOutput) {
console.log()
console.log(color.gray('Output:'))
console.log(job.lastOutput)
}
}
export async function cronLog(arg?: string, options?: { follow?: boolean }) {
// No arg: show the cron tool's own logs
// "myapp": show myapp's logs filtered to [cron entries
// "myapp:backup": show myapp's logs filtered to [cron:backup]
const follow = options?.follow ?? false
if (!arg) {
// Show cron tool's own logs
if (follow) {
await tailCronLogs('cron')
return
}
const logs = await get<LogLine[]>('/api/apps/cron/logs')
if (!logs || logs.length === 0) {
console.log('No cron logs yet')
return
}
for (const line of logs) printCronLog(line)
return
}
// Parse arg — could be "myapp" or "myapp:backup"
const colon = arg.indexOf(':')
const appName = colon >= 0 ? arg.slice(0, colon) : arg
const jobName = colon >= 0 ? arg.slice(colon + 1) : undefined
const grepPrefix = jobName ? `[cron:${jobName}]` : '[cron'
const resolved = resolveAppName(appName)
if (!resolved) return
if (follow) {
await tailCronLogs(resolved, grepPrefix)
return
}
const logs = await get<LogLine[]>(`/api/apps/${resolved}/logs`)
if (!logs || logs.length === 0) {
console.log('No cron logs yet')
return
}
for (const line of logs) {
if (line.text.includes(grepPrefix)) printCronLog(line)
}
}
export async function cronRun(arg: string) {
const parsed = parseJobArg(arg)
if (!parsed) return
const result = await post<{ ok: boolean; message: string; error?: string }>(
`/api/tools/cron/api/jobs/${parsed.app}/${parsed.name}/run`
)
if (!result) return
console.log(color.green(result.message))
}
const printCronLog = (line: LogLine) =>
console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`)
async function tailCronLogs(app: string, grep?: string) {
try {
const url = makeUrl(`/api/apps/${app}/logs/stream`)
const res = await fetch(url)
if (!res.ok) {
console.error(`App not found: ${app}`)
return
}
if (!res.body) return
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6)) as LogLine
if (!grep || data.text.includes(grep)) printCronLog(data)
}
}
}
} catch (error) {
handleError(error)
}
}

View File

@ -1,3 +1,4 @@
export { cronList, cronLog, cronRun, cronStatus } from './cron'
export { envList, envRm, envSet } from './env' export { envList, envRm, envSet } from './env'
export { logApp } from './logs' export { logApp } from './logs'
export { export {

View File

@ -7,6 +7,10 @@ import { withPager } from './pager'
import { import {
cleanApp, cleanApp,
configShow, configShow,
cronList,
cronLog,
cronRun,
cronStatus,
diffApp, diffApp,
envList, envList,
envRm, envRm,
@ -163,6 +167,32 @@ program
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.action(statsApp) .action(statsApp)
const cron = program
.command('cron')
.helpGroup('Lifecycle:')
.description('Manage cron jobs')
.argument('[app]', 'app name (list jobs for specific app)')
.action(cronList)
cron
.command('log')
.description('Show cron job logs')
.argument('[target]', 'app name or job (app:name)')
.option('-f, --follow', 'follow log output')
.action(cronLog)
cron
.command('status')
.description('Show detailed status for a job')
.argument('<job>', 'job identifier (app:name)')
.action(cronStatus)
cron
.command('run')
.description('Run a job immediately')
.argument('<job>', 'job identifier (app:name)')
.action(cronRun)
// Sync // Sync
program program

View File

@ -1,4 +1,4 @@
import { APPS_DIR, TOES_DIR, allApps, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps' import { APPS_DIR, TOES_DIR, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps'
import type { App as BackendApp } from '$apps' import type { App as BackendApp } from '$apps'
import type { App as SharedApp } from '@types' import type { App as SharedApp } from '@types'
import { generateTemplates, type TemplateType } from '%templates' import { generateTemplates, type TemplateType } from '%templates'
@ -74,6 +74,27 @@ router.get('/:app/logs', c => {
return c.json(logs) return c.json(logs)
}) })
router.post('/:app/logs', async c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
const app = allApps().find(a => a.name === appName)
if (!app) return c.json({ error: 'App not found' }, 404)
let body: { text?: string, stream?: 'stdout' | 'stderr' }
try {
body = await c.req.json()
} catch {
return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
}
const text = body.text?.trimEnd()
if (!text) return c.json({ ok: false, error: 'Text is required' }, 400)
appendLog(appName, text, body.stream ?? 'stdout')
return c.json({ ok: true })
})
router.get('/:app/logs/dates', c => { router.get('/:app/logs/dates', c => {
const appName = c.req.param('app') const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404) if (!appName) return c.json({ error: 'App not found' }, 404)

View File

@ -58,6 +58,16 @@ export const runApps = () =>
export const runningApps = (): App[] => export const runningApps = (): App[] =>
allApps().filter(a => a.state === 'running') allApps().filter(a => a.state === 'running')
export function appendLog(appName: string, text: string, streamType: 'stdout' | 'stderr' = 'stdout') {
const app = _apps.get(appName)
if (!app) return
info(app, text)
writeLogLine(appName, streamType, text)
app.logs = (app.logs ?? []).slice(-MAX_LOGS)
update()
}
export function getLogDates(appName: string): string[] { export function getLogDates(appName: string): string[] {
const dir = logDir(appName) const dir = logDir(appName)
if (!existsSync(dir)) return [] if (!existsSync(dir)) return []