toes/src/cli/commands/cron.ts

228 lines
6.5 KiB
TypeScript

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<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, { 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)
}
}