Compare commits

...

2 Commits

Author SHA1 Message Date
75af5f3d31 toes start/stop/restart feedback 2026-02-12 12:36:42 -08:00
ecac19a07f [cron] schedule times like "7am" 2026-02-12 12:35:24 -08:00
2 changed files with 84 additions and 11 deletions

View File

@ -4,6 +4,7 @@ export type Schedule =
| "30 minutes" | "15 minutes" | "5 minutes" | "1 minute" | "30 minutes" | "15 minutes" | "5 minutes" | "1 minute"
| "30minutes" | "15minutes" | "5minutes" | "1minute" | "30minutes" | "15minutes" | "5minutes" | "1minute"
| 30 | 15 | 5 | 1 | 30 | 15 | 5 | 1
| (string & {}) // time strings like "7am", "7:30pm", "14:00"
export type CronJob = { export type CronJob = {
id: string // "appname:filename" id: string // "appname:filename"
@ -71,19 +72,48 @@ const SCHEDULE_MAP: Record<string, string> = {
'1minute': '* * * * *', '1minute': '* * * * *',
} }
export function toCronExpr(schedule: Schedule): string {
if (typeof schedule === 'number') {
return SCHEDULE_MAP[`${schedule}minutes`]!
}
return SCHEDULE_MAP[schedule]!
}
export function isValidSchedule(value: unknown): value is Schedule { export function isValidSchedule(value: unknown): value is Schedule {
if (typeof value === 'number') { if (typeof value === 'number') {
return [1, 5, 15, 30].includes(value) return [1, 5, 15, 30].includes(value)
} }
if (typeof value === 'string') { if (typeof value === 'string') {
return value in SCHEDULE_MAP return value in SCHEDULE_MAP || parseTime(value) !== null
} }
return false return false
} }
function parseTime(s: string): { hour: number, minute: number } | null {
// 12h: "7am", "7pm", "7:30am", "7:30pm", "12am", "12:00pm"
const m12 = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i)
if (m12) {
let hour = parseInt(m12[1])
const minute = m12[2] ? parseInt(m12[2]) : 0
const period = m12[3].toLowerCase()
if (hour < 1 || hour > 12 || minute > 59) return null
if (period === 'am' && hour === 12) hour = 0
else if (period === 'pm' && hour !== 12) hour += 12
return { hour, minute }
}
// 24h: "14:00", "0:00", "23:59"
const m24 = s.match(/^(\d{1,2}):(\d{2})$/)
if (m24) {
const hour = parseInt(m24[1])
const minute = parseInt(m24[2])
if (hour > 23 || minute > 59) return null
return { hour, minute }
}
return null
}
export function toCronExpr(schedule: Schedule): string {
if (typeof schedule === 'number') {
return SCHEDULE_MAP[`${schedule}minutes`]!
}
if (schedule in SCHEDULE_MAP) {
return SCHEDULE_MAP[schedule]!
}
const time = parseTime(schedule)!
return `${time.minute} ${time.hour} * * *`
}

View File

@ -16,6 +16,24 @@ export const STATE_ICONS: Record<string, string> = {
invalid: color.red('◌'), invalid: color.red('◌'),
} }
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
async function waitForState(name: string, target: string, timeout: number): Promise<string | undefined> {
const start = Date.now()
while (Date.now() - start < timeout) {
await sleep(500)
const app: App | undefined = await get(`/api/apps/${name}`)
if (!app) return undefined
if (app.state === target) return target
// Terminal failure states — stop polling
if (target === 'running' && (app.state === 'stopped' || app.state === 'invalid')) return app.state
if (target === 'stopped' && app.state === 'invalid') return app.state
}
// Timed out — return last known state
const app: App | undefined = await get(`/api/apps/${name}`)
return app?.state
}
export async function configShow() { export async function configShow() {
console.log(`Host: ${color.bold(HOST)}`) console.log(`Host: ${color.bold(HOST)}`)
@ -232,7 +250,15 @@ export async function renameApp(arg: string | undefined, newName: string) {
export async function restartApp(arg?: string) { export async function restartApp(arg?: string) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return if (!name) return
await post(`/api/apps/${name}/restart`) const result = await post(`/api/apps/${name}/restart`)
if (!result) return
process.stdout.write(`${color.yellow('↻')} Restarting ${color.bold(name)}...`)
const state = await waitForState(name, 'running', 15000)
if (state === 'running') {
console.log(` ${color.green('running')}`)
} else {
console.log(` ${color.red(state ?? 'unknown')}`)
}
} }
export async function rmApp(arg?: string) { export async function rmApp(arg?: string) {
@ -264,11 +290,28 @@ export async function rmApp(arg?: string) {
export async function startApp(arg?: string) { export async function startApp(arg?: string) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return if (!name) return
await post(`/api/apps/${name}/start`) const result = await post(`/api/apps/${name}/start`)
if (!result) return
process.stdout.write(`${color.green('▶')} Starting ${color.bold(name)}...`)
const state = await waitForState(name, 'running', 15000)
if (state === 'running') {
console.log(` ${color.green('running')}`)
} else {
console.log(` ${color.red(state ?? 'unknown')}`)
}
} }
export async function stopApp(arg?: string) { export async function stopApp(arg?: string) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return if (!name) return
await post(`/api/apps/${name}/stop`) const result = await post(`/api/apps/${name}/stop`)
if (!result) return
process.stdout.write(`${color.red('■')} Stopping ${color.bold(name)}...`)
const state = await waitForState(name, 'stopped', 10000)
if (state === 'stopped') {
console.log(` ${color.gray('stopped')}`)
} else {
console.log(` ${color.yellow(state ?? 'unknown')}`)
}
} }