forked from defunkt/toes
228 lines
6.5 KiB
TypeScript
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)
|
|
}
|
|
}
|