dashboard: fold schedule creation into the table view (closes #564)
The N3W SCH3DUL3 vertical form is gone. The schedules table now carries an always-visible inline create row at the bottom — fill the cells, click + to POST /api/schedules, the new schedule appears above on the next refresh. Per-column inputs match the display semantics: - `next` → `<input type="datetime-local">` (defaults to now+5min) - `every` → 4 mini d/h/m/s number inputs (blank/all-zero = one-shot) - `body` → `<textarea rows=1>` expanding to 4em on focus-within so multi-line prompts still fit (closes mara's "prompt may still be multi-line" requirement) - per-agent columns → checkbox (whole cell clickable via padded label) - actions → + submit + ⌫ reset (clears the carry without POSTing) `newScheduleCarry` module-scope object survives the paintAtomic re-render so mid-typed values stick across schedule-list refreshes (same pattern as `scheduleEditCarry` from #474). H2 collapses from "N3W SCH3DUL3" + "QU3U3D SCH3DUL3S" down to a single "SCH3DUL3S" since the section now does both. Empty-state branch on `renderSchedulesList` is gone — the table always renders because the create row is always there. `#schedule-new-section` div + `renderScheduleNewForm` / `readScheduleFormCarry` / `submitNewSchedule` and the `.schedule-new-form` CSS selector all removed. The edit-form path (#474 `renderScheduleEditForm` colspan'd row on `✎` toggle) is unchanged — "edit mode is inline in the table" already shipped there, this PR just adds the same mental model for create. Validations match the old form: empty targets / empty body / invalid datetime / non-integer interval each surface an `alert()`. The + button shows a spinner while the POST is in flight, then clears the carry + triggers `refreshSchedules()`.
This commit is contained in:
parent
c9e1c79035
commit
0deca50352
3 changed files with 341 additions and 157 deletions
|
|
@ -1693,7 +1693,6 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
Cards show: id + source + due-in + cancel-all in the header,
|
||||
the prompt body, then a targets table with per-row cancel. */
|
||||
|
||||
.schedule-new-form,
|
||||
.schedule-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -1924,6 +1923,109 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
padding: 0.1em 0.45em;
|
||||
}
|
||||
|
||||
/* #564 — inline create row at the bottom of the schedules table.
|
||||
Cells host inputs directly so the operator can fill + click + to
|
||||
queue a new schedule without leaving the table view. Slightly
|
||||
different background tone so it reads as "this isn't a schedule
|
||||
yet, it's the create form". */
|
||||
.schedules-table-create-row td {
|
||||
background: rgba(38, 32, 60, 0.25);
|
||||
border-top: 2px solid var(--border);
|
||||
vertical-align: top;
|
||||
padding: 0.4em 0.5em;
|
||||
}
|
||||
.schedules-table-create-cell {
|
||||
padding: 0.35em 0.4em;
|
||||
}
|
||||
.schedules-table-inline-input,
|
||||
.schedules-table-inline-textarea,
|
||||
.schedules-table-inline-num {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
font-family: ui-monospace, 'JetBrains Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.schedules-table-inline-input:focus,
|
||||
.schedules-table-inline-textarea:focus,
|
||||
.schedules-table-inline-num:focus {
|
||||
border-color: var(--purple);
|
||||
outline: none;
|
||||
}
|
||||
.schedules-table-inline-textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 1.6em;
|
||||
resize: vertical;
|
||||
/* Single-line by default; bumps to 4em on focus so a multi-line
|
||||
prompt has room without the row taking permanent vertical space.
|
||||
:focus-within so resizing-up sticks while focus stays in the row. */
|
||||
}
|
||||
.schedules-table-create-body:focus-within .schedules-table-inline-textarea {
|
||||
min-height: 4em;
|
||||
}
|
||||
.schedules-table-inline-desc {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
.schedules-table-inline-datetime {
|
||||
width: 100%;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
.schedules-table-inline-interval {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schedules-table-inline-num {
|
||||
width: 2.4em;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* hide native number spinners — they steal too much width at the
|
||||
2.4em column size and the d/h/m/s buttons aren't useful with no
|
||||
min/max range hint anyway. */
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
.schedules-table-inline-num::-webkit-outer-spin-button,
|
||||
.schedules-table-inline-num::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.schedules-table-inline-unit {
|
||||
color: var(--muted);
|
||||
font-size: 0.7em;
|
||||
margin-right: 0.15em;
|
||||
}
|
||||
.schedules-table-inline-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
.schedules-table-inline-check-lbl {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
padding: 0.4em 0.6em;
|
||||
/* Pad out the label so the entire cell is a click target. */
|
||||
}
|
||||
.schedules-table-create-submit {
|
||||
font-size: 1em;
|
||||
padding: 0.15em 0.55em;
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
.schedules-table-create-reset {
|
||||
font-size: 0.85em;
|
||||
padding: 0.15em 0.45em;
|
||||
color: var(--muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* Selection bar (#443). Sticky-bottom strip that surfaces bulk
|
||||
actions when ≥1 agent is selected (click the icon). Visually
|
||||
echoes the flow composer's frosted-mauve treatment so the chrome
|
||||
|
|
|
|||
|
|
@ -133,26 +133,21 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SCH3DUL3S (#459): scheduled prompts. operator-direct creation
|
||||
form at the top (POST /api/schedules, no approval gate), live
|
||||
schedules list below (GET /api/schedules) with per-target
|
||||
last-fired timestamps + result, plus per-target / whole-row
|
||||
cancel buttons (POST /api/schedules/{id}/cancel). #444 backend
|
||||
doesn't emit a SchedulesChanged dashboard event yet, so the
|
||||
list re-fetches on tab activation + after each form/cancel
|
||||
submit. Live SSE wiring is the future PR C. -->
|
||||
<!-- SCH3DUL3S (#459, #564): scheduled prompts. Creation + edit
|
||||
are folded into the same table now (#564) — empty bottom row
|
||||
is the create form (fill cells, click +), inline-edit-row
|
||||
expands on the `✎` toggle for existing schedules. Live
|
||||
schedules list driven by GET /api/schedules; POST /api/schedules
|
||||
to create, PATCH /api/schedules/{id} to edit, POST
|
||||
/api/schedules/{id}/cancel for per-target / whole-row cancel.
|
||||
#444 backend doesn't emit SchedulesChanged dashboard event yet,
|
||||
so the list re-fetches on tab activation + after each submit /
|
||||
cancel. Live SSE wiring is the future PR C. -->
|
||||
<section class="tab-pane" id="tab-pane-schedules"
|
||||
role="tabpanel" aria-labelledby="tab-schedules">
|
||||
<h2>◆ N3W SCH3DUL3 ◆</h2>
|
||||
<h2>◆ SCH3DUL3S ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">queue a prompt to fire at a future time. operator-direct (no approval gate); recurring when an interval is set. targets are any known agent name or <code>operator</code> / <code>manager</code>.</p>
|
||||
<div id="schedule-new-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<h2>◆ QU3U3D SCH3DUL3S ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">all schedules currently in the table. expand each card to see per-target firing history. cancel a single target with the row button or the whole schedule with <code>✕ cancel all</code>.</p>
|
||||
<p class="meta">all schedules currently in the table. fill the bottom row to queue a new schedule (operator-direct, no approval gate; recurring when an interval is set). cancel a single target with the row button or the whole schedule with <code>✕ cancel all</code>.</p>
|
||||
<div id="schedules-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2028,7 +2028,6 @@ window.marked = marked;
|
|||
});
|
||||
return;
|
||||
}
|
||||
renderScheduleNewForm();
|
||||
renderSchedulesList();
|
||||
}
|
||||
// Active = at least one target still alive (no `cancelled_at_unix`)
|
||||
|
|
@ -2203,97 +2202,50 @@ window.marked = marked;
|
|||
return scheduleField('targets', box);
|
||||
}
|
||||
|
||||
function renderScheduleNewForm() {
|
||||
const liveRoot = $('schedule-new-section');
|
||||
if (!liveRoot) return;
|
||||
// Preserve any field the operator was mid-typing in by reading
|
||||
// current values before we re-render. Only `body` + `description`
|
||||
// are big enough to feel — the others are toggle/datetime/number.
|
||||
// Read from the live root BEFORE the swap so we see current
|
||||
// values (the fragment we build into is fresh).
|
||||
const carry = readScheduleFormCarry(liveRoot);
|
||||
paintAtomic(liveRoot, (root) => {
|
||||
|
||||
const form_ = el('form', { class: 'schedule-new-form' });
|
||||
form_.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
submitNewSchedule(form_);
|
||||
});
|
||||
|
||||
form_.append(buildTargetChips({
|
||||
idPrefix: 'st-',
|
||||
fieldName: 'targets',
|
||||
checked: new Set(carry.targets),
|
||||
}));
|
||||
|
||||
const bodyInput = el('textarea', {
|
||||
name: 'body', rows: '4', required: 'required', placeholder: 'message delivered to each target at fire time',
|
||||
});
|
||||
bodyInput.value = carry.body;
|
||||
form_.append(scheduleField('prompt body', bodyInput));
|
||||
|
||||
const firstFireInput = el('input', {
|
||||
type: 'datetime-local', name: 'first_fire', required: 'required',
|
||||
});
|
||||
// Default to "5 minutes from now" so the operator has a sensible
|
||||
// pre-filled value (also future-positive so worker doesn't fire
|
||||
// immediately on a stale-clock accident).
|
||||
firstFireInput.value = carry.first_fire
|
||||
|| isoForDatetimeLocal(new Date(Date.now() + 5 * 60 * 1000));
|
||||
form_.append(scheduleField('first fire', firstFireInput));
|
||||
|
||||
// Interval composer (#466). See `buildIntervalComposer` below —
|
||||
// shared between the new-schedule form and the edit-schedule form
|
||||
// (#474) so the same chip/d/h/m/s widget works for both flows.
|
||||
const intervalCx = buildIntervalComposer({
|
||||
label: 'interval (blank / all-zero = one-shot)',
|
||||
namePrefix: 'interval_',
|
||||
initialSeconds: 0,
|
||||
});
|
||||
form_.append(intervalCx.wrapper);
|
||||
// Restore from carry. Prefer the new split fields; fall back to
|
||||
// legacy single seconds value if the page was reloaded mid-edit
|
||||
// against an older bundle.
|
||||
if (carry.interval_d || carry.interval_h
|
||||
|| carry.interval_m || carry.interval_s) {
|
||||
intervalCx.setParts({
|
||||
d: carry.interval_d, h: carry.interval_h,
|
||||
m: carry.interval_m, s: carry.interval_s,
|
||||
});
|
||||
} else if (carry.interval) {
|
||||
const legacy = parseInt(carry.interval, 10);
|
||||
if (Number.isFinite(legacy) && legacy > 0) intervalCx.fillFromSeconds(legacy);
|
||||
}
|
||||
intervalCx.updatePreview();
|
||||
|
||||
const descInput = el('input', {
|
||||
type: 'text', name: 'description', placeholder: 'shown on the schedule card',
|
||||
});
|
||||
descInput.value = carry.description;
|
||||
form_.append(scheduleField('description (optional)', descInput));
|
||||
|
||||
const actions = el('div', { class: 'schedule-actions' });
|
||||
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '+ qu3ue prompt');
|
||||
actions.append(submit);
|
||||
form_.append(actions);
|
||||
|
||||
root.append(form_);
|
||||
}); // paintAtomic
|
||||
}
|
||||
function readScheduleFormCarry(root) {
|
||||
return {
|
||||
targets: Array.from(root.querySelectorAll('input[name="targets"]:checked')).map((i) => i.value),
|
||||
body: root.querySelector('textarea[name="body"]')?.value || '',
|
||||
first_fire: root.querySelector('input[name="first_fire"]')?.value || '',
|
||||
// #466: interval is now split into d/h/m/s sub-fields. Keep the
|
||||
// legacy `interval` key in case an older bundle is in the page.
|
||||
interval: root.querySelector('input[name="interval"]')?.value || '',
|
||||
interval_d: root.querySelector('input[name="interval_d"]')?.value || '',
|
||||
interval_h: root.querySelector('input[name="interval_h"]')?.value || '',
|
||||
interval_m: root.querySelector('input[name="interval_m"]')?.value || '',
|
||||
interval_s: root.querySelector('input[name="interval_s"]')?.value || '',
|
||||
description: root.querySelector('input[name="description"]')?.value || '',
|
||||
// #564: inline-create state — survives paintAtomic re-renders. We
|
||||
// refresh the schedules table on tab activate + after every mutation,
|
||||
// and each refresh rebuilds the DOM via `paintAtomic`. The bottom
|
||||
// create row's inputs would lose mid-typing values without this
|
||||
// carry. `readNewScheduleCarryFromDOM` is called BEFORE every
|
||||
// re-render so the carry sees the latest user input; the row
|
||||
// builders then pre-fill from `newScheduleCarry`.
|
||||
const newScheduleCarry = {
|
||||
targets: new Set(),
|
||||
body: '',
|
||||
description: '',
|
||||
first_fire: '',
|
||||
interval_d: '',
|
||||
interval_h: '',
|
||||
interval_m: '',
|
||||
interval_s: '',
|
||||
};
|
||||
function readNewScheduleCarryFromDOM(tr) {
|
||||
if (!tr) return;
|
||||
const get = (sel) => tr.querySelector(sel);
|
||||
const checked = Array.from(tr.querySelectorAll('input[name="new_targets"]:checked'))
|
||||
.map((i) => i.value);
|
||||
newScheduleCarry.targets = new Set(checked);
|
||||
const setIfDefined = (key, sel) => {
|
||||
const el_ = get(sel);
|
||||
if (el_) newScheduleCarry[key] = el_.value || '';
|
||||
};
|
||||
setIfDefined('body', 'textarea[name="new_body"]');
|
||||
setIfDefined('description', 'input[name="new_description"]');
|
||||
setIfDefined('first_fire', 'input[name="new_first_fire"]');
|
||||
setIfDefined('interval_d', 'input[name="new_interval_d"]');
|
||||
setIfDefined('interval_h', 'input[name="new_interval_h"]');
|
||||
setIfDefined('interval_m', 'input[name="new_interval_m"]');
|
||||
setIfDefined('interval_s', 'input[name="new_interval_s"]');
|
||||
}
|
||||
function resetNewScheduleCarry() {
|
||||
newScheduleCarry.targets = new Set();
|
||||
newScheduleCarry.body = '';
|
||||
newScheduleCarry.description = '';
|
||||
newScheduleCarry.first_fire = '';
|
||||
newScheduleCarry.interval_d = '';
|
||||
newScheduleCarry.interval_h = '';
|
||||
newScheduleCarry.interval_m = '';
|
||||
newScheduleCarry.interval_s = '';
|
||||
}
|
||||
function isoForDatetimeLocal(date) {
|
||||
// `<input type="datetime-local">` expects `YYYY-MM-DDTHH:MM`
|
||||
|
|
@ -2303,53 +2255,6 @@ window.marked = marked;
|
|||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
async function submitNewSchedule(form_) {
|
||||
const fd = new FormData(form_);
|
||||
const targets = fd.getAll('targets').map(String);
|
||||
const body = String(fd.get('body') || '').trim();
|
||||
const firstFireStr = String(fd.get('first_fire') || '');
|
||||
const description = String(fd.get('description') || '').trim();
|
||||
|
||||
if (!targets.length) { alert('schedule must have at least one target'); return; }
|
||||
if (!body) { alert('prompt body must be non-empty'); return; }
|
||||
if (!firstFireStr) { alert('first fire timestamp is required'); return; }
|
||||
const firstFireDate = new Date(firstFireStr);
|
||||
if (Number.isNaN(firstFireDate.getTime())) { alert('first fire is not a valid datetime'); return; }
|
||||
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
|
||||
// Interval composer (#466): total seconds from d/h/m/s sub-fields.
|
||||
// `0` means one-shot at the wire level (backend expects null).
|
||||
const intervalTotal = intervalSecondsFromFormData(fd, 'interval_');
|
||||
if (Number.isNaN(intervalTotal)) {
|
||||
alert('interval fields must be non-negative integers (or blank for one-shot)');
|
||||
return;
|
||||
}
|
||||
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
|
||||
const payload = { targets, body, first_fire_at_unix };
|
||||
if (interval_seconds != null) payload.interval_seconds = interval_seconds;
|
||||
if (description) payload.description = description;
|
||||
const submitBtn = form_.querySelector('button[type="submit"]');
|
||||
const originalLabel = submitBtn ? submitBtn.innerHTML : '';
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<span class="spinner">◐</span> queueing…'; }
|
||||
try {
|
||||
const resp = await fetch('/api/schedules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
alert('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||||
return;
|
||||
}
|
||||
// Clear the form (form_ is about to be re-rendered fresh below).
|
||||
form_.reset();
|
||||
await refreshSchedules();
|
||||
} catch (err) {
|
||||
alert('schedule submit failed: ' + err);
|
||||
} finally {
|
||||
if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = originalLabel; }
|
||||
}
|
||||
}
|
||||
// #535: schedules render as a single table — one row per schedule,
|
||||
// attribute columns + one ✓/✕ column per agent (tilted 45° header
|
||||
// so a row of agents takes ~28px each instead of full word width),
|
||||
|
|
@ -2358,11 +2263,12 @@ window.marked = marked;
|
|||
function renderSchedulesList() {
|
||||
const liveRoot = $('schedules-section');
|
||||
if (!liveRoot) return;
|
||||
// #564: capture any mid-typed inline-create state BEFORE
|
||||
// paintAtomic blows the row away so a refresh doesn't yank the
|
||||
// operator's half-filled form. The carry is replayed by
|
||||
// `renderInlineCreateRow` below.
|
||||
readNewScheduleCarryFromDOM(liveRoot.querySelector('.schedules-table-create-row'));
|
||||
paintAtomic(liveRoot, (root) => {
|
||||
if (!schedulesState.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no schedules queued'));
|
||||
return;
|
||||
}
|
||||
const agents = schedulesTableAgentSet();
|
||||
const table = el('table', { class: 'schedules-table' });
|
||||
table.append(renderSchedulesTableHead(agents));
|
||||
|
|
@ -2379,10 +2285,192 @@ window.marked = marked;
|
|||
tbody.append(renderScheduleEditRow(s, agents));
|
||||
}
|
||||
}
|
||||
// #564: always-visible inline create row at the bottom of the
|
||||
// table — fill cells + click + to POST. Folds the old
|
||||
// `#schedule-new-section` form into the same surface as the
|
||||
// schedules list so creation and display share one mental model.
|
||||
tbody.append(renderInlineCreateRow(agents));
|
||||
table.append(tbody);
|
||||
root.append(table);
|
||||
});
|
||||
}
|
||||
// #564: inline create row. Each column carries an input matching
|
||||
// its display semantics (datetime-local for next-fire, mini d/h/m/s
|
||||
// number inputs for every, textarea for body, checkbox per agent
|
||||
// column for targets). Submitting POSTs `/api/schedules` and clears
|
||||
// the carry on success; the next `refreshSchedules` redraws the
|
||||
// table with the new schedule above.
|
||||
function renderInlineCreateRow(agents) {
|
||||
const tr = el('tr', { class: 'schedules-table-create-row' });
|
||||
|
||||
tr.append(el('td', { class: 'meta schedules-table-id' }, 'new'));
|
||||
tr.append(el('td', { class: 'meta' }, '—'));
|
||||
|
||||
const nextInput = el('input', {
|
||||
type: 'datetime-local',
|
||||
name: 'new_first_fire',
|
||||
class: 'schedules-table-inline-input schedules-table-inline-datetime',
|
||||
required: 'required',
|
||||
title: 'first fire time (defaults to 5 minutes from now)',
|
||||
});
|
||||
// Default: 5 minutes from now so a stale-clock or quick-submit
|
||||
// accident doesn't fire immediately on `now()`.
|
||||
nextInput.value = newScheduleCarry.first_fire
|
||||
|| isoForDatetimeLocal(new Date(Date.now() + 5 * 60 * 1000));
|
||||
tr.append(el('td', { class: 'schedules-table-create-cell' }, nextInput));
|
||||
|
||||
const intervalRow = el('div', {
|
||||
class: 'schedules-table-inline-interval',
|
||||
title: 'recurring every D days H hours M minutes S seconds (all blank / zero = one-shot)',
|
||||
});
|
||||
const mkUnit = (suffix, unit) => {
|
||||
const inp = el('input', {
|
||||
type: 'number',
|
||||
name: 'new_interval_' + suffix,
|
||||
min: '0',
|
||||
step: '1',
|
||||
placeholder: '0',
|
||||
class: 'schedules-table-inline-num',
|
||||
'aria-label': 'interval ' + suffix,
|
||||
});
|
||||
inp.value = newScheduleCarry['interval_' + suffix] || '';
|
||||
intervalRow.append(inp, el('span', { class: 'schedules-table-inline-unit' }, unit));
|
||||
};
|
||||
mkUnit('d', 'd');
|
||||
mkUnit('h', 'h');
|
||||
mkUnit('m', 'm');
|
||||
mkUnit('s', 's');
|
||||
tr.append(el('td', { class: 'schedules-table-create-cell' }, intervalRow));
|
||||
|
||||
tr.append(el('td', { class: 'meta' }, 'operator'));
|
||||
|
||||
const bodyTa = el('textarea', {
|
||||
name: 'new_body',
|
||||
rows: '1',
|
||||
required: 'required',
|
||||
placeholder: 'prompt body (required, multi-line ok)',
|
||||
class: 'schedules-table-inline-textarea',
|
||||
});
|
||||
bodyTa.value = newScheduleCarry.body;
|
||||
const descInput = el('input', {
|
||||
type: 'text',
|
||||
name: 'new_description',
|
||||
placeholder: 'description (optional)',
|
||||
class: 'schedules-table-inline-input schedules-table-inline-desc',
|
||||
});
|
||||
descInput.value = newScheduleCarry.description;
|
||||
tr.append(el('td', { class: 'schedules-table-create-cell schedules-table-create-body' },
|
||||
bodyTa, descInput));
|
||||
|
||||
// Per-agent target checkboxes — one cell per agent column. Wrap
|
||||
// each checkbox in a label so the whole cell area is clickable.
|
||||
for (const a of agents) {
|
||||
const td = el('td', { class: 'schedules-table-check schedules-table-create-cell' });
|
||||
const id_ = 'new-target-' + a;
|
||||
const cb = el('input', {
|
||||
type: 'checkbox',
|
||||
name: 'new_targets',
|
||||
value: a,
|
||||
id: id_,
|
||||
class: 'schedules-table-inline-check',
|
||||
});
|
||||
if (newScheduleCarry.targets.has(a)) cb.checked = true;
|
||||
const lbl = el('label', {
|
||||
for: id_,
|
||||
class: 'schedules-table-inline-check-lbl',
|
||||
title: 'tick to target ' + a,
|
||||
}, cb);
|
||||
td.append(lbl);
|
||||
tr.append(td);
|
||||
}
|
||||
|
||||
// Actions cell — submit button + reset.
|
||||
const actionsCell = el('td', { class: 'schedules-table-actions' });
|
||||
const submitBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'btn btn-spawn btn-inline-small schedules-table-create-submit',
|
||||
title: 'queue this new schedule',
|
||||
}, '+');
|
||||
submitBtn.addEventListener('click', () => submitNewScheduleInline(tr, submitBtn));
|
||||
const resetBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'btn btn-inline-small schedules-table-create-reset',
|
||||
title: 'clear all fields',
|
||||
}, '⌫');
|
||||
resetBtn.addEventListener('click', () => {
|
||||
resetNewScheduleCarry();
|
||||
renderSchedulesList();
|
||||
});
|
||||
actionsCell.append(submitBtn, resetBtn);
|
||||
tr.append(actionsCell);
|
||||
|
||||
return tr;
|
||||
}
|
||||
async function submitNewScheduleInline(tr, submitBtn) {
|
||||
const targets = Array.from(tr.querySelectorAll('input[name="new_targets"]:checked'))
|
||||
.map((i) => i.value);
|
||||
const body = String(tr.querySelector('textarea[name="new_body"]')?.value || '').trim();
|
||||
const description = String(tr.querySelector('input[name="new_description"]')?.value || '').trim();
|
||||
const firstFireStr = String(tr.querySelector('input[name="new_first_fire"]')?.value || '');
|
||||
|
||||
if (!targets.length) {
|
||||
alert('schedule must have at least one target — tick at least one agent column');
|
||||
return;
|
||||
}
|
||||
if (!body) { alert('prompt body must be non-empty'); return; }
|
||||
if (!firstFireStr) { alert('first fire timestamp is required'); return; }
|
||||
const firstFireDate = new Date(firstFireStr);
|
||||
if (Number.isNaN(firstFireDate.getTime())) {
|
||||
alert('first fire is not a valid datetime'); return;
|
||||
}
|
||||
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
|
||||
|
||||
// Parse the d/h/m/s parts inline — we don't use FormData since
|
||||
// the inline row isn't wrapped in a <form>. Mirrors
|
||||
// `intervalSecondsFromFormData` semantics: blank/0 = one-shot,
|
||||
// anything non-integer or negative → NaN → alert.
|
||||
const part = (suffix, mult) => {
|
||||
const raw = String(tr.querySelector(`input[name="new_interval_${suffix}"]`)?.value || '').trim();
|
||||
if (!raw) return 0;
|
||||
const n = parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 0) return NaN;
|
||||
return n * mult;
|
||||
};
|
||||
const intervalTotal = part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1);
|
||||
if (Number.isNaN(intervalTotal)) {
|
||||
alert('interval fields must be non-negative integers (or blank for one-shot)');
|
||||
return;
|
||||
}
|
||||
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
|
||||
|
||||
const payload = { targets, body, first_fire_at_unix };
|
||||
if (interval_seconds != null) payload.interval_seconds = interval_seconds;
|
||||
if (description) payload.description = description;
|
||||
|
||||
const originalLabel = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner">◐</span>';
|
||||
try {
|
||||
const resp = await fetch('/api/schedules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
alert('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||||
return;
|
||||
}
|
||||
// Reset carry so the next render shows an empty row.
|
||||
resetNewScheduleCarry();
|
||||
await refreshSchedules();
|
||||
} catch (err) {
|
||||
alert('schedule submit failed: ' + err);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalLabel;
|
||||
}
|
||||
}
|
||||
// The set of agent columns in the schedules table: operator + manager
|
||||
// first, then live containers (sorted), then any extra names that
|
||||
// appear as a schedule target but aren't in the live container list
|
||||
|
|
@ -2874,7 +2962,6 @@ window.marked = marked;
|
|||
'meta-inputs-section',
|
||||
'rebuild-queue-section',
|
||||
'reminders-section',
|
||||
'schedule-new-section',
|
||||
'schedules-section',
|
||||
];
|
||||
// <details> sections that should survive a refresh need a stable
|
||||
|
|
|
|||
Loading…
Reference in a new issue