From d94a4421f9f8fc6eb9d96e6898d2691b13468054 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath <2+defunkt@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:12:57 -0800 Subject: [PATCH] integrated cron logs, cron cli --- apps/cron/20260201-000000/index.tsx | 47 +++++ apps/cron/20260201-000000/lib/executor.ts | 24 ++- src/cli/commands/cron.ts | 227 ++++++++++++++++++++++ src/cli/commands/index.ts | 1 + src/cli/setup.ts | 30 +++ src/server/api/apps.ts | 23 ++- src/server/apps.ts | 10 + 7 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 src/cli/commands/cron.ts diff --git a/apps/cron/20260201-000000/index.tsx b/apps/cron/20260201-000000/index.tsx index 954ba5a..05d0824 100644 --- a/apps/cron/20260201-000000/index.tsx +++ b/apps/cron/20260201-000000/index.tsx @@ -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() diff --git a/apps/cron/20260201-000000/lib/executor.ts b/apps/cron/20260201-000000/lib/executor.ts index fc18c35..2a8c9f8 100644 --- a/apps/cron/20260201-000000/lib/executor.ts +++ b/apps/cron/20260201-000000/lib/executor.ts @@ -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, 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 void): Promise, 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, 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 void): Promise 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) + 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) + } +} diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index c212892..ee2e0c5 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,3 +1,4 @@ +export { cronList, cronLog, cronRun, cronStatus } from './cron' export { envList, envRm, envSet } from './env' export { logApp } from './logs' export { diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 590c11d..960ce52 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -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 identifier (app:name)') + .action(cronStatus) + +cron + .command('run') + .description('Run a job immediately') + .argument('', 'job identifier (app:name)') + .action(cronRun) + // Sync program diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index a53ce6a..6a3bdf8 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -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) diff --git a/src/server/apps.ts b/src/server/apps.ts index bb618f9..1c31fc6 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -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 []