diff --git a/PLAN.md b/PLAN.md index 82cef34..3171e81 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 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. +- **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.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 diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index fdacdc8..ee002ad 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -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([]) + const [running, setRunning] = useState<{ stack: string; id: string } | null>(null) + const [done, setDone] = useState([]) + 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 (

What is out of date

{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked yet'} + {behindStacks.length > 0 && 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}} Check now
Each check is one request per image, which registries count — so it happens when asked, and the answer is kept for six hours.
@@ -538,14 +561,30 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u {imgs.length > 0 && (
{behind.length === 0 &&
Every image that could be checked is current.
} +
{imgs.filter((i) => i.state !== 'current').map((i) => (
{i.image} - {i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'} - {(upd?.stacks?.[i.image] ?? []).join(' ')} + {done.some((d) => stacksFor(i.image).includes(d)) ? updated just now : i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'} + {stacksFor(i.image).join(' ')} + {i.state === 'update' && !done.some((d) => stacksFor(i.image).includes(d)) && ( + updateStacks(stacksFor(i.image), `${i.image}`)}>Update + )}
))} +
{behind.length === 0 && imgs.some((i) => i.state === 'current') &&
{imgs.filter((i) => i.state === 'current').length} current.
} + {running && ( +
+
Updating {running.stack}{queue.length > 0 ? `${queue.length} to follow` : 'last one'}
+ { + if (t.exit_code === 0 && !t.error) setDone((d) => [...d, running.stack]) + else toast(`${running.stack}: update failed`, 'err') + startNext(queue) + }} /> +
+ )}
)} @@ -567,7 +606,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
Stack{job.stack}{job.services?.length ? ` · ${job.services.join(' ')}` : ''}
-
Cron{job.cron}
+
Runs{describeCron(job.cron) || {job.cron}}{describeCron(job.cron) && · {job.cron}}
Does{job.apply ? 'pulls and deploys' : 'pulls only — you deploy'}
Last run{last?.at ? new Date(last.at).toLocaleString() : 'never'}
diff --git a/web/src/Jobs.tsx b/web/src/Jobs.tsx index 45819a8..3de23e4 100644 --- a/web/src/Jobs.tsx +++ b/web/src/Jobs.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { describeCron } from './crontext' import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api' import { Logs } from './Services' 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` } -// Plain-English reading of the common five-field shapes; anything else shows the raw expression. -function describe(expr: string): string { - const alias: Record = { '@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 describe = (expr: string) => describeCron(expr) || expr const srcLabel = (s: string) => (s.startsWith('crontab:') ? `${s.slice(8)}'s crontab` : s) diff --git a/web/src/crontext.ts b/web/src/crontext.ts new file mode 100644 index 0000000..a17bf54 --- /dev/null +++ b/web/src/crontext.ts @@ -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 = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 } +const MONTH_ALIAS: Record = { 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 = { + '@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 = {}): number[] | null { + const out = new Set() + 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() +} diff --git a/web/src/styles.css b/web/src/styles.css index 868be28..702241e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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 .spark { margin-top: 8px; } .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 .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; }