diff --git a/web/src/components/SettingsView.tsx b/web/src/components/SettingsView.tsx
index 42c64e7..f3e2986 100644
--- a/web/src/components/SettingsView.tsx
+++ b/web/src/components/SettingsView.tsx
@@ -399,7 +399,10 @@ export default function SettingsView({
-
+ {/* row-top: the description runs four lines, and the interval is
+ the thing you read it for — it belongs level with the text, not
+ floating halfway down beside it. */}
+
Back up the bucket
@@ -443,18 +446,22 @@ export default function SettingsView({
{/* The dashboard strip says a backup is failing; this says
what the Job actually reported, which is what tells you
- whether it is yours to fix. */}
+ whether it is yours to fix. The message and the Job's own
+ reason are a paragraph you read once, so they hang off the
+ timestamp as a tooltip rather than wrapping across the
+ table and pushing every other row around. */}
{bk.lastFailure && (
Last failure
-
+
{new Date(bk.lastFailure.at).toLocaleString()}
- {bk.lastFailure.message ? ` — ${bk.lastFailure.message}` : ''}
{bk.failures > 1 ? ` (${bk.failures} in a row)` : ''}
- {bk.lastFailure.reason && (
-
@@ -467,80 +474,6 @@ export default function SettingsView({
)}
)}
- {/* On demand regardless of the interval: taking one backup
- before a risky change should not mean switching on a
- schedule. Disabled while a run is in flight — two Jobs
- uploading to one dataset would race. */}
- {/* Folders to keep out of the history. An env directory is
- thousands of files the backup has to hash and none of them
- worth keeping — measured, that is 7s of work versus 1s. */}
- {bk?.canRunNow && (
- <>
-
-
- Skip these folders — type a name and press Enter. Anywhere they appear
- (node_modules, .venv),
- they stay out of the history. Everything here is something a command puts
- back; nothing is skipped for being large. Remove any you want kept.
-
- {/* The default list is long and picked from measurements, so an operator
- who trims it has no way back without retyping 30 names. Hidden when
- the list already matches the defaults, so it is never a no-op —
- compared against the local edit rather than the server's copy, or it
- would linger until the next status poll. */}
- {bk.excludeDefaults?.length > 0
- && (cfg.backup.exclude.length !== bk.excludeDefaults.length
- || cfg.backup.exclude.some((t, i) => t !== bk.excludeDefaults[i])) && (
-
- )}
-
-
{
- if (e.target === e.currentTarget) (e.currentTarget.querySelector('input') as HTMLInputElement)?.focus();
- }}>
- {cfg.backup.exclude.map((t) => (
-
- {t}
-
-
- ))}
- setSkipDraft(e.target.value)}
- onKeyDown={(e) => {
- // Enter or comma commits; Backspace on an empty box
- // takes the last chip back, as tag fields do.
- if (e.key === 'Enter' || e.key === ',') {
- e.preventDefault();
- addSkip();
- } else if (e.key === 'Backspace' && !skipDraft && cfg.backup.exclude.length) {
- setCfg({ ...cfg, backup: { ...cfg.backup, exclude: cfg.backup.exclude.slice(0, -1) } });
- }
- }}
- onBlur={addSkip}
- />
-
- >
- )}
- {bk?.canRunNow && (
-
- )}
- {bkMsg &&
{bkMsg}
}
@@ -574,6 +507,84 @@ export default function SettingsView({
)}
+ {/* Folders to keep out of the history. An env directory is
+ thousands of files the backup has to hash and none of them
+ worth keeping — measured, that is 7s of work versus 1s.
+ Full width, outside the row: thirty chips wrapping inside the
+ label column read as a wall, and the field they belong to is a
+ text input, which wants the whole measure. */}
+ {bk?.canRunNow && (
+ <>
+
+
+ Skip these folders — type a name and press Enter. Anywhere they appear
+ (node_modules, .venv),
+ they stay out of the history. Everything here is something a command puts
+ back; nothing is skipped for being large. Remove any you want kept.
+
+ {/* The default list is long and picked from measurements, so an operator
+ who trims it has no way back without retyping 30 names. Hidden when
+ the list already matches the defaults, so it is never a no-op —
+ compared against the local edit rather than the server's copy, or it
+ would linger until the next status poll. */}
+ {bk.excludeDefaults?.length > 0
+ && (cfg.backup.exclude.length !== bk.excludeDefaults.length
+ || cfg.backup.exclude.some((t, i) => t !== bk.excludeDefaults[i])) && (
+
+ )}
+
+
{
+ if (e.target === e.currentTarget) (e.currentTarget.querySelector('input') as HTMLInputElement)?.focus();
+ }}>
+ {cfg.backup.exclude.map((t) => (
+
+ {t}
+
+
+ ))}
+ setSkipDraft(e.target.value)}
+ onKeyDown={(e) => {
+ // Enter or comma commits; Backspace on an empty box
+ // takes the last chip back, as tag fields do.
+ if (e.key === 'Enter' || e.key === ',') {
+ e.preventDefault();
+ addSkip();
+ } else if (e.key === 'Backspace' && !skipDraft && cfg.backup.exclude.length) {
+ setCfg({ ...cfg, backup: { ...cfg.backup, exclude: cfg.backup.exclude.slice(0, -1) } });
+ }
+ }}
+ onBlur={addSkip}
+ />
+
+ >
+ )}
+ {/* On demand regardless of the interval: taking one backup
+ before a risky change should not mean switching on a
+ schedule. Disabled while a run is in flight — two Jobs
+ uploading to one dataset would race. */}
+ {bk?.canRunNow && (
+
+ )}
+ {bkMsg &&
{bkMsg}
}
+
Archive inactive sessions
diff --git a/web/src/styles.css b/web/src/styles.css
index 009aa7c..7acd340 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -871,6 +871,9 @@ body {
.settings-page h3 { margin: 24px 0 8px; font-size: 14px; }
/* plain rows, not cards: the bold label is the section header */
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 2px; }
+/* a row whose description runs several lines: the control sits level with the
+ text instead of centring against it, so the eye finds it where it starts */
+.setting-row.row-top { align-items: flex-start; }
/* right-side controls share one width so the column reads as one rail */
.setting-row > .btn-ghost, .setting-row .confirm-del { flex: none; }
/* operator config (artifacts, jobs): every control sits in the same-width
@@ -936,6 +939,10 @@ a.btn-ghost { text-decoration: none; }
and only take the accent on hover. */
.kv a { color: inherit; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 2px; }
.kv a:hover { color: var(--accent); text-decoration-style: solid; }
+/* A failure value: the timestamp is the row, and what went wrong hangs off the
+ (i) beside it. overflow is visible because .kv b clips for its ellipsis, and
+ that clipping would eat the tooltip card. */
+.kv b.kv-fail { display: inline-flex; align-items: center; gap: 6px; overflow: visible; color: var(--danger); }
/* A kv used inside a setting's description sits tighter than a section-level one. */
.kv-inline { margin-top: 8px; }
.kv-inline > div { padding: 6px 2px; font-size: 12.5px; }