From befe5c0523bda49efe139c89db2e1a03a70605a5 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 16:18:01 +0200 Subject: [PATCH] dashboard: edit-form target multi-select (#474 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/web-ui.md | 26 ++++---- frontend/packages/dashboard/src/app.js | 83 ++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index cb52dcf1..fec6d2ec 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -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. diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index 231b0e44..87fb01a9 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -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);