From c233f8a924f3c04a716e999a6e4bca1e2a702590 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 09:53:48 +0200 Subject: [PATCH] dashboard: scheduled prompts tab + creation form (closes #459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
-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).
---
 frontend/packages/dashboard/src/app.js        | 336 +++++++++++++++++-
 frontend/packages/dashboard/src/dashboard.css | 125 +++++++
 frontend/packages/dashboard/src/index.html    |  35 ++
 3 files changed, 495 insertions(+), 1 deletion(-)

diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js
index 7f55eb38..b12d7a14 100644
--- a/frontend/packages/dashboard/src/app.js
+++ b/frontend/packages/dashboard/src/app.js
@@ -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) {
+    // `` 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 = ' 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',
   ];
   // 
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 diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index b76e9f53..1016ad0f 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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 diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 94582573..315b35b6 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -34,6 +34,16 @@ ◆ SYST3M ◆ + + + ◆ SCH3DUL3S ◆ + + +
+

◆ N3W SCH3DUL3 ◆

+
══════════════════════════════════════════════════════════════
+

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 operator / manager.

+
+

loading…

+
+ +

◆ QU3U3D SCH3DUL3S ◆

+
══════════════════════════════════════════════════════════════
+

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 ✕ cancel all.

+
+

loading…

+
+
+