import type { LogLine } from '@types' import color from 'ansis' import { get, getSignal, 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(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(`/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('/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(`/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, { signal: getSignal() }) 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) } }