The Updates tab could tell you eleven images were behind and offer nothing to do about it; updating meant knowing which stack each image belonged to and going to find it. Now each row has Update and the header has Update all. Updating is per stack, because that is the unit compose deals in, so a row acts on whichever stacks use that image — postgres:15-alpine is wikijs's, postgres:17 is vikunja's — and Update all walks the affected stacks through a queue one at a time, with each task's output on screen, rather than recreating half the machine at once. A stack that finishes says "updated just now" rather than re-checking, since every check is a registry request and Docker Hub counts them. Also: cron expressions read as English. "daily at 3:15am", with 15 3 * * * kept beside it. Jobs.tsx had a smaller version of this for its own list; it now uses the shared one, which handles lists, ranges, steps, names, weekdays/weekends, midnight and noon, and says nothing at all when it is not certain — the caller falls back to the expression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
6.2 KiB
TypeScript
135 lines
6.2 KiB
TypeScript
// A cron expression in English. Not a parser for running jobs — internal/cron does that,
|
|
// and computes the next run — just a sentence for the person reading the schedule, who
|
|
// should not have to decode "15 3 * * 1" in their head.
|
|
//
|
|
// Anything it can't say plainly it declines to describe, and the caller shows the
|
|
// expression instead. A confident wrong sentence is worse than five fields.
|
|
|
|
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
|
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
|
const DAY_ALIAS: Record<string, number> = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }
|
|
const MONTH_ALIAS: Record<string, number> = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 }
|
|
|
|
// "@daily" and friends, which crontab(5) accepts in place of the five fields.
|
|
const ALIASES: Record<string, string> = {
|
|
'@yearly': 'once a year, on 1 January at midnight',
|
|
'@annually': 'once a year, on 1 January at midnight',
|
|
'@monthly': 'monthly, on the 1st at midnight',
|
|
'@weekly': 'weekly, on Sunday at midnight',
|
|
'@daily': 'daily at midnight',
|
|
'@midnight': 'daily at midnight',
|
|
'@hourly': 'hourly, on the hour',
|
|
'@reboot': 'at every boot',
|
|
}
|
|
|
|
const clock = (h: number, m: number) => {
|
|
if (m === 0 && h === 0) return 'midnight'
|
|
if (m === 0 && h === 12) return 'noon'
|
|
const ampm = h < 12 ? 'am' : 'pm'
|
|
const h12 = h % 12 === 0 ? 12 : h % 12
|
|
return m === 0 ? `${h12}${ampm}` : `${h12}:${String(m).padStart(2, '0')}${ampm}`
|
|
}
|
|
|
|
// Monday-to-Friday and Saturday-Sunday are worth naming; the rest get listed.
|
|
function dayNames(d: number[]): string {
|
|
const set = d.join(',')
|
|
if (set === '1,2,3,4,5') return 'weekdays'
|
|
if (set === '0,6') return 'weekends'
|
|
return list(d.map((x) => DAYS[x]))
|
|
}
|
|
|
|
const list = (parts: string[]) =>
|
|
parts.length <= 1 ? (parts[0] ?? '') : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`
|
|
|
|
const ordinal = (n: number) => {
|
|
const s = ['th', 'st', 'nd', 'rd'][(n % 100 - 20) % 10] ?? ['th', 'st', 'nd', 'rd'][n % 100] ?? 'th'
|
|
return `${n}${s}`
|
|
}
|
|
|
|
// values of one field, or null when it is a form this doesn't describe
|
|
function values(field: string, min: number, max: number, alias: Record<string, number> = {}): number[] | null {
|
|
const out = new Set<number>()
|
|
for (const part of field.split(',')) {
|
|
const [range, stepText] = part.split('/')
|
|
const step = stepText === undefined ? 1 : Number(stepText)
|
|
if (!Number.isInteger(step) || step < 1) return null
|
|
let lo: number, hi: number
|
|
if (range === '*') {
|
|
lo = min; hi = max
|
|
} else {
|
|
const ends = range.split('-').map((v) => {
|
|
const n = alias[v.toLowerCase()] ?? Number(v)
|
|
return Number.isInteger(n) ? n : NaN
|
|
})
|
|
if (ends.some(Number.isNaN) || ends.length > 2) return null
|
|
lo = ends[0]
|
|
hi = ends.length === 2 ? ends[1] : (stepText === undefined ? ends[0] : max)
|
|
}
|
|
if (lo < min || hi > max || lo > hi) return null
|
|
for (let v = lo; v <= hi; v += step) out.add(v)
|
|
}
|
|
return [...out].sort((a, b) => a - b)
|
|
}
|
|
|
|
const everyValue = (v: number[], min: number, max: number) => v.length === max - min + 1
|
|
|
|
// stepOf reports the interval when a field is a plain "every n" over its whole range.
|
|
function stepOf(v: number[], min: number, max: number): number | null {
|
|
if (v.length < 2 || v[0] !== min) return null
|
|
const step = v[1] - v[0]
|
|
for (let i = 1; i < v.length; i++) if (v[i] - v[i - 1] !== step) return null
|
|
return v[v.length - 1] + step > max ? step : null
|
|
}
|
|
|
|
/** describeCron turns "15 3 * * *" into "daily at 3:15am", or returns "" if it can't. */
|
|
export function describeCron(expr: string): string {
|
|
const raw = (expr ?? '').trim()
|
|
if (!raw) return ''
|
|
if (raw.startsWith('@')) return ALIASES[raw.toLowerCase()] ?? ''
|
|
const f = raw.split(/\s+/)
|
|
if (f.length !== 5) return '' // 6-field (seconds) and Quartz forms aren't ours to guess at
|
|
const [minF, hourF, domF, monF, dowF] = f
|
|
const mins = values(minF, 0, 59)
|
|
const hours = values(hourF, 0, 23)
|
|
const doms = values(domF, 1, 31)
|
|
const mons = values(monF, 1, 12, MONTH_ALIAS)
|
|
const dows = values(dowF.replace(/7/g, '0'), 0, 6, DAY_ALIAS)
|
|
if (!mins || !hours || !doms || !mons || !dows) return ''
|
|
|
|
// --- when in the day ---
|
|
let time: string
|
|
const everyMin = everyValue(mins, 0, 59)
|
|
const everyHour = everyValue(hours, 0, 23)
|
|
const minStep = stepOf(mins, 0, 59)
|
|
const hourStep = stepOf(hours, 0, 23)
|
|
if (everyMin && everyHour) time = 'every minute'
|
|
else if (everyMin) time = `every minute of ${list(hours.map((h) => clock(h, 0).replace(/(am|pm)/, '$1')))}`
|
|
else if (minStep && everyHour) time = `every ${minStep} minutes`
|
|
else if (minStep) time = `every ${minStep} minutes, ${hours.length === 1 ? `during the ${clock(hours[0], 0)} hour` : `during ${list(hours.map((h) => clock(h, 0)))}`}`
|
|
else if (everyHour && mins.length === 1) time = mins[0] === 0 ? 'hourly, on the hour' : `hourly, at ${mins[0]} past`
|
|
else if (hourStep && mins.length === 1) time = `every ${hourStep} hours` + (mins[0] === 0 ? ', on the hour' : `, at ${mins[0]} past`)
|
|
else if (mins.length === 1 && hours.length >= 1) time = `at ${list(hours.map((h) => clock(h, mins[0])))}`
|
|
else {
|
|
const times: string[] = []
|
|
for (const h of hours) for (const m of mins) times.push(clock(h, m))
|
|
if (times.length > 6) return '' // a wall of times is not a translation
|
|
time = `at ${list(times)}`
|
|
}
|
|
|
|
// --- which days ---
|
|
const allDom = everyValue(doms, 1, 31)
|
|
const allDow = everyValue(dows, 0, 6)
|
|
const allMon = everyValue(mons, 1, 12)
|
|
let days = ''
|
|
if (allDom && allDow) days = ''
|
|
else if (allDom) days = `on ${dayNames(dows)}`
|
|
else if (allDow) days = `on the ${list(doms.map(ordinal))}`
|
|
// crontab(5): with both set, the job runs when *either* matches — worth saying out loud
|
|
else days = `on the ${list(doms.map(ordinal))}, and on ${dayNames(dows)}`
|
|
const months = allMon ? '' : `in ${list(mons.map((m) => MONTHS[m - 1]))}`
|
|
|
|
const daily = !days && !months
|
|
const head = daily ? (time.startsWith('every') || time.startsWith('hourly') ? '' : 'daily ') : ''
|
|
return [head + time, days, months].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim()
|
|
}
|