integrated cron logs, cron cli
This commit is contained in:
parent
c9986277ab
commit
d94a4421f9
|
|
@ -340,6 +340,53 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
|||
'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 => {
|
||||
const appFilter = c.req.query('app')
|
||||
let jobs = getAllJobs()
|
||||
|
|
|
|||
|
|
@ -5,8 +5,17 @@ import { getNextRun } from './scheduler'
|
|||
|
||||
const APPS_DIR = process.env.APPS_DIR!
|
||||
const TOES_DIR = process.env.TOES_DIR!
|
||||
const TOES_URL = process.env.TOES_URL!
|
||||
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) {
|
||||
const reader = stream.getReader()
|
||||
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')
|
||||
|
||||
forwardLog(job.app, `[cron] Running ${job.name}`)
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], {
|
||||
cwd,
|
||||
|
|
@ -42,9 +53,15 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
|
|||
await Promise.all([
|
||||
readStream(proc.stdout as ReadableStream<Uint8Array>, 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 => {
|
||||
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)
|
||||
|
||||
// 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.lastError) console.error(job.lastError)
|
||||
} 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.state = 'idle'
|
||||
console.error(`[cron] ${job.id} failed:`, e)
|
||||
forwardLog(job.app, `[cron] ${job.name} failed: ${job.lastError}`, 'stderr')
|
||||
}
|
||||
|
||||
onUpdate()
|
||||
|
|
|
|||
227
src/cli/commands/cron.ts
Normal file
227
src/cli/commands/cron.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export { cronList, cronLog, cronRun, cronStatus } from './cron'
|
||||
export { envList, envRm, envSet } from './env'
|
||||
export { logApp } from './logs'
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import { withPager } from './pager'
|
|||
import {
|
||||
cleanApp,
|
||||
configShow,
|
||||
cronList,
|
||||
cronLog,
|
||||
cronRun,
|
||||
cronStatus,
|
||||
diffApp,
|
||||
envList,
|
||||
envRm,
|
||||
|
|
@ -163,6 +167,32 @@ program
|
|||
.argument('[name]', 'app name (uses current directory if omitted)')
|
||||
.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
|
||||
|
||||
program
|
||||
|
|
|
|||
|
|
@ -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 SharedApp } from '@types'
|
||||
import { generateTemplates, type TemplateType } from '%templates'
|
||||
|
|
@ -74,6 +74,27 @@ router.get('/:app/logs', c => {
|
|||
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 => {
|
||||
const appName = c.req.param('app')
|
||||
if (!appName) return c.json({ error: 'App not found' }, 404)
|
||||
|
|
|
|||
|
|
@ -58,6 +58,16 @@ export const runApps = () =>
|
|||
export const runningApps = (): App[] =>
|
||||
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[] {
|
||||
const dir = logDir(appName)
|
||||
if (!existsSync(dir)) return []
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user