ux(backup): next-run hint in the Backup status card header
The Backup status card sat with just a heading + tooltip on the right;
the Locations card on the same row already had a hint pill ("Active
destinations"). Mirror that pattern: show the next scheduled backup
time pushed to the right of the heading, so the user can see at a
glance when the daily run will fire without digging into Configuration.
Derived purely client-side from CFG_BACKUP_CRONTAB_APP (read off the
already-loaded window.systemConfigs map) — no backend surface needed:
- nextCronFireTime(expr) parses a 5-field crontab (minute hour dom
month dow) supporting *, N, lists (N,M,O), ranges (N-M), and
steps (* /N, N-M/S). Walks one minute at a time from now+1, honours
the POSIX OR rule for DOM+DOW, caps at 366 days so an unmatchable
expression doesn't loop forever, returns null on bad syntax so the
UI falls back gracefully.
- formatRelativeFuture(when) — formatRelative's future-tense sibling:
"in 6h", "tomorrow", "in 3d".
- formatScheduleClock(when) — "at 05:00" today, "Mon 05:00" otherwise.
Hint slot rendered in #backup-next-run. Three states:
- parseable + computable "Next backup tomorrow · at 05:00"
+ title with absolute time + schedule
- unparseable schedule "Schedule: <raw>" with title hint
- empty CFG_BACKUP_CRONTAB_APP "No schedule set" with title hint
Smoke-tested the cron parser against "0 5 * * *", "*/15 * * * *",
"30 23 * * 0", "0 0 1 * *", "", "garbage", and "0 5 * *" (4 fields).
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
3f3499a348
commit
2e7ab3235a
@ -84,6 +84,7 @@
|
||||
<div class="backup-card">
|
||||
<div class="backup-card-header">
|
||||
<h2>Backup status <span class="tooltip" title="Latest backup per app + System config. Back up System first — it's needed to restore the rest." style="font-size:.75em;opacity:.7;cursor:help">ℹ️</span></h2>
|
||||
<span class="backup-card-hint" id="backup-next-run" title="Next scheduled backup run (from the app backup crontab)">—</span>
|
||||
</div>
|
||||
<!-- The "System config" tile is rendered FIRST inside this grid
|
||||
by renderDashboard() / renderSystemTile(), styled the same
|
||||
|
||||
@ -633,6 +633,26 @@ class BackupPage {
|
||||
${this.tile('Total stored', this.formatBytes(totalSize), 'deduplicated, encrypted')}
|
||||
`;
|
||||
|
||||
// Next-run hint in the "Backup status" card header — derived from
|
||||
// CFG_BACKUP_CRONTAB_APP (the cron expression the app-backup
|
||||
// scheduler uses). Pure client-side computation; no backend
|
||||
// surface needed.
|
||||
const nextRunEl = document.getElementById('backup-next-run');
|
||||
if (nextRunEl) {
|
||||
const cron = (window.systemConfigs?.CFG_BACKUP_CRONTAB_APP || '').trim();
|
||||
const next = cron ? this.nextCronFireTime(cron) : null;
|
||||
if (next) {
|
||||
nextRunEl.textContent = `Next backup ${this.formatRelativeFuture(next)} · ${this.formatScheduleClock(next)}`;
|
||||
nextRunEl.title = `Next scheduled backup: ${next.toLocaleString()}\nSchedule: ${cron}`;
|
||||
} else if (cron) {
|
||||
nextRunEl.textContent = `Schedule: ${cron}`;
|
||||
nextRunEl.title = `Couldn't parse the schedule "${cron}" to compute the next run.`;
|
||||
} else {
|
||||
nextRunEl.textContent = 'No schedule set';
|
||||
nextRunEl.title = 'CFG_BACKUP_CRONTAB_APP is empty — backups only run when triggered manually.';
|
||||
}
|
||||
}
|
||||
|
||||
// System config tile is rendered FIRST so the bare-metal-restore
|
||||
// prerequisite is always at eye-level — without it, the user's
|
||||
// backups exist but the credentials needed to reach them don't.
|
||||
@ -2300,6 +2320,124 @@ class BackupPage {
|
||||
if (d < 30) return `${d}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
// formatRelative's future-tense sibling — "in 6h", "tomorrow at 5am", etc.
|
||||
// Used by the backup-status header to summarise the next scheduled run.
|
||||
formatRelativeFuture(when) {
|
||||
if (!when) return '';
|
||||
const t = when.getTime();
|
||||
const diff = t - Date.now();
|
||||
if (diff <= 0) return 'imminent';
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return 'in less than a minute';
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `in ${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `in ${h}h`;
|
||||
const sameDay = (a, b) => a.toDateString() === b.toDateString();
|
||||
const tomorrow = new Date(Date.now() + 86400000);
|
||||
if (sameDay(when, tomorrow)) return 'tomorrow';
|
||||
const days = Math.floor(h / 24);
|
||||
return `in ${days}d`;
|
||||
}
|
||||
|
||||
// "05:00" or "Mon 05:00" depending on whether it's later today or not.
|
||||
formatScheduleClock(when) {
|
||||
if (!when) return '';
|
||||
const sameDay = (new Date()).toDateString() === when.toDateString();
|
||||
const t = when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (sameDay) return `at ${t}`;
|
||||
const day = when.toLocaleDateString([], { weekday: 'short' });
|
||||
return `${day} ${t}`;
|
||||
}
|
||||
|
||||
// Tiny cron-next utility — given a 5-field crontab expression
|
||||
// (minute hour dom month dow) returns a Date for the next fire after
|
||||
// now, or null if the expression is unparseable / never fires within
|
||||
// the lookahead window. Supports the common syntax: *, N, lists
|
||||
// (N,M,O), ranges (N-M), and steps (* /N or N-M/S). Doesn't try to
|
||||
// be a full cron implementation — just enough for the
|
||||
// CFG_BACKUP_CRONTAB_APP value the WebUI shows in the header.
|
||||
nextCronFireTime(expr) {
|
||||
const fields = String(expr || '').trim().split(/\s+/);
|
||||
if (fields.length !== 5) return null;
|
||||
const ranges = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]];
|
||||
let sets;
|
||||
try {
|
||||
sets = fields.map((f, i) => this._cronFieldSet(f, ranges[i][0], ranges[i][1]));
|
||||
} catch (_) { return null; }
|
||||
if (sets.some(s => !s.length)) return null;
|
||||
|
||||
const [minSet, hourSet, domSet, monSet, dowSet] = sets;
|
||||
const minOk = new Set(minSet);
|
||||
const hourOk = new Set(hourSet);
|
||||
const domOk = new Set(domSet);
|
||||
const monOk = new Set(monSet);
|
||||
const dowOk = new Set(dowSet);
|
||||
const domStar = fields[2] === '*';
|
||||
const dowStar = fields[4] === '*';
|
||||
|
||||
// Start one minute from now (rounded down to the minute) and walk
|
||||
// forward checking each candidate. Cap at ~366 days so a totally
|
||||
// unmatchable expression doesn't loop forever.
|
||||
const start = new Date();
|
||||
start.setSeconds(0, 0);
|
||||
start.setMinutes(start.getMinutes() + 1);
|
||||
const limit = start.getTime() + 366 * 86400 * 1000;
|
||||
const cur = new Date(start);
|
||||
while (cur.getTime() < limit) {
|
||||
const m = cur.getMinutes();
|
||||
const h = cur.getHours();
|
||||
const dom = cur.getDate();
|
||||
const mon = cur.getMonth() + 1;
|
||||
const dow = cur.getDay(); // 0 = Sun
|
||||
// POSIX cron: if both DOM and DOW are restricted, fire when
|
||||
// EITHER matches. If only one is restricted, that one must
|
||||
// match. If both are *, day passes.
|
||||
let dayMatch;
|
||||
if (domStar && dowStar) dayMatch = true;
|
||||
else if (domStar) dayMatch = dowOk.has(dow);
|
||||
else if (dowStar) dayMatch = domOk.has(dom);
|
||||
else dayMatch = domOk.has(dom) || dowOk.has(dow);
|
||||
if (dayMatch && monOk.has(mon) && hourOk.has(h) && minOk.has(m)) {
|
||||
return new Date(cur);
|
||||
}
|
||||
cur.setMinutes(cur.getMinutes() + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Expand one cron field into a sorted list of valid numeric values.
|
||||
// Throws on bad syntax so nextCronFireTime can drop back to null.
|
||||
_cronFieldSet(field, lo, hi) {
|
||||
const out = new Set();
|
||||
for (const part of String(field).split(',')) {
|
||||
// step (every-Nth): "value/step" — value is "*", a single
|
||||
// number, or a range "a-b".
|
||||
let stepBase = part, step = 1;
|
||||
const slash = part.indexOf('/');
|
||||
if (slash !== -1) {
|
||||
stepBase = part.slice(0, slash);
|
||||
step = parseInt(part.slice(slash + 1), 10);
|
||||
if (!Number.isFinite(step) || step < 1) throw new Error('bad step');
|
||||
}
|
||||
let from = lo, to = hi;
|
||||
if (stepBase === '*' || stepBase === '') {
|
||||
// range stays lo..hi
|
||||
} else if (stepBase.includes('-')) {
|
||||
const [a, b] = stepBase.split('-').map(n => parseInt(n, 10));
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) throw new Error('bad range');
|
||||
from = a; to = b;
|
||||
} else {
|
||||
const n = parseInt(stepBase, 10);
|
||||
if (!Number.isFinite(n)) throw new Error('bad value');
|
||||
from = n; to = n;
|
||||
}
|
||||
if (from < lo || to > hi || from > to) throw new Error('out of range');
|
||||
for (let v = from; v <= to; v += step) out.add(v);
|
||||
}
|
||||
return [...out].sort((a, b) => a - b);
|
||||
}
|
||||
}
|
||||
|
||||
window.BackupPage = BackupPage;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user