From 2d2f4e592b23494e244939a2e57cd8427d4da999 Mon Sep 17 00:00:00 2001 From: Kris Date: Sat, 27 Jun 2026 08:15:36 +0530 Subject: [PATCH] feat(scheduler): add Scheduled Jobs UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scheduler-ui.js: job list + create/edit form + Run Now/Enable/Disable/Delete, reacting to scheduler:* SSE events; same-agent Run Now warning - index.html: "⏰ Schedules" toolbar button + #schedulerModal + script include - constants.js / app.js: frontend SSE event constants + handler map entries - styles.css: scheduler row/badge/form styles Follows Codeman's vanilla-JS mixin + .modal/.form-row conventions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rp7JhmQXcYJhmxFMdZuuah --- src/web/public/app.js | 6 + src/web/public/constants.js | 6 + src/web/public/index.html | 91 ++++++++++ src/web/public/scheduler-ui.js | 306 +++++++++++++++++++++++++++++++++ src/web/public/styles.css | 22 +++ 5 files changed, 431 insertions(+) create mode 100644 src/web/public/scheduler-ui.js diff --git a/src/web/public/app.js b/src/web/public/app.js index c63f12be..eb9a1754 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -165,6 +165,12 @@ const _SSE_HANDLER_MAP = [ [SSE_EVENTS.SCHEDULED_COMPLETED, '_onScheduledCompleted'], [SSE_EVENTS.SCHEDULED_STOPPED, '_onScheduledStopped'], + // Scheduled jobs (cron-style scheduler) + [SSE_EVENTS.SCHEDULER_JOBS_CHANGED, '_onSchedulerJobsChanged'], + [SSE_EVENTS.SCHEDULER_JOB_DELETED, '_onSchedulerJobsChanged'], + [SSE_EVENTS.SCHEDULER_RUN_CREATED, '_onSchedulerRunChanged'], + [SSE_EVENTS.SCHEDULER_RUN_UPDATED, '_onSchedulerRunChanged'], + // Respawn [SSE_EVENTS.RESPAWN_STARTED, '_onRespawnStarted'], [SSE_EVENTS.RESPAWN_STOPPED, '_onRespawnStopped'], diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 33b74246..eabf8dcc 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -276,6 +276,12 @@ const SSE_EVENTS = { SCHEDULED_LOG: 'scheduled:log', SCHEDULED_DELETED: 'scheduled:deleted', + // Scheduled jobs (cron-style scheduler) + SCHEDULER_JOBS_CHANGED: 'scheduler:jobsChanged', + SCHEDULER_JOB_DELETED: 'scheduler:jobDeleted', + SCHEDULER_RUN_CREATED: 'scheduler:runCreated', + SCHEDULER_RUN_UPDATED: 'scheduler:runUpdated', + // Respawn RESPAWN_STARTED: 'respawn:started', RESPAWN_STOPPED: 'respawn:stopped', diff --git a/src/web/public/index.html b/src/web/public/index.html index 3f6edd2d..0d5d0b9b 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -541,6 +541,7 @@
+ v0.0.0
@@ -570,6 +571,95 @@ + + +
@@ -2056,6 +2146,7 @@ + diff --git a/src/web/public/scheduler-ui.js b/src/web/public/scheduler-ui.js new file mode 100644 index 00000000..4f343989 --- /dev/null +++ b/src/web/public/scheduler-ui.js @@ -0,0 +1,306 @@ +/** + * @fileoverview Scheduled Jobs UI (cron-style scheduler) mixed into + * CodemanApp.prototype. Renders the job list + create/edit form in the + * #schedulerModal, and reacts to scheduler:* SSE events. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js, api-client.js, constants.js (escapeHtml) + */ + +Object.assign(CodemanApp.prototype, { + // ── SSE handlers ────────────────────────────────────────────────────────── + + _onSchedulerJobsChanged(data) { + if (data && Array.isArray(data.jobs)) { + this._schedulerJobs = data.jobs; + if (this._isSchedulerOpen()) this.renderSchedulerJobs(); + } else if (this._isSchedulerOpen()) { + this.refreshScheduler(); + } + }, + + _onSchedulerRunChanged() { + // A run's status changed — refresh the list so lastStatus stays current. + if (this._isSchedulerOpen()) this.refreshScheduler(); + }, + + // ── Modal open/close ────────────────────────────────────────────────────── + + _isSchedulerOpen() { + const el = document.getElementById('schedulerModal'); + return !!el && el.classList.contains('active'); + }, + + openScheduler() { + const el = document.getElementById('schedulerModal'); + if (!el) return; + el.classList.add('active'); + this.cancelSchedulerJobForm(); + this.refreshScheduler(); + }, + + closeScheduler() { + const el = document.getElementById('schedulerModal'); + if (el) el.classList.remove('active'); + }, + + async refreshScheduler() { + const jobs = await this._apiJson('/api/scheduler/jobs'); + this._schedulerJobs = Array.isArray(jobs) ? jobs : []; + this.renderSchedulerJobs(); + }, + + // ── List rendering ──────────────────────────────────────────────────────── + + renderSchedulerJobs() { + const list = document.getElementById('schedulerJobList'); + if (!list) return; + const jobs = this._schedulerJobs || []; + if (jobs.length === 0) { + list.innerHTML = '
No scheduled jobs yet. Click “+ New Job”.
'; + return; + } + const rows = jobs.map((j) => { + const next = j.enabled ? this._fmtTime(j.nextRunAt) : '—'; + const last = this._fmtTime(j.lastRunAt); + const status = j.lastStatus ? escapeHtml(j.lastStatus) : '—'; + return ` +
+
+
${escapeHtml(j.name || '(unnamed)')} + ${escapeHtml(j.agentType)} + ${escapeHtml(this._fmtSchedule(j))} + ${j.enabled ? '' : 'disabled'} +
+
+ ${escapeHtml(j.workingDir || '')} + · next: ${escapeHtml(next)} · last: ${escapeHtml(last)} · status: ${status} +
+
+
+ + + + +
+
`; + }); + list.innerHTML = rows.join(''); + }, + + _fmtTime(ts) { + if (!ts) return '—'; + try { + return new Date(ts).toLocaleString(); + } catch { + return '—'; + } + }, + + _fmtSchedule(j) { + switch (j.scheduleType) { + case 'once': + return 'once'; + case 'interval': + return `every ${j.intervalMinutes}m`; + case 'daily': + return `daily ${j.dailyTime || ''}`; + case 'weekly': { + const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const days = (j.weeklyDays || []).map((d) => names[d] || d).join(','); + return `weekly ${days} ${j.weeklyTime || ''}`; + } + default: + return j.scheduleType || ''; + } + }, + + // ── Create / edit form ──────────────────────────────────────────────────── + + openSchedulerJobForm(job) { + const form = document.getElementById('schedulerJobForm'); + if (!form) return; + document.getElementById('schedulerFormError').textContent = ''; + document.getElementById('schedulerFormTitle').textContent = job ? 'Edit Scheduled Job' : 'New Scheduled Job'; + document.getElementById('schJobId').value = job ? job.id : ''; + document.getElementById('schName').value = job ? job.name || '' : ''; + document.getElementById('schAgentType').value = job ? job.agentType || 'claude' : 'claude'; + document.getElementById('schWorkingDir').value = job ? job.workingDir || '' : ''; + document.getElementById('schPromptMode').value = job ? job.promptMode || 'inline_text' : 'inline_text'; + document.getElementById('schPromptText').value = job ? job.promptText || '' : ''; + document.getElementById('schPromptFilePath').value = job ? job.promptFilePath || '' : ''; + document.getElementById('schInputMode').value = job ? job.inputMode || 'typed' : 'typed'; + document.getElementById('schScheduleType').value = job ? job.scheduleType || 'once' : 'once'; + document.getElementById('schRunAt').value = job && job.runAt ? this._toLocalInput(job.runAt) : ''; + document.getElementById('schIntervalMinutes').value = job && job.intervalMinutes ? job.intervalMinutes : 60; + document.getElementById('schDailyTime').value = job ? job.dailyTime || '' : ''; + document.getElementById('schWeeklyTime').value = job ? job.weeklyTime || '' : ''; + const weekly = (job && job.weeklyDays) || []; + document.querySelectorAll('#schWeeklyDays input[type=checkbox]').forEach((cb) => { + cb.checked = weekly.includes(Number(cb.value)); + }); + document.getElementById('schConcurrencyPolicy').value = job ? job.concurrencyPolicy || 'warn_only' : 'warn_only'; + document.getElementById('schEnabled').checked = job ? !!job.enabled : true; + document.getElementById('schNotes').value = job ? job.notes || '' : ''; + + this.onSchedulerPromptModeChange(); + this.onSchedulerScheduleTypeChange(); + form.classList.remove('hidden'); + }, + + editSchedulerJob(id) { + const job = (this._schedulerJobs || []).find((j) => j.id === id); + if (job) this.openSchedulerJobForm(job); + }, + + cancelSchedulerJobForm() { + const form = document.getElementById('schedulerJobForm'); + if (form) form.classList.add('hidden'); + }, + + onSchedulerPromptModeChange() { + const mode = document.getElementById('schPromptMode').value; + document.getElementById('schPromptTextRow').classList.toggle('hidden', mode !== 'inline_text'); + document.getElementById('schPromptFileRow').classList.toggle('hidden', mode !== 'prompt_file_path'); + }, + + onSchedulerScheduleTypeChange() { + const t = document.getElementById('schScheduleType').value; + document.getElementById('schRunAtRow').classList.toggle('hidden', t !== 'once'); + document.getElementById('schIntervalRow').classList.toggle('hidden', t !== 'interval'); + document.getElementById('schDailyRow').classList.toggle('hidden', t !== 'daily'); + document.getElementById('schWeeklyDaysRow').classList.toggle('hidden', t !== 'weekly'); + document.getElementById('schWeeklyTimeRow').classList.toggle('hidden', t !== 'weekly'); + }, + + _toLocalInput(ts) { + // epoch-ms → 'YYYY-MM-DDTHH:MM' in local time for . + const d = new Date(ts); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + }, + + _collectSchedulerForm() { + const t = document.getElementById('schScheduleType').value; + const promptMode = document.getElementById('schPromptMode').value; + const body = { + name: document.getElementById('schName').value.trim(), + agentType: document.getElementById('schAgentType').value, + workingDir: document.getElementById('schWorkingDir').value.trim(), + promptMode, + inputMode: document.getElementById('schInputMode').value, + scheduleType: t, + concurrencyPolicy: document.getElementById('schConcurrencyPolicy').value, + enabled: document.getElementById('schEnabled').checked, + notes: document.getElementById('schNotes').value.trim() || undefined, + }; + if (promptMode === 'inline_text') body.promptText = document.getElementById('schPromptText').value; + else body.promptFilePath = document.getElementById('schPromptFilePath').value.trim(); + + if (t === 'once') { + const v = document.getElementById('schRunAt').value; + body.runAt = v ? new Date(v).getTime() : undefined; + } else if (t === 'interval') { + body.intervalMinutes = Number(document.getElementById('schIntervalMinutes').value); + } else if (t === 'daily') { + body.dailyTime = document.getElementById('schDailyTime').value; + } else if (t === 'weekly') { + body.weeklyTime = document.getElementById('schWeeklyTime').value; + body.weeklyDays = Array.from(document.querySelectorAll('#schWeeklyDays input:checked')).map((cb) => + Number(cb.value) + ); + } + return body; + }, + + async saveSchedulerJob() { + const errEl = document.getElementById('schedulerFormError'); + errEl.textContent = ''; + const body = this._collectSchedulerForm(); + if (!body.name) { + errEl.textContent = 'Name is required.'; + return; + } + if (!body.workingDir) { + errEl.textContent = 'Working directory is required.'; + return; + } + const id = document.getElementById('schJobId').value; + const res = id + ? await this._apiPut(`/api/scheduler/jobs/${id}`, body) + : await this._apiPost('/api/scheduler/jobs', body); + if (!res || !res.ok) { + let msg = 'Failed to save job.'; + try { + const j = await res.json(); + if (j && j.error) msg = j.error; + } catch { + /* ignore */ + } + errEl.textContent = msg; + return; + } + this.showToast?.(id ? 'Scheduled job updated' : 'Scheduled job created', 'success'); + this.cancelSchedulerJobForm(); + this.refreshScheduler(); + }, + + // ── Actions ─────────────────────────────────────────────────────────────── + + async runSchedulerJob(id) { + const job = (this._schedulerJobs || []).find((j) => j.id === id); + if (job) { + const active = this._countActiveAgents(job.agentType); + if (active > 0 && !confirm(`${active} ${job.agentType} session(s) already active. Run this job anyway?`)) { + return; + } + } + const res = await this._apiPost(`/api/scheduler/jobs/${id}/run`, {}); + if (res && res.ok) { + this.showToast?.('Run started — opening session', 'success'); + let data = null; + try { + data = await res.json(); + } catch { + /* ignore */ + } + const run = data && (data.data ? data.data.run : data.run); + if (run && run.sessionId) this._focusScheduledSession(run.sessionId); + this.refreshScheduler(); + } else { + this.showToast?.('Failed to run job', 'error'); + } + }, + + _focusScheduledSession(sessionId) { + // Best-effort: switch to the created session tab if it exists. + if (this.sessions && this.sessions.has(sessionId) && typeof this.switchSession === 'function') { + this.closeScheduler(); + this.switchSession(sessionId); + } + }, + + _countActiveAgents(agentType) { + if (!this.sessions) return 0; + let n = 0; + for (const s of this.sessions.values()) if (s && s.mode === agentType) n++; + return n; + }, + + async toggleSchedulerJob(id, enabled) { + const res = await this._apiPut(`/api/scheduler/jobs/${id}/enabled`, { enabled }); + if (res && res.ok) this.refreshScheduler(); + else this.showToast?.('Failed to update job', 'error'); + }, + + async deleteSchedulerJob(id) { + if (!confirm('Delete this scheduled job and its run history?')) return; + const res = await this._apiDelete(`/api/scheduler/jobs/${id}`); + if (res && res.ok) { + this.showToast?.('Scheduled job deleted', 'success'); + this.refreshScheduler(); + } else { + this.showToast?.('Failed to delete job', 'error'); + } + }, +}); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index bf0d3cdc..6072802c 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -11071,3 +11071,25 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { background: linear-gradient(135deg, #7c3aed, #8b5cf6); box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.5); } + +/* ── Scheduled Jobs (cron-style scheduler) ───────────────────────────────── */ +.scheduler-job-list { display: flex; flex-direction: column; gap: 8px; } +.scheduler-job-row { + display: flex; justify-content: space-between; align-items: center; gap: 12px; + padding: 10px 12px; border: 1px solid var(--border, #333); border-radius: 8px; + background: var(--panel-bg, rgba(255,255,255,0.02)); +} +.scheduler-job-main { min-width: 0; flex: 1; } +.scheduler-job-name { font-weight: 600; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.scheduler-job-meta { font-size: 12px; opacity: 0.7; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; } +.scheduler-job-actions { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; } +.scheduler-badge { + font-size: 11px; padding: 1px 6px; border-radius: 10px; + background: var(--accent-bg, rgba(120,160,255,0.15)); opacity: 0.9; +} +.scheduler-badge-off { background: rgba(200,80,80,0.18); } +.scheduler-weekdays { display: flex; gap: 10px; flex-wrap: wrap; } +.scheduler-weekdays label { display: inline-flex; align-items: center; gap: 3px; font-weight: 400; } +.scheduler-job-form { + margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border, #333); +}