Update from the list that says what is out of date

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>
This commit is contained in:
Mitchell-Johnson
2026-09-26 12:49:32 -07:00
co-authored by Claude Opus 5
parent 03e0761f3e
commit 94800b8207
5 changed files with 195 additions and 19 deletions
+13
View File
@@ -177,6 +177,19 @@ something long in one, switch away and back, refresh — it's all still there.
the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms
and 10 min at 2 s. Still to do: persistent and 10 min at 2 s. Still to do: persistent
history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges. history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges.
- **Updating from the Updates tab** — **shipped 2026-09-26**: the "what is out of date" list
was read-only, so seeing a newer image and doing something about it were different places.
Each row now has **Update**, and the header has **Update all**. Updating is per stack —
that is the unit compose deals in — so a row acts on whichever stacks use that image, and
several stacks run through a queue one at a time rather than recreating half the machine at
once, each with its task output on screen. A row that succeeds says "updated just now"
instead of re-checking, since a check is a registry request.
- **Cron in English** (`crontext.ts`, 2026-09-26): schedules read "daily at 3:15am" rather
than `15 3 * * *`, with the expression kept beside it. Handles lists, ranges, steps, day
and month names, `@daily` and friends, weekdays/weekends, midnight and noon — and declines
to describe anything it is not sure of, because a confident wrong sentence is worse than
five fields. The Jobs tab had its own smaller version of this; it now uses this one.
- **Hardware tab** — **shipped 2026-09-25** (`internal/hardware`, `internal/api/hardware.go`, - **Hardware tab** — **shipped 2026-09-25** (`internal/hardware`, `internal/api/hardware.go`,
`Hardware.tsx`): what the machine is physically made of, per machine like every other page. `Hardware.tsx`): what the machine is physically made of, per machine like every other page.
Four headline tiles (processor, memory, storage, graphics) over cards for each: the Four headline tiles (processor, memory, storage, graphics) over cards for each: the
+42 -3
View File
@@ -10,6 +10,7 @@ import { useHashSel } from './hashsel'
import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs' import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
import Sparkline from './Sparkline' import Sparkline from './Sparkline'
import { fmtBytes } from './fmt' import { fmtBytes } from './fmt'
import { describeCron } from './crontext'
import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast, useEscape } from './ui' import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast, useEscape } from './ui'
type Tab = 'containers' | 'stacks' | 'updates' | 'images' | 'volumes' | 'networks' type Tab = 'containers' | 'stacks' | 'updates' | 'images' | 'volumes' | 'networks'
@@ -527,10 +528,32 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
} }
const imgs = upd?.images ?? [] const imgs = upd?.images ?? []
const behind = imgs.filter((i) => i.state === 'update') const behind = imgs.filter((i) => i.state === 'update')
// Updating is per stack — that is what compose can do — so an image row acts on whichever
// stacks use it, and a queue runs them one at a time rather than recreating half the
// machine at once.
const [queue, setQueue] = useState<string[]>([])
const [running, setRunning] = useState<{ stack: string; id: string } | null>(null)
const [done, setDone] = useState<string[]>([])
const stacksFor = useCallback((img: string) => upd?.stacks?.[img] ?? [], [upd])
const startNext = useCallback(async (rest: string[]) => {
const next = rest[0]
if (!next) { setRunning(null); setQueue([]); return }
setQueue(rest.slice(1))
try { setRunning({ stack: next, id: (await api.stackAction(next, 'update')).id }) }
catch (e) { toast(`${next}: ${e instanceof Error ? e.message : 'failed'}`, 'err'); setRunning(null); startNext(rest.slice(1)) }
}, [])
const updateStacks = async (names: string[], what: string) => {
const uniq = [...new Set(names)]
if (uniq.length === 0) { toast('nothing here names a stack this console can deploy', 'info'); return }
if (!await askYes(`Update ${what}?\n\nThis pulls the newer image${uniq.length > 1 ? 's' : ''} and recreates ${uniq.length === 1 ? `the ${uniq[0]} stack` : `these stacks, one at a time: ${uniq.join(', ')}`}. Their containers restart.`, 'Update')) return
setDone([]); startNext(uniq)
}
const behindStacks = [...new Set(behind.flatMap((i) => stacksFor(i.image)))]
return ( return (
<div className="svc-detail"> <div className="svc-detail">
<div className="dash-bar"><h2 className="section">What is out of date</h2><span className="spacer" /> <div className="dash-bar"><h2 className="section">What is out of date</h2><span className="spacer" />
<span className="muted small">{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked yet'}</span> <span className="muted small">{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked yet'}</span>
{behindStacks.length > 0 && <Btn className="btn on" disabled={!!running} onClick={() => updateStacks(behindStacks, `everything out of date — ${behind.length} image${behind.length === 1 ? '' : 's'} across ${behindStacks.length} stack${behindStacks.length === 1 ? '' : 's'}`)}>Update all · {behind.length}</Btn>}
<Btn className="btn" busy={busy} onClick={check}>Check now</Btn> <Btn className="btn" busy={busy} onClick={check}>Check now</Btn>
</div> </div>
<div className="muted small">Each check is one request per image, which registries count — so it happens when asked, and the answer is kept for six hours.</div> <div className="muted small">Each check is one request per image, which registries count — so it happens when asked, and the answer is kept for six hours.</div>
@@ -538,14 +561,30 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
{imgs.length > 0 && ( {imgs.length > 0 && (
<section className="card" style={{ marginTop: 8 }}> <section className="card" style={{ marginTop: 8 }}>
{behind.length === 0 && <div className="muted">Every image that could be checked is current.</div>} {behind.length === 0 && <div className="muted">Every image that could be checked is current.</div>}
<div className="upd-list">
{imgs.filter((i) => i.state !== 'current').map((i) => ( {imgs.filter((i) => i.state !== 'current').map((i) => (
<div className="row" key={i.image}> <div className="row" key={i.image}>
<span className="k mono small">{i.image}</span> <span className="k mono small">{i.image}</span>
<span className={i.state === 'update' ? 'warn-text' : 'muted small'}>{i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'}</span> <span className={i.state === 'update' ? 'warn-text' : 'muted small'}>{done.some((d) => stacksFor(i.image).includes(d)) ? <span className="good">updated just now</span> : i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'}</span>
<span className="muted small">{(upd?.stacks?.[i.image] ?? []).join(' ')}</span> <span className="muted small">{stacksFor(i.image).join(' ')}</span>
{i.state === 'update' && !done.some((d) => stacksFor(i.image).includes(d)) && (
<Btn className="btn" disabled={!!running || stacksFor(i.image).length === 0} title={stacksFor(i.image).length ? `pull it and recreate ${stacksFor(i.image).join(', ')}` : 'no stack here uses this image'}
onClick={() => updateStacks(stacksFor(i.image), `${i.image}`)}>Update</Btn>
)}
</div> </div>
))} ))}
</div>
{behind.length === 0 && imgs.some((i) => i.state === 'current') && <div className="muted small" style={{ marginTop: 6 }}>{imgs.filter((i) => i.state === 'current').length} current.</div>} {behind.length === 0 && imgs.some((i) => i.state === 'current') && <div className="muted small" style={{ marginTop: 6 }}>{imgs.filter((i) => i.state === 'current').length} current.</div>}
{running && (
<div style={{ marginTop: 8 }}>
<div className="sub-head"><span className="dname">Updating {running.stack}</span><span className="spacer" /><span className="muted small">{queue.length > 0 ? `${queue.length} to follow` : 'last one'}</span></div>
<TaskOutput id={running.id} onDone={(t) => {
if (t.exit_code === 0 && !t.error) setDone((d) => [...d, running.stack])
else toast(`${running.stack}: update failed`, 'err')
startNext(queue)
}} />
</div>
)}
</section> </section>
)} )}
@@ -567,7 +606,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
</div> </div>
<div className="props" style={{ marginTop: 6 }}> <div className="props" style={{ marginTop: 6 }}>
<div><span className="k">Stack</span><span>{job.stack}{job.services?.length ? ` · ${job.services.join(' ')}` : ''}</span></div> <div><span className="k">Stack</span><span>{job.stack}{job.services?.length ? ` · ${job.services.join(' ')}` : ''}</span></div>
<div><span className="k">Cron</span><span className="mono small">{job.cron}</span></div> <div><span className="k">Runs</span><span>{describeCron(job.cron) || <span className="mono small">{job.cron}</span>}{describeCron(job.cron) && <span className="muted mono small"> · {job.cron}</span>}</span></div>
<div><span className="k">Does</span><span>{job.apply ? 'pulls and deploys' : 'pulls only — you deploy'}</span></div> <div><span className="k">Does</span><span>{job.apply ? 'pulls and deploys' : 'pulls only — you deploy'}</span></div>
<div><span className="k">Last run</span><span>{last?.at ? new Date(last.at).toLocaleString() : 'never'}</span></div> <div><span className="k">Last run</span><span>{last?.at ? new Date(last.at).toLocaleString() : 'never'}</span></div>
</div> </div>
+2 -16
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { describeCron } from './crontext'
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api' import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
import { Logs } from './Services' import { Logs } from './Services'
import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui' import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
@@ -13,22 +14,7 @@ function rel(ms: number): string {
return s >= 0 ? `in ${txt}` : `${txt} ago` return s >= 0 ? `in ${txt}` : `${txt} ago`
} }
// Plain-English reading of the common five-field shapes; anything else shows the raw expression. const describe = (expr: string) => describeCron(expr) || expr
function describe(expr: string): string {
const alias: Record<string, string> = { '@reboot': 'at boot', '@hourly': 'every hour', '@daily': 'every day at 00:00', '@midnight': 'every day at 00:00', '@weekly': 'every Sunday at 00:00', '@monthly': 'on the 1st at 00:00', '@yearly': 'every 1 Jan', '@annually': 'every 1 Jan' }
if (alias[expr]) return alias[expr]
const f = expr.split(/\s+/)
if (f.length !== 5) return expr
const [mi, h, dom, mon, dow] = f
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const at = /^\d+$/.test(mi) && /^\d+$/.test(h) ? `at ${h.padStart(2, '0')}:${mi.padStart(2, '0')}` : /^\d+$/.test(mi) && h === '*' ? `every hour at :${mi.padStart(2, '0')}` : mi.startsWith('*/') && h === '*' ? `every ${mi.slice(2)} min` : null
if (!at) return expr
const dowTxt = dow === '*' ? '' : /^\d$/.test(dow) ? `every ${days[+dow]}` : `on days ${dow}`
const domTxt = dom === '*' ? '' : /^\d+$/.test(dom) ? `on the ${dom}${['st', 'nd', 'rd'][+dom - 1] ?? 'th'}` : `on days ${dom}`
const monTxt = mon === '*' ? '' : `in month ${mon}`
const when = [dowTxt, domTxt, monTxt].filter(Boolean).join(', ')
return (when ? `${when} ` : dow === '*' && dom === '*' && !at.startsWith('every') ? 'every day ' : '') + at
}
const srcLabel = (s: string) => (s.startsWith('crontab:') ? `${s.slice(8)}'s crontab` : s) const srcLabel = (s: string) => (s.startsWith('crontab:') ? `${s.slice(8)}'s crontab` : s)
+134
View File
@@ -0,0 +1,134 @@
// 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()
}
+4
View File
@@ -94,6 +94,10 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.tile .sub { font-size: 12px; color: var(--muted); margin-top: 2px; font-variant-numeric: tabular-nums; } .tile .sub { font-size: 12px; color: var(--muted); margin-top: 2px; font-variant-numeric: tabular-nums; }
.tile .spark { margin-top: 8px; } .tile .spark { margin-top: 8px; }
.card .empty { color: var(--muted); font-size: 13px; } .card .empty { color: var(--muted); font-size: 13px; }
/* the out-of-date list: image, what the registry said, which stacks, and the button */
.upd-list .row { grid-template-columns: minmax(130px, 1.3fr) minmax(110px, 1.5fr) auto auto; }
.upd-list .row .btn { padding: 2px 10px; }
@media (max-width: 560px) { .upd-list .row { grid-template-columns: 1fr auto; } .upd-list .row > :nth-child(2) { grid-column: 1 / -1; } }
.row { display: grid; grid-template-columns: minmax(90px, 1fr) 2fr auto; gap: 10px; align-items: center; padding: 4px 0; font-size: 13px; min-width: 0; } .row { display: grid; grid-template-columns: minmax(90px, 1fr) 2fr auto; gap: 10px; align-items: center; padding: 4px 0; font-size: 13px; min-width: 0; }
.row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .row .k small { color: var(--muted); margin-left: 6px; } .row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .row .k small { color: var(--muted); margin-left: 6px; }
.num { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-size: 12px; white-space: nowrap; } .num { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-size: 12px; white-space: nowrap; }