dashboard: scheduled prompts tab + creation form (closes #459)
frontend for the #444 scheduled-prompts feature. backend is already merged (PR #454 + sibling commits): GET /api/schedules (snapshot), POST /api/schedules (operator-direct submit), POST /api/schedules/{id}/cancel (whole or per-target). new chrome - SCH3DUL3S tab in the dashboard tab strip, between SYST3M and the FL0W link. count pill shows the number of schedules with at least one still-active target. - pane has two sections: N3W SCH3DUL3 (creation form) + QU3U3D SCH3DUL3S (list of cards). creation form - targets multi-select rendered as chip-style checkboxes; candidates pulled from containersState plus the special `operator` and `manager` recipients. - prompt body textarea (required, non-empty trim). - first-fire datetime-local input, defaulted to "5 minutes from now" so the form has a sensible pre-filled future timestamp. - optional interval (seconds) input — blank = one-shot. - optional description (one-liner shown on the schedule card). - mid-typing carry: re-rendering the form preserves field values + checkbox state. the operator never loses what they were typing when the schedule list refreshes underneath them. - POSTs SchedulePromptPayload JSON to /api/schedules; on success re-fetches the list to surface the new row. schedules list - one card per schedule, active rows first (sorted by next_fire_at_unix), cancelled tail dimmed. - header: id, source chip (`operator` or `approval` — reuses the rebuild-queue rqe-source styling for visual consistency), next-fire countdown / overdue label, recurring vs one-shot badge, owner. - body: prompt text in a styled <pre>-ish block with linkified path references. - per-target table: target name, last fire age, last result, per-row cancel button. - whole-schedule "✕ cancel all" button. - per-target cancel posts { targets: ["name"] }; cancel-all posts no body (== cancel whole row). no-SSE refresh - the backend doesn't emit SchedulesChanged dashboard events yet (damocles flagged this as a follow-up PR C). list re-fetches on: - tab activation (so switching to SCH3DUL3S never lands stale) - cold load via refreshState - after every submit + cancel POST the operator's typical interactions all force a refresh; the remaining gap (worker fires while you're staring at the tab) is the natural argument for PR C. files - frontend/packages/dashboard/src/index.html — new tab + pane with the two sections. - frontend/packages/dashboard/src/app.js — schedulesState cache, refreshSchedules, render*ScheduleNewForm, submit + cancel helpers, tab routing extended for `schedules`, count pill wired into refreshTabCounts. - frontend/packages/dashboard/src/dashboard.css — schedule form + card styling + chip checkboxes + targets table. small .btn-inline-small helper for the per-target cancel. validation - npm run build --workspace=@hive/dashboard clean. app.js 158 kb → 161 kb. CSS 41.3 kb → 43.9 kb. - browser smoke test isn't possible from inside iris's container; endpoints are wire-compatible (backend types unchanged) and the form serialisation matches SchedulePromptPayload's JSON shape exactly (targets / body / first_fire_at_unix / interval_seconds / description).
This commit is contained in:
parent
2593896383
commit
c233f8a924
3 changed files with 495 additions and 1 deletions
|
|
@ -1918,6 +1918,330 @@ window.marked = marked;
|
|||
return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
|
||||
}
|
||||
|
||||
// ─── scheduled prompts (#459) ──────────────────────────────────────────
|
||||
// Backend (#444) exposes `/api/schedules` (snapshot), `/api/schedules`
|
||||
// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
|
||||
// (whole or per-target). No SSE channel for schedule mutations yet,
|
||||
// so we refresh on tab activation + after every submit/cancel POST.
|
||||
// Local cache lets `refreshTabCounts` show the active count without
|
||||
// re-fetching every second.
|
||||
let schedulesState = [];
|
||||
async function refreshSchedules() {
|
||||
const listRoot = $('schedules-section');
|
||||
if (!listRoot) return;
|
||||
try {
|
||||
const resp = await fetch('/api/schedules');
|
||||
if (!resp.ok) {
|
||||
listRoot.innerHTML = '';
|
||||
listRoot.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
|
||||
return;
|
||||
}
|
||||
schedulesState = await resp.json();
|
||||
} catch (err) {
|
||||
listRoot.innerHTML = '';
|
||||
listRoot.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
|
||||
return;
|
||||
}
|
||||
renderScheduleNewForm();
|
||||
renderSchedulesList();
|
||||
}
|
||||
// Active = at least one target still alive (no `cancelled_at_unix`)
|
||||
// AND the whole schedule isn't cancelled. Drives the tab pill count.
|
||||
function activeScheduleCount() {
|
||||
let n = 0;
|
||||
for (const s of schedulesState) {
|
||||
if (s.cancelled_at_unix) continue;
|
||||
if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
function renderScheduleNewForm() {
|
||||
const root = $('schedule-new-section');
|
||||
if (!root) 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.
|
||||
const carry = readScheduleFormCarry(root);
|
||||
root.innerHTML = '';
|
||||
|
||||
// Targets multi-select pulls from the live containers list. The
|
||||
// backend accepts any string (forward-compat for richer recipients),
|
||||
// so we also surface the special `operator` and `manager` names
|
||||
// as fixed options on top.
|
||||
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);
|
||||
|
||||
const form_ = el('form', { class: 'schedule-new-form' });
|
||||
form_.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
submitNewSchedule(form_);
|
||||
});
|
||||
|
||||
const targetsLabel = el('label', { class: 'schedule-field' },
|
||||
el('span', { class: 'schedule-field-label' }, 'targets'));
|
||||
const targetsBox = el('div', { class: 'schedule-targets' });
|
||||
const restoredTargets = new Set(carry.targets);
|
||||
for (const name of candidateTargets) {
|
||||
const id_ = 'st-' + name;
|
||||
const cb = el('input', {
|
||||
type: 'checkbox', name: '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);
|
||||
|
||||
const bodyLabel = el('label', { class: 'schedule-field' },
|
||||
el('span', { class: 'schedule-field-label' }, 'prompt body'));
|
||||
const bodyInput = el('textarea', {
|
||||
name: 'body', rows: '4', required: 'required', placeholder: 'message delivered to each target at fire time',
|
||||
});
|
||||
bodyInput.value = carry.body;
|
||||
bodyLabel.append(bodyInput);
|
||||
form_.append(bodyLabel);
|
||||
|
||||
const firstFireLabel = el('label', { class: 'schedule-field' },
|
||||
el('span', { class: 'schedule-field-label' }, 'first fire'));
|
||||
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));
|
||||
firstFireLabel.append(firstFireInput);
|
||||
form_.append(firstFireLabel);
|
||||
|
||||
const intervalLabel = el('label', { class: 'schedule-field' },
|
||||
el('span', { class: 'schedule-field-label' }, 'interval (seconds; blank = one-shot)'));
|
||||
const intervalInput = el('input', {
|
||||
type: 'number', name: 'interval', min: '1', step: '1', placeholder: 'e.g. 3600 for hourly',
|
||||
});
|
||||
intervalInput.value = carry.interval;
|
||||
intervalLabel.append(intervalInput);
|
||||
form_.append(intervalLabel);
|
||||
|
||||
const descLabel = el('label', { class: 'schedule-field' },
|
||||
el('span', { class: 'schedule-field-label' }, 'description (optional)'));
|
||||
const descInput = el('input', {
|
||||
type: 'text', name: 'description', placeholder: 'shown on the schedule card',
|
||||
});
|
||||
descInput.value = carry.description;
|
||||
descLabel.append(descInput);
|
||||
form_.append(descLabel);
|
||||
|
||||
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_);
|
||||
}
|
||||
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 || '',
|
||||
interval: root.querySelector('input[name="interval"]')?.value || '',
|
||||
description: root.querySelector('input[name="description"]')?.value || '',
|
||||
};
|
||||
}
|
||||
function isoForDatetimeLocal(date) {
|
||||
// `<input type="datetime-local">` expects `YYYY-MM-DDTHH:MM`
|
||||
// in the user's local timezone (with no trailing Z). Build it
|
||||
// by hand rather than slicing toISOString which is always UTC.
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
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 intervalStr = String(fd.get('interval') || '').trim();
|
||||
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);
|
||||
let interval_seconds = null;
|
||||
if (intervalStr) {
|
||||
const parsed = parseInt(intervalStr, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
alert('interval must be a positive integer (or blank for one-shot)');
|
||||
return;
|
||||
}
|
||||
interval_seconds = parsed;
|
||||
}
|
||||
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; }
|
||||
}
|
||||
}
|
||||
function renderSchedulesList() {
|
||||
const root = $('schedules-section');
|
||||
if (!root) return;
|
||||
root.innerHTML = '';
|
||||
if (!schedulesState.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no schedules queued'));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'schedules' });
|
||||
// Active schedules first (still firing), then cancelled tail.
|
||||
const sorted = schedulesState.slice().sort((a, b) => {
|
||||
const aDone = a.cancelled_at_unix ? 1 : 0;
|
||||
const bDone = b.cancelled_at_unix ? 1 : 0;
|
||||
if (aDone !== bDone) return aDone - bDone;
|
||||
return a.next_fire_at_unix - b.next_fire_at_unix;
|
||||
});
|
||||
for (const s of sorted) ul.append(renderScheduleCard(s));
|
||||
root.append(ul);
|
||||
}
|
||||
function renderScheduleCard(s) {
|
||||
const cancelled = !!s.cancelled_at_unix;
|
||||
const li = el('li', { class: 'schedule-row' + (cancelled ? ' schedule-cancelled' : '') });
|
||||
const head = el('div', { class: 'schedule-head' });
|
||||
head.append(
|
||||
el('span', { class: 'meta' }, '#' + s.id), ' ',
|
||||
el('span', { class: 'rqe-source rqe-source-' + (s.source && s.source.kind === 'approval' ? 'approval' : 'manual') },
|
||||
s.source && s.source.kind === 'approval' ? 'approval' : 'operator'),
|
||||
);
|
||||
if (cancelled) {
|
||||
head.append(' ', el('span', { class: 'badge badge-muted', title: 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString() }, 'cancelled'));
|
||||
} else {
|
||||
const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000);
|
||||
const dueLabel = dueIn <= 0
|
||||
? `overdue ${fmtAgo(s.next_fire_at_unix)}`
|
||||
: `next fire in ${fmtDuration(dueIn)}`;
|
||||
head.append(' ', el('span', {
|
||||
class: 'meta',
|
||||
title: new Date(s.next_fire_at_unix * 1000).toISOString(),
|
||||
}, dueLabel));
|
||||
}
|
||||
if (s.interval_seconds) {
|
||||
head.append(' ', el('span',
|
||||
{ class: 'badge badge-muted', title: 'recurring' },
|
||||
`↻ every ${fmtDuration(s.interval_seconds)}`));
|
||||
} else {
|
||||
head.append(' ', el('span', { class: 'badge badge-muted', title: 'one-shot' }, 'one-shot'));
|
||||
}
|
||||
head.append(' ', el('span', { class: 'meta' }, '· owner ' + s.owner));
|
||||
li.append(head);
|
||||
|
||||
if (s.description) {
|
||||
li.append(el('div', { class: 'schedule-description' }, s.description));
|
||||
}
|
||||
const body = el('div', { class: 'schedule-body' });
|
||||
appendLinkified(body, s.body);
|
||||
li.append(body);
|
||||
|
||||
// Targets table — one row per recipient with cancel button +
|
||||
// last-fire metadata.
|
||||
const targets = s.targets || [];
|
||||
if (targets.length) {
|
||||
const table = el('table', { class: 'schedule-targets-table' });
|
||||
const thead = el('thead', {}, el('tr', {},
|
||||
el('th', {}, 'target'),
|
||||
el('th', {}, 'last fire'),
|
||||
el('th', {}, 'last result'),
|
||||
el('th', {}, ''),
|
||||
));
|
||||
table.append(thead);
|
||||
const tbody = el('tbody', {});
|
||||
for (const t of targets) {
|
||||
const tCancelled = !!t.cancelled_at_unix;
|
||||
const tr = el('tr', { class: tCancelled ? 'schedule-target-cancelled' : '' });
|
||||
tr.append(el('td', {}, t.target));
|
||||
tr.append(el('td', { class: 'meta' },
|
||||
t.last_fired_at_unix
|
||||
? fmtAgo(t.last_fired_at_unix) + ' ago'
|
||||
: '—'));
|
||||
tr.append(el('td', { class: 'meta' }, t.last_result || '—'));
|
||||
const actionTd = el('td', {});
|
||||
if (!cancelled && !tCancelled) {
|
||||
const btn = el('button', { type: 'button', class: 'btn btn-deny btn-inline-small' }, '✕');
|
||||
btn.title = 'cancel just this target';
|
||||
btn.addEventListener('click', () => cancelScheduleTargets(s.id, [t.target]));
|
||||
actionTd.append(btn);
|
||||
} else if (tCancelled) {
|
||||
actionTd.append(el('span', { class: 'meta' }, '✕ cancelled'));
|
||||
}
|
||||
tr.append(actionTd);
|
||||
tbody.append(tr);
|
||||
}
|
||||
table.append(tbody);
|
||||
li.append(table);
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
const actions = el('div', { class: 'schedule-actions' });
|
||||
const cancelAll = el('button', { type: 'button', class: 'btn btn-deny' }, '✕ cancel all');
|
||||
cancelAll.title = 'cancel the whole schedule';
|
||||
cancelAll.addEventListener('click', () => cancelScheduleAll(s.id));
|
||||
actions.append(cancelAll);
|
||||
li.append(actions);
|
||||
}
|
||||
return li;
|
||||
}
|
||||
async function cancelScheduleAll(id) {
|
||||
if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return;
|
||||
await postScheduleCancel(id, null);
|
||||
}
|
||||
async function cancelScheduleTargets(id, targets) {
|
||||
if (!confirm(`cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`)) return;
|
||||
await postScheduleCancel(id, targets);
|
||||
}
|
||||
async function postScheduleCancel(id, targets) {
|
||||
try {
|
||||
const opts = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (targets) opts.body = JSON.stringify({ targets });
|
||||
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/cancel', opts);
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
alert('cancel failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||||
return;
|
||||
}
|
||||
await refreshSchedules();
|
||||
} catch (err) {
|
||||
alert('cancel failed: ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── state polling ──────────────────────────────────────────────────────
|
||||
let pollTimer = null;
|
||||
// Sections whose innerHTML gets blown away on each refresh. If the
|
||||
|
|
@ -1932,6 +2256,8 @@ 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
|
||||
// `data-restore-key` attribute. snapshotOpenDetails walks managed
|
||||
|
|
@ -2013,6 +2339,7 @@ window.marked = marked;
|
|||
renderMetaInputs(s);
|
||||
renderRebuildQueue(s);
|
||||
refreshReminders();
|
||||
refreshSchedules();
|
||||
restoreOpenDetails(openDetails);
|
||||
notifyDeltas(s);
|
||||
// No periodic refresh timer. Phase 6 covers every container
|
||||
|
|
@ -2107,7 +2434,7 @@ window.marked = marked;
|
|||
// tab-strip link. Tab routing only applies when the tab DOM is
|
||||
// present (e.g. not on the flow page itself, where these elements
|
||||
// don't exist and the loop no-ops).
|
||||
const TABS = ['swarm', 'call', 'system'];
|
||||
const TABS = ['swarm', 'call', 'system', 'schedules'];
|
||||
function activateTab(name) {
|
||||
const target = TABS.includes(name) ? name : TABS[0];
|
||||
for (const t of TABS) {
|
||||
|
|
@ -2116,6 +2443,9 @@ window.marked = marked;
|
|||
if (tab) tab.classList.toggle('active', t === target);
|
||||
if (pane) pane.classList.toggle('tab-pane-active', t === target);
|
||||
}
|
||||
// #459: schedules pane has no SSE channel yet (PR C follow-up), so
|
||||
// re-fetch on activation so the operator never lands on stale data.
|
||||
if (target === 'schedules') refreshSchedules();
|
||||
}
|
||||
function syncTabFromHash() {
|
||||
const h = (window.location.hash || '#swarm').replace(/^#/, '');
|
||||
|
|
@ -2159,6 +2489,10 @@ window.marked = marked;
|
|||
}
|
||||
}
|
||||
setTabCount('system', sysCount);
|
||||
// SCH3DUL3S — count of schedules with at least one still-active
|
||||
// target (whole-schedule cancellation or all-targets-cancelled
|
||||
// means "not waiting on the worker"; those don't pull attention).
|
||||
setTabCount('schedules', activeScheduleCount());
|
||||
// FL0W pill count: lives in ./flow.js now (it has the inbox
|
||||
// derived store). Dashboard tab strip's `#tab-count-flow` slot
|
||||
// stays hidden by default; future plumbing could broadcast the
|
||||
|
|
|
|||
|
|
@ -1599,6 +1599,131 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
surface the messages via the pill/flyout instead. */
|
||||
.flow-inbox-headless { display: none !important; }
|
||||
|
||||
/* ─── scheduled prompts tab (#459) ─────────────────────────────────────
|
||||
Creation form at the top, list of queued schedule cards below.
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6em;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.8em 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.schedule-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2em;
|
||||
}
|
||||
.schedule-field-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.schedule-field input,
|
||||
.schedule-field textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 0.35em 0.5em;
|
||||
}
|
||||
.schedule-field textarea { resize: vertical; min-height: 4em; }
|
||||
.schedule-targets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.schedule-target-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
padding: 0.2em 0.6em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
background: rgba(24, 24, 37, 0.4);
|
||||
}
|
||||
.schedule-target-chip:hover { border-color: var(--purple-dim); }
|
||||
.schedule-target-chip:has(input:checked) {
|
||||
border-color: var(--purple);
|
||||
color: var(--purple);
|
||||
background: rgba(203, 166, 247, 0.08);
|
||||
}
|
||||
.schedule-target-chip input { margin: 0; }
|
||||
.schedule-actions {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.schedules {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.schedule-row {
|
||||
padding: 0.6em 0.9em;
|
||||
background: rgba(24, 24, 37, 0.55);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.schedule-row.schedule-cancelled { opacity: 0.55; }
|
||||
.schedule-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.schedule-description {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.schedule-body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 0.5em 0.7em;
|
||||
}
|
||||
.schedule-targets-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.schedule-targets-table th,
|
||||
.schedule-targets-table td {
|
||||
padding: 0.25em 0.6em;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.schedule-targets-table th {
|
||||
color: var(--muted);
|
||||
font-weight: normal;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.schedule-targets-table tr:last-child td { border-bottom: 0; }
|
||||
.schedule-target-cancelled td { opacity: 0.5; }
|
||||
.btn-inline-small {
|
||||
font-size: 0.7em;
|
||||
padding: 0.1em 0.45em;
|
||||
}
|
||||
|
||||
/* 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
|
||||
|
|
|
|||
|
|
@ -34,6 +34,16 @@
|
|||
<span class="tab-label">◆ SYST3M ◆</span>
|
||||
<span class="tab-count" id="tab-count-system" hidden></span>
|
||||
</a>
|
||||
<!-- SCH3DUL3S (#459): scheduled-prompts surface. List of
|
||||
queued schedules + an operator-direct creation form.
|
||||
Count pill mirrors the active (non-cancelled) schedule
|
||||
count; hidden when zero. -->
|
||||
<a class="tab" id="tab-schedules" href="#schedules" role="tab"
|
||||
aria-controls="tab-pane-schedules"
|
||||
data-tab="schedules">
|
||||
<span class="tab-label">◆ SCH3DUL3S ◆</span>
|
||||
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||
</a>
|
||||
|
||||
<!-- FL0W is its own page (`/flow.html`), not a tab — per
|
||||
operator @ #369#issuecomment-3437 ("yes terminal can be a
|
||||
|
|
@ -128,6 +138,31 @@
|
|||
</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. -->
|
||||
<section class="tab-pane" id="tab-pane-schedules"
|
||||
role="tabpanel" aria-labelledby="tab-schedules">
|
||||
<h2>◆ N3W SCH3DUL3 ◆</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>
|
||||
<div id="schedules-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FL0W: lives on its own page now (`/flow.html`). The
|
||||
message-flow + inbox + compose DOM only exists there — when
|
||||
app.js boots on this page the corresponding renderers
|
||||
|
|
|
|||
Loading…
Reference in a new issue