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:
co-authored by
Claude Opus 5
parent
03e0761f3e
commit
94800b8207
+42
-3
@@ -10,6 +10,7 @@ import { useHashSel } from './hashsel'
|
||||
import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
|
||||
import Sparkline from './Sparkline'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { describeCron } from './crontext'
|
||||
import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast, useEscape } from './ui'
|
||||
|
||||
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 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 (
|
||||
<div className="svc-detail">
|
||||
<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>
|
||||
{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>
|
||||
</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 && (
|
||||
<section className="card" style={{ marginTop: 8 }}>
|
||||
{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) => (
|
||||
<div className="row" key={i.image}>
|
||||
<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="muted small">{(upd?.stacks?.[i.image] ?? []).join(' ')}</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">{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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -567,7 +606,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
||||
</div>
|
||||
<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">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">Last run</span><span>{last?.at ? new Date(last.at).toLocaleString() : 'never'}</span></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user