dashboard: edit-form target multi-select (#474 follow-up)

mara confirmed target editing on q #195 ("also let me edit targets").
Damocles wired `targets_add` / `targets_remove` onto PATCH
/api/schedules/{id} in #478. UI side: the schedule edit form's
read-only targets callout becomes a multi-select checkbox box
(same chip styling as the new-schedule form) pre-checked for
currently-active targets.

Submit logic:

- diff new selection against `originalActiveTargets` to populate
  `targets_add` (checked, not originally active) and
  `targets_remove` (originally active, now unchecked)
- only include each key in the PATCH body when non-empty
- guard against zero-target submission with a clear alert
  pointing the operator at `✕ cancel all` as the intended path
- already-active targets that aren't in the live candidate list
  (e.g. a since-destroyed container) still surface so the
  operator can intentionally drop them

Note (re-add semantics): backend's replace-on-conflict drops
the cancelled-target tombstone + history on re-add — per the
design discussion with damocles, operator intent on re-adding
reads as "fresh start, target is active again." UI copy below
the chip row reflects this.

Carry persistence extended to the `targets` array so the
checkbox state survives a state-poll re-render mid-edit;
listens on `change` in addition to `input` for checkbox events.
This commit is contained in:
iris 2026-05-26 16:18:01 +02:00
commit befe5c0523
2 changed files with 87 additions and 24 deletions

View file

@ -232,8 +232,11 @@ time, and per-target last-result. Per-row controls: a
every active target (#467 — recurring schedules keep their
cadence; one-shots are consumed after the manual fire), an
`✎ edit` button opens an inline edit form (#474 — body /
description / interval / next-fire editable, targets stay
immutable; submit PATCHes `/api/schedules/{id}`), and a
description / interval / next-fire / targets all editable;
targets are a multi-select diff'd against the original active
set so unchecked-was-active = `targets_remove`, checked-not-
originally-active = `targets_add`; submit PATCHes
`/api/schedules/{id}`), and a
`CANC3L` button cancels the whole schedule
(`POST /api/schedules/{id}/cancel`). Individual target chips
have their own cancel links. An inline creation form lets
@ -498,18 +501,19 @@ not ours.
Agent-initiated schedules go through the approval queue instead
(manager MCP `request_schedule_prompt`).
- `PATCH /api/schedules/{id}` — partial edit (#474). JSON body
`{ body?, description?, interval_seconds?, next_fire_at_unix? }`.
`{ body?, description?, interval_seconds?, next_fire_at_unix?,
targets_add?, targets_remove? }`.
Missing key = "leave alone"; explicit `null` on
`description` / `interval_seconds` clears the field (so a
recurring schedule flips to one-shot when `interval_seconds`
is sent as `null`). Targets stay immutable — cancel + new
schedule is the retarget workaround. Refuses cancelled rows;
returns the updated `WireSchedule` on success.
- `POST /api/schedules/{id}/fire-now` — operator-initiated manual
fire (#467). Runs the per-target fan-out once immediately.
Recurring schedules keep their cadence (manual fire is additive);
one-shot schedules are consumed (cancelled afterwards). Manager
surface: `fire_schedule_now(id)` MCP tool.
is sent as `null`). `targets_add` is replace-on-conflict:
re-adding a previously-cancelled target drops the tombstone
and the target starts fresh (operator intent on re-add =
"this target is active again"). `targets_remove` delegates
to the same path as `cancel_targets` — tombstones preserve
audit, parent schedule auto-cancels when no active targets
remain. Refuses cancelled rows; returns the updated
`WireSchedule` on success.
- `POST /api/schedules/{id}/cancel` — cancel a schedule. Body
`{ targets?: ["name", …] }` cancels just those recipients;
absent or empty body cancels the whole schedule.

View file

@ -2480,8 +2480,9 @@ window.marked = marked;
}
intervalCx.updatePreview();
// Persist carry on every input so a refresh repaint preserves it.
form_.addEventListener('input', () => {
// Persist carry on every input + checkbox change so a refresh
// repaint preserves it.
const saveCarry = () => {
const fd = new FormData(form_);
scheduleEditCarry.set(s.id, {
body: String(fd.get('body') || ''),
@ -2491,18 +2492,54 @@ window.marked = marked;
interval_h: String(fd.get('edit_interval_h') || ''),
interval_m: String(fd.get('edit_interval_m') || ''),
interval_s: String(fd.get('edit_interval_s') || ''),
targets: fd.getAll('edit_targets').map(String),
});
});
};
form_.addEventListener('input', saveCarry);
form_.addEventListener('change', saveCarry);
// Targets read-only callout. Per #474 design: targets stay
// immutable, cancel+new is the retarget workaround.
const targetList = (s.targets || [])
.filter((t) => !t.cancelled_at_unix)
.map((t) => t.target)
.join(', ');
// Targets multi-select (#474 fast-follow). Mirrors the new-schedule
// form's chip pattern: live container names + operator + manager,
// pre-checked for whichever targets are currently active. Submit
// diffs against the original set to populate `targets_add` /
// `targets_remove` on the PATCH body. Cancelled tombstones aren't
// listed (re-adding them flows through `targets_add`, which the
// backend replace-on-conflict drops the tombstone for).
const originalActiveTargets = new Set(
(s.targets || []).filter((t) => !t.cancelled_at_unix).map((t) => t.target),
);
const targetsLabel = el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, 'targets'));
const targetsBox = el('div', { class: 'schedule-targets' });
const candidateTargets = ['operator', 'manager'];
const containerNames = Array.from(containersState.values())
.map((c) => c.name)
.filter((n) => n !== 'manager' && n !== 'operator')
.sort();
for (const n of containerNames) candidateTargets.push(n);
// Any already-active target that's NOT in the live candidate list
// (operator's typo, a container that has since been destroyed,
// etc.) still shows so the operator can intentionally drop it.
for (const t of originalActiveTargets) {
if (!candidateTargets.includes(t)) candidateTargets.push(t);
}
const restoredTargets = carry.targets
? new Set(carry.targets)
: originalActiveTargets;
for (const name of candidateTargets) {
const id_ = 'se-' + s.id + '-' + name;
const cb = el('input', {
type: 'checkbox', name: 'edit_targets', value: name, id: id_,
});
if (restoredTargets.has(name)) cb.checked = true;
targetsBox.append(el('label', { class: 'schedule-target-chip', for: id_ },
cb, el('span', {}, name)));
}
targetsLabel.append(targetsBox);
form_.append(targetsLabel);
form_.append(el('p', { class: 'meta schedule-edit-targets-note' },
'targets (' + (targetList || 'none active') + ') are immutable — '
+ 'cancel + new schedule if you need a different recipient set.'));
're-adding a previously cancelled target drops its history and starts fresh; '
+ 'unchecking an active target cancels it.'));
const actions = el('div', { class: 'schedule-actions' });
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '✓ save changes');
@ -2554,9 +2591,29 @@ window.marked = marked;
const intervalTotal = partD + partH + partM + partS;
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
// Compute target diff against the schedule's currently-active set
// (cancelled tombstones don't count). The backend treats
// `targets_add` as replace-on-conflict (re-adding a tombstoned
// target drops its history), so we can just blanket "send the
// checked list as add, send the unchecked-but-was-active list as
// remove."
const newTargets = new Set(fd.getAll('edit_targets').map(String));
const originalActive = new Set(
(s.targets || [])
.filter((t) => !t.cancelled_at_unix)
.map((t) => t.target),
);
if (!newTargets.size) {
alert('schedule must have at least one target — uncheck submit, or use ✕ cancel all instead');
return;
}
const targetsAdd = [...newTargets].filter((t) => !originalActive.has(t));
const targetsRemove = [...originalActive].filter((t) => !newTargets.has(t));
// Build the PATCH body. Only include keys for fields that
// actually changed; explicit `null` clears description / flips
// recurring→one-shot.
// recurring→one-shot. `targets_add` / `targets_remove` only
// populated when non-empty so the wire body stays minimal.
const patch = {};
if (newBody !== s.body) patch.body = newBody;
if (newDescription !== (s.description || '')) {
@ -2568,6 +2625,8 @@ window.marked = marked;
if (newIntervalSeconds !== (s.interval_seconds || null)) {
patch.interval_seconds = newIntervalSeconds;
}
if (targetsAdd.length) patch.targets_add = targetsAdd;
if (targetsRemove.length) patch.targets_remove = targetsRemove;
if (!Object.keys(patch).length) {
// No-op submit. Treat as "close edit form".
editingSchedules.delete(s.id);