Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 93 additions & 82 deletions web/src/components/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,10 @@ export default function SettingsView({
</span>
</div>

<div className="setting-row">
{/* 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. */}
<div className="setting-row row-top">
<div>
<div className="s-label">Back up the bucket</div>
<div className="s-help">
Expand Down Expand Up @@ -443,18 +446,22 @@ export default function SettingsView({
</div>
{/* 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 && (
<div>
<span>Last failure</span>
<b style={{ color: 'var(--danger)', whiteSpace: 'normal', textAlign: 'right' }}>
<b className="kv-fail">
{new Date(bk.lastFailure.at).toLocaleString()}
{bk.lastFailure.message ? ` — ${bk.lastFailure.message}` : ''}
{bk.failures > 1 ? ` (${bk.failures} in a row)` : ''}
{bk.lastFailure.reason && (
<div className="mono" style={{ fontWeight: 400, color: 'var(--muted)', fontSize: 11.5, marginTop: 2 }}>
{bk.lastFailure.reason}
</div>
{(bk.lastFailure.message || bk.lastFailure.reason) && (
<span
className="tip"
tabIndex={0}
data-tip={[bk.lastFailure.message, bk.lastFailure.reason].filter(Boolean).join(' — ')}
><InfoGlyph className="tip-i" /></span>
)}
</b>
</div>
Expand All @@ -467,80 +474,6 @@ export default function SettingsView({
)}
</div>
)}
{/* 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 && (
<>
<div className="s-help skiphead" style={{ marginTop: 10 }}>
<span>
Skip these folders — type a name and press Enter. Anywhere they appear
(<span className="mono">node_modules</span>, <span className="mono">.venv</span>),
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.
</span>
{/* 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])) && (
<button
className="btn-ghost skiprestore"
onClick={() => setCfg({ ...cfg, backup: { ...cfg.backup, exclude: [...bk.excludeDefaults] } })}
>Restore defaults</button>
)}
</div>
<div className="tagf" onClick={(e) => {
if (e.target === e.currentTarget) (e.currentTarget.querySelector('input') as HTMLInputElement)?.focus();
}}>
{cfg.backup.exclude.map((t) => (
<span className="tagf-chip mono" key={t}>
{t}
<button
className="tagf-x"
aria-label={`Stop skipping ${t}`}
onClick={() => setCfg({ ...cfg, backup: { ...cfg.backup, exclude: cfg.backup.exclude.filter((x) => x !== t) } })}
>×</button>
</span>
))}
<input
className="tagf-input mono"
placeholder={cfg.backup.exclude.length ? 'add another…' : 'node_modules'}
value={skipDraft}
onChange={(e) => 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}
/>
</div>
</>
)}
{bk?.canRunNow && (
<button
className="btn-ghost"
style={{ marginTop: 8 }}
disabled={bkBusy || bk.running}
onClick={doBackup}
>
{bkBusy ? 'Launching…' : bk.running ? 'Backing up…' : 'Back up now'}
</button>
)}
{bkMsg && <div className="s-help" style={{ marginTop: 6 }}>{bkMsg}</div>}
</div>
<span className="cfg-ctl">
<div className="seg cfg-seg">
Expand Down Expand Up @@ -574,6 +507,84 @@ export default function SettingsView({
</div>
)}

{/* 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 && (
<>
<div className="s-help skiphead" style={{ marginTop: 10 }}>
<span>
Skip these folders — type a name and press Enter. Anywhere they appear
(<span className="mono">node_modules</span>, <span className="mono">.venv</span>),
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.
</span>
{/* 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])) && (
<button
className="btn-ghost skiprestore"
onClick={() => setCfg({ ...cfg, backup: { ...cfg.backup, exclude: [...bk.excludeDefaults] } })}
>Restore defaults</button>
)}
</div>
<div className="tagf" onClick={(e) => {
if (e.target === e.currentTarget) (e.currentTarget.querySelector('input') as HTMLInputElement)?.focus();
}}>
{cfg.backup.exclude.map((t) => (
<span className="tagf-chip mono" key={t}>
{t}
<button
className="tagf-x"
aria-label={`Stop skipping ${t}`}
onClick={() => setCfg({ ...cfg, backup: { ...cfg.backup, exclude: cfg.backup.exclude.filter((x) => x !== t) } })}
>×</button>
</span>
))}
<input
className="tagf-input mono"
placeholder={cfg.backup.exclude.length ? 'add another…' : 'node_modules'}
value={skipDraft}
onChange={(e) => 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}
/>
</div>
</>
)}
{/* 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 && (
<button
className="btn-ghost"
style={{ marginTop: 8 }}
disabled={bkBusy || bk.running}
onClick={doBackup}
>
{bkBusy ? 'Launching…' : bk.running ? 'Backing up…' : 'Back up now'}
</button>
)}
{bkMsg && <div className="s-help" style={{ marginTop: 6 }}>{bkMsg}</div>}

<div className="setting-row">
<div>
<div className="s-label">Archive inactive sessions</div>
Expand Down
7 changes: 7 additions & 0 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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; }
Expand Down