Spaces:
Running
Settings: give the backup block room to read (#44)
Browse filesThree layout fixes to the backup setting, which had grown until the
interval control — the thing the block is actually about — was the
hardest part of it to find.
- The interval segments now sit level with the description instead of
centring against a column that runs a description, a kv table and a
thirty-chip tag field tall. Same rail position, no longer floating
halfway down the block.
- A failed run reports its timestamp and streak on one line; the Job's
message and reason move into the existing (i) tooltip. They were a
paragraph wrapping inside a right-aligned kv value, which pushed the
rest of the table around and made a table worth scanning into prose.
- The skip list, its help text and "Back up now" move out of the label
column to full width. Thirty chips wrapping in half the measure read
as a wall, and the field they belong to is a text input, which wants
the whole line.
No behaviour change: same controls, same handlers, same requests.
Co-authored-by: Agent Manager <agents@agent-manager.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
- web/src/components/SettingsView.tsx +93 -82
- web/src/styles.css +7 -0
|
@@ -403,7 +403,10 @@ export default function SettingsView({
|
|
| 403 |
</span>
|
| 404 |
</div>
|
| 405 |
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
| 407 |
<div>
|
| 408 |
<div className="s-label">Back up the bucket</div>
|
| 409 |
<div className="s-help">
|
|
@@ -447,18 +450,22 @@ export default function SettingsView({
|
|
| 447 |
</div>
|
| 448 |
{/* The dashboard strip says a backup is failing; this says
|
| 449 |
what the Job actually reported, which is what tells you
|
| 450 |
-
whether it is yours to fix.
|
|
|
|
|
|
|
|
|
|
| 451 |
{bk.lastFailure && (
|
| 452 |
<div>
|
| 453 |
<span>Last failure</span>
|
| 454 |
-
<b
|
| 455 |
{new Date(bk.lastFailure.at).toLocaleString()}
|
| 456 |
-
{bk.lastFailure.message ? ` — ${bk.lastFailure.message}` : ''}
|
| 457 |
{bk.failures > 1 ? ` (${bk.failures} in a row)` : ''}
|
| 458 |
-
{bk.lastFailure.reason && (
|
| 459 |
-
<
|
| 460 |
-
|
| 461 |
-
|
|
|
|
|
|
|
| 462 |
)}
|
| 463 |
</b>
|
| 464 |
</div>
|
|
@@ -471,80 +478,6 @@ export default function SettingsView({
|
|
| 471 |
)}
|
| 472 |
</div>
|
| 473 |
)}
|
| 474 |
-
{/* On demand regardless of the interval: taking one backup
|
| 475 |
-
before a risky change should not mean switching on a
|
| 476 |
-
schedule. Disabled while a run is in flight — two Jobs
|
| 477 |
-
uploading to one dataset would race. */}
|
| 478 |
-
{/* Folders to keep out of the history. An env directory is
|
| 479 |
-
thousands of files the backup has to hash and none of them
|
| 480 |
-
worth keeping — measured, that is 7s of work versus 1s. */}
|
| 481 |
-
{bk?.canRunNow && (
|
| 482 |
-
<>
|
| 483 |
-
<div className="s-help skiphead" style={{ marginTop: 10 }}>
|
| 484 |
-
<span>
|
| 485 |
-
Skip these folders — type a name and press Enter. Anywhere they appear
|
| 486 |
-
(<span className="mono">node_modules</span>, <span className="mono">.venv</span>),
|
| 487 |
-
they stay out of the history. Everything here is something a command puts
|
| 488 |
-
back; nothing is skipped for being large. Remove any you want kept.
|
| 489 |
-
</span>
|
| 490 |
-
{/* The default list is long and picked from measurements, so an operator
|
| 491 |
-
who trims it has no way back without retyping 30 names. Hidden when
|
| 492 |
-
the list already matches the defaults, so it is never a no-op —
|
| 493 |
-
compared against the local edit rather than the server's copy, or it
|
| 494 |
-
would linger until the next status poll. */}
|
| 495 |
-
{bk.excludeDefaults?.length > 0
|
| 496 |
-
&& (cfg.backup.exclude.length !== bk.excludeDefaults.length
|
| 497 |
-
|| cfg.backup.exclude.some((t, i) => t !== bk.excludeDefaults[i])) && (
|
| 498 |
-
<button
|
| 499 |
-
className="btn-ghost skiprestore"
|
| 500 |
-
onClick={() => setCfg({ ...cfg, backup: { ...cfg.backup, exclude: [...bk.excludeDefaults] } })}
|
| 501 |
-
>Restore defaults</button>
|
| 502 |
-
)}
|
| 503 |
-
</div>
|
| 504 |
-
<div className="tagf" onClick={(e) => {
|
| 505 |
-
if (e.target === e.currentTarget) (e.currentTarget.querySelector('input') as HTMLInputElement)?.focus();
|
| 506 |
-
}}>
|
| 507 |
-
{cfg.backup.exclude.map((t) => (
|
| 508 |
-
<span className="tagf-chip mono" key={t}>
|
| 509 |
-
{t}
|
| 510 |
-
<button
|
| 511 |
-
className="tagf-x"
|
| 512 |
-
aria-label={`Stop skipping ${t}`}
|
| 513 |
-
onClick={() => setCfg({ ...cfg, backup: { ...cfg.backup, exclude: cfg.backup.exclude.filter((x) => x !== t) } })}
|
| 514 |
-
>×</button>
|
| 515 |
-
</span>
|
| 516 |
-
))}
|
| 517 |
-
<input
|
| 518 |
-
className="tagf-input mono"
|
| 519 |
-
placeholder={cfg.backup.exclude.length ? 'add another…' : 'node_modules'}
|
| 520 |
-
value={skipDraft}
|
| 521 |
-
onChange={(e) => setSkipDraft(e.target.value)}
|
| 522 |
-
onKeyDown={(e) => {
|
| 523 |
-
// Enter or comma commits; Backspace on an empty box
|
| 524 |
-
// takes the last chip back, as tag fields do.
|
| 525 |
-
if (e.key === 'Enter' || e.key === ',') {
|
| 526 |
-
e.preventDefault();
|
| 527 |
-
addSkip();
|
| 528 |
-
} else if (e.key === 'Backspace' && !skipDraft && cfg.backup.exclude.length) {
|
| 529 |
-
setCfg({ ...cfg, backup: { ...cfg.backup, exclude: cfg.backup.exclude.slice(0, -1) } });
|
| 530 |
-
}
|
| 531 |
-
}}
|
| 532 |
-
onBlur={addSkip}
|
| 533 |
-
/>
|
| 534 |
-
</div>
|
| 535 |
-
</>
|
| 536 |
-
)}
|
| 537 |
-
{bk?.canRunNow && (
|
| 538 |
-
<button
|
| 539 |
-
className="btn-ghost"
|
| 540 |
-
style={{ marginTop: 8 }}
|
| 541 |
-
disabled={bkBusy || bk.running}
|
| 542 |
-
onClick={doBackup}
|
| 543 |
-
>
|
| 544 |
-
{bkBusy ? 'Launching…' : bk.running ? 'Backing up…' : 'Back up now'}
|
| 545 |
-
</button>
|
| 546 |
-
)}
|
| 547 |
-
{bkMsg && <div className="s-help" style={{ marginTop: 6 }}>{bkMsg}</div>}
|
| 548 |
</div>
|
| 549 |
<span className="cfg-ctl">
|
| 550 |
<div className="seg cfg-seg">
|
|
@@ -578,6 +511,84 @@ export default function SettingsView({
|
|
| 578 |
</div>
|
| 579 |
)}
|
| 580 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
<div className="setting-row">
|
| 582 |
<div>
|
| 583 |
<div className="s-label">Archive inactive sessions</div>
|
|
|
|
| 403 |
</span>
|
| 404 |
</div>
|
| 405 |
|
| 406 |
+
{/* row-top: the description runs four lines, and the interval is
|
| 407 |
+
the thing you read it for — it belongs level with the text, not
|
| 408 |
+
floating halfway down beside it. */}
|
| 409 |
+
<div className="setting-row row-top">
|
| 410 |
<div>
|
| 411 |
<div className="s-label">Back up the bucket</div>
|
| 412 |
<div className="s-help">
|
|
|
|
| 450 |
</div>
|
| 451 |
{/* The dashboard strip says a backup is failing; this says
|
| 452 |
what the Job actually reported, which is what tells you
|
| 453 |
+
whether it is yours to fix. The message and the Job's own
|
| 454 |
+
reason are a paragraph you read once, so they hang off the
|
| 455 |
+
timestamp as a tooltip rather than wrapping across the
|
| 456 |
+
table and pushing every other row around. */}
|
| 457 |
{bk.lastFailure && (
|
| 458 |
<div>
|
| 459 |
<span>Last failure</span>
|
| 460 |
+
<b className="kv-fail">
|
| 461 |
{new Date(bk.lastFailure.at).toLocaleString()}
|
|
|
|
| 462 |
{bk.failures > 1 ? ` (${bk.failures} in a row)` : ''}
|
| 463 |
+
{(bk.lastFailure.message || bk.lastFailure.reason) && (
|
| 464 |
+
<span
|
| 465 |
+
className="tip"
|
| 466 |
+
tabIndex={0}
|
| 467 |
+
data-tip={[bk.lastFailure.message, bk.lastFailure.reason].filter(Boolean).join(' — ')}
|
| 468 |
+
><InfoGlyph className="tip-i" /></span>
|
| 469 |
)}
|
| 470 |
</b>
|
| 471 |
</div>
|
|
|
|
| 478 |
)}
|
| 479 |
</div>
|
| 480 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
</div>
|
| 482 |
<span className="cfg-ctl">
|
| 483 |
<div className="seg cfg-seg">
|
|
|
|
| 511 |
</div>
|
| 512 |
)}
|
| 513 |
|
| 514 |
+
{/* Folders to keep out of the history. An env directory is
|
| 515 |
+
thousands of files the backup has to hash and none of them
|
| 516 |
+
worth keeping — measured, that is 7s of work versus 1s.
|
| 517 |
+
Full width, outside the row: thirty chips wrapping inside the
|
| 518 |
+
label column read as a wall, and the field they belong to is a
|
| 519 |
+
text input, which wants the whole measure. */}
|
| 520 |
+
{bk?.canRunNow && (
|
| 521 |
+
<>
|
| 522 |
+
<div className="s-help skiphead" style={{ marginTop: 10 }}>
|
| 523 |
+
<span>
|
| 524 |
+
Skip these folders — type a name and press Enter. Anywhere they appear
|
| 525 |
+
(<span className="mono">node_modules</span>, <span className="mono">.venv</span>),
|
| 526 |
+
they stay out of the history. Everything here is something a command puts
|
| 527 |
+
back; nothing is skipped for being large. Remove any you want kept.
|
| 528 |
+
</span>
|
| 529 |
+
{/* The default list is long and picked from measurements, so an operator
|
| 530 |
+
who trims it has no way back without retyping 30 names. Hidden when
|
| 531 |
+
the list already matches the defaults, so it is never a no-op —
|
| 532 |
+
compared against the local edit rather than the server's copy, or it
|
| 533 |
+
would linger until the next status poll. */}
|
| 534 |
+
{bk.excludeDefaults?.length > 0
|
| 535 |
+
&& (cfg.backup.exclude.length !== bk.excludeDefaults.length
|
| 536 |
+
|| cfg.backup.exclude.some((t, i) => t !== bk.excludeDefaults[i])) && (
|
| 537 |
+
<button
|
| 538 |
+
className="btn-ghost skiprestore"
|
| 539 |
+
onClick={() => setCfg({ ...cfg, backup: { ...cfg.backup, exclude: [...bk.excludeDefaults] } })}
|
| 540 |
+
>Restore defaults</button>
|
| 541 |
+
)}
|
| 542 |
+
</div>
|
| 543 |
+
<div className="tagf" onClick={(e) => {
|
| 544 |
+
if (e.target === e.currentTarget) (e.currentTarget.querySelector('input') as HTMLInputElement)?.focus();
|
| 545 |
+
}}>
|
| 546 |
+
{cfg.backup.exclude.map((t) => (
|
| 547 |
+
<span className="tagf-chip mono" key={t}>
|
| 548 |
+
{t}
|
| 549 |
+
<button
|
| 550 |
+
className="tagf-x"
|
| 551 |
+
aria-label={`Stop skipping ${t}`}
|
| 552 |
+
onClick={() => setCfg({ ...cfg, backup: { ...cfg.backup, exclude: cfg.backup.exclude.filter((x) => x !== t) } })}
|
| 553 |
+
>×</button>
|
| 554 |
+
</span>
|
| 555 |
+
))}
|
| 556 |
+
<input
|
| 557 |
+
className="tagf-input mono"
|
| 558 |
+
placeholder={cfg.backup.exclude.length ? 'add another…' : 'node_modules'}
|
| 559 |
+
value={skipDraft}
|
| 560 |
+
onChange={(e) => setSkipDraft(e.target.value)}
|
| 561 |
+
onKeyDown={(e) => {
|
| 562 |
+
// Enter or comma commits; Backspace on an empty box
|
| 563 |
+
// takes the last chip back, as tag fields do.
|
| 564 |
+
if (e.key === 'Enter' || e.key === ',') {
|
| 565 |
+
e.preventDefault();
|
| 566 |
+
addSkip();
|
| 567 |
+
} else if (e.key === 'Backspace' && !skipDraft && cfg.backup.exclude.length) {
|
| 568 |
+
setCfg({ ...cfg, backup: { ...cfg.backup, exclude: cfg.backup.exclude.slice(0, -1) } });
|
| 569 |
+
}
|
| 570 |
+
}}
|
| 571 |
+
onBlur={addSkip}
|
| 572 |
+
/>
|
| 573 |
+
</div>
|
| 574 |
+
</>
|
| 575 |
+
)}
|
| 576 |
+
{/* On demand regardless of the interval: taking one backup
|
| 577 |
+
before a risky change should not mean switching on a
|
| 578 |
+
schedule. Disabled while a run is in flight — two Jobs
|
| 579 |
+
uploading to one dataset would race. */}
|
| 580 |
+
{bk?.canRunNow && (
|
| 581 |
+
<button
|
| 582 |
+
className="btn-ghost"
|
| 583 |
+
style={{ marginTop: 8 }}
|
| 584 |
+
disabled={bkBusy || bk.running}
|
| 585 |
+
onClick={doBackup}
|
| 586 |
+
>
|
| 587 |
+
{bkBusy ? 'Launching…' : bk.running ? 'Backing up…' : 'Back up now'}
|
| 588 |
+
</button>
|
| 589 |
+
)}
|
| 590 |
+
{bkMsg && <div className="s-help" style={{ marginTop: 6 }}>{bkMsg}</div>}
|
| 591 |
+
|
| 592 |
<div className="setting-row">
|
| 593 |
<div>
|
| 594 |
<div className="s-label">Archive inactive sessions</div>
|
|
@@ -871,6 +871,9 @@ body {
|
|
| 871 |
.settings-page h3 { margin: 24px 0 8px; font-size: 14px; }
|
| 872 |
/* plain rows, not cards: the bold label is the section header */
|
| 873 |
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 2px; }
|
|
|
|
|
|
|
|
|
|
| 874 |
/* right-side controls share one width so the column reads as one rail */
|
| 875 |
.setting-row > .btn-ghost, .setting-row .confirm-del { flex: none; }
|
| 876 |
/* operator config (artifacts, jobs): every control sits in the same-width
|
|
@@ -936,6 +939,10 @@ a.btn-ghost { text-decoration: none; }
|
|
| 936 |
and only take the accent on hover. */
|
| 937 |
.kv a { color: inherit; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 2px; }
|
| 938 |
.kv a:hover { color: var(--accent); text-decoration-style: solid; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 939 |
/* A kv used inside a setting's description sits tighter than a section-level one. */
|
| 940 |
.kv-inline { margin-top: 8px; }
|
| 941 |
.kv-inline > div { padding: 6px 2px; font-size: 12.5px; }
|
|
|
|
| 871 |
.settings-page h3 { margin: 24px 0 8px; font-size: 14px; }
|
| 872 |
/* plain rows, not cards: the bold label is the section header */
|
| 873 |
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 2px; }
|
| 874 |
+
/* a row whose description runs several lines: the control sits level with the
|
| 875 |
+
text instead of centring against it, so the eye finds it where it starts */
|
| 876 |
+
.setting-row.row-top { align-items: flex-start; }
|
| 877 |
/* right-side controls share one width so the column reads as one rail */
|
| 878 |
.setting-row > .btn-ghost, .setting-row .confirm-del { flex: none; }
|
| 879 |
/* operator config (artifacts, jobs): every control sits in the same-width
|
|
|
|
| 939 |
and only take the accent on hover. */
|
| 940 |
.kv a { color: inherit; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 2px; }
|
| 941 |
.kv a:hover { color: var(--accent); text-decoration-style: solid; }
|
| 942 |
+
/* A failure value: the timestamp is the row, and what went wrong hangs off the
|
| 943 |
+
(i) beside it. overflow is visible because .kv b clips for its ellipsis, and
|
| 944 |
+
that clipping would eat the tooltip card. */
|
| 945 |
+
.kv b.kv-fail { display: inline-flex; align-items: center; gap: 6px; overflow: visible; color: var(--danger); }
|
| 946 |
/* A kv used inside a setting's description sits tighter than a section-level one. */
|
| 947 |
.kv-inline { margin-top: 8px; }
|
| 948 |
.kv-inline > div { padding: 6px 2px; font-size: 12.5px; }
|