From 4ed48baf681abdfe5087196034084eade58772b4 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 5 Jun 2026 02:40:35 +0900 Subject: [PATCH] Cookbook scheduler: inline settings card at the top of the Cookbook tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier scheduler commit shipped the backend + Schedule modal but left the feature dormant — no way to toggle it from the UI. This adds the missing knob: * DEFAULT_SETTINGS gains `cookbook_scheduler_enabled` (False) and `cookbook_schedule_calendar_href` ("") so `/api/auth/settings` POST will actually persist them. Without this, the POST silently dropped unknown keys. * cookbookSchedule.js gains a self-contained settings card injected at the top of the Cookbook tab body whenever the cookbook modal opens. Card contents: - Enable toggle (writes cookbook_scheduler_enabled) - Calendar dropdown populated from /api/calendar/calendars (writes cookbook_schedule_calendar_href) - Status line: off / pick-a-calendar / N scheduled in next 24h · M running now · K skipped - "Reconcile now" button that POSTs /api/cookbook/schedule/reconcile-now * The same module reveals/hides the Schedule… buttons on serve panels whenever the feature flag changes, so toggling on immediately surfaces the schedule UI without a refresh. Settings UI lives in cookbookSchedule.js (not settings.js) so the entire scheduler surface — backend, reconciler, modal, settings — collapses cleanly: delete src/cookbook_scheduler.py + routes/cookbook_schedule_routes.py + static/js/cookbookSchedule.js, drop the two DEFAULT_SETTINGS keys, and the two app.py registration lines, and the feature is gone. --- src/settings.py | 6 ++ static/js/cookbookSchedule.js | 171 ++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) diff --git a/src/settings.py b/src/settings.py index 09a53c9..ef8c162 100644 --- a/src/settings.py +++ b/src/settings.py @@ -159,6 +159,12 @@ DEFAULT_SETTINGS = { "admin_panel": "ctrl+shift+u", "cancel": "escape", }, + # Cookbook scheduler (calendar-driven serve windows). Off by default; + # the inline card in the Cookbook tab flips this. When true, the + # reconciler at src/cookbook_scheduler.py reads events from the + # designated calendar and auto-launches/kills serves. + "cookbook_scheduler_enabled": False, + "cookbook_schedule_calendar_href": "", } DEFAULT_FEATURES = { diff --git a/static/js/cookbookSchedule.js b/static/js/cookbookSchedule.js index 6a5cd82..c0f612a 100644 --- a/static/js/cookbookSchedule.js +++ b/static/js/cookbookSchedule.js @@ -241,4 +241,175 @@ // Also re-check whenever a serve panel expands. const obs = new MutationObserver(() => refreshScheduleButtonVisibility()); obs.observe(document.body, { childList: true, subtree: true }); + + // ── Settings card injected at the top of the Cookbook tab ───────────── + // Lives here (not in settings.js) so the whole feature is in one file. + // When you delete cookbookSchedule.js, this UI vanishes with it. + + async function fetchSettings() { + try { + const r = await fetch("/api/auth/settings", { credentials: "same-origin" }); + if (!r.ok) return {}; + return await r.json(); + } catch (_) { return {}; } + } + + async function saveSettings(body) { + try { + const r = await fetch("/api/auth/settings", { + method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return r.ok; + } catch (_) { return false; } + } + + async function fetchCalendars() { + try { + const r = await fetch("/api/calendar/calendars", { credentials: "same-origin" }); + if (!r.ok) return []; + const d = await r.json(); + // Endpoint shape varies — accept either an array or { calendars: [...] }. + const list = Array.isArray(d) ? d : (d.calendars || []); + return list.map(c => ({ + href: c.href || c.url || c.id || "", + name: c.display_name || c.name || c.summary || c.href || "Calendar", + })).filter(c => c.href); + } catch (_) { return []; } + } + + async function fetchUpcoming() { + try { + const r = await fetch("/api/cookbook/schedule/upcoming?hours=24", { credentials: "same-origin" }); + if (!r.ok) return null; + return await r.json(); + } catch (_) { return null; } + } + + function buildCardHtml(s, calendars, upcoming) { + const enabled = !!s.cookbook_scheduler_enabled; + const calHref = s.cookbook_schedule_calendar_href || ""; + const events = (upcoming && upcoming.events) || []; + const running = events.filter(e => e.status === "running" || e.status === "adopted").length; + const skipped = events.filter(e => e.status === "skipped" || e.status === "failed").length; + let statusLine = ""; + if (!enabled) { + statusLine = "Scheduler is off. Toggle on to start launching models on a schedule."; + } else if (!calHref) { + statusLine = "Pick a calendar — events on it become serve windows."; + } else if (events.length === 0) { + statusLine = "Enabled. No scheduled windows in the next 24h."; + } else { + const parts = [`${events.length} scheduled in next 24h`]; + if (running) parts.push(`${running} running now`); + if (skipped) parts.push(`${skipped} skipped`); + statusLine = parts.join(" · "); + } + const calOptions = [''] + .concat(calendars.map(c => ``)) + .join(""); + return ` +
+
+ + ${esc(statusLine)} + +
+
+ + + Saved +
+
`; + } + + async function renderCard() { + const body = document.querySelector("#cookbook-modal .cookbook-body"); + if (!body) return; + // Skip if cookbook modal is hidden — wait until next open. + const modal = document.getElementById("cookbook-modal"); + if (modal && modal.classList.contains("hidden")) return; + let existing = body.querySelector(".cookbook-schedule-card"); + + const [s, cals, upcoming] = await Promise.all([fetchSettings(), fetchCalendars(), fetchUpcoming()]); + const html = buildCardHtml(s, cals, upcoming); + + if (existing) { + const tmp = document.createElement("div"); + tmp.innerHTML = html; + existing.replaceWith(tmp.firstElementChild); + } else { + const tmp = document.createElement("div"); + tmp.innerHTML = html; + body.insertBefore(tmp.firstElementChild, body.firstChild); + } + wireCard(); + } + + function wireCard() { + const card = document.querySelector(".cookbook-schedule-card"); + if (!card || card.dataset.wired === "1") return; + card.dataset.wired = "1"; + + const enabledChk = card.querySelector(".cookbook-sched-enabled"); + const calSel = card.querySelector(".cookbook-sched-calendar"); + const reconcileBtn = card.querySelector(".cookbook-sched-reconcile"); + const saveMsg = card.querySelector(".cookbook-sched-save-msg"); + const calRow = card.querySelector(".cookbook-sched-calrow"); + + function flashSaved() { + if (!saveMsg) return; + saveMsg.style.opacity = "1"; + setTimeout(() => { saveMsg.style.opacity = "0"; }, 1500); + } + + enabledChk.addEventListener("change", async () => { + _enabledCache = null; // bust cache + await saveSettings({ cookbook_scheduler_enabled: enabledChk.checked }); + calRow.style.display = enabledChk.checked ? "flex" : "none"; + flashSaved(); + // Toggle Schedule buttons immediately + refresh card status. + refreshScheduleButtonVisibility(); + setTimeout(renderCard, 200); + }); + + calSel.addEventListener("change", async () => { + await saveSettings({ cookbook_schedule_calendar_href: calSel.value }); + flashSaved(); + setTimeout(renderCard, 300); + }); + + reconcileBtn.addEventListener("click", async () => { + reconcileBtn.disabled = true; + reconcileBtn.textContent = "Reconciling…"; + try { + await fetch("/api/cookbook/schedule/reconcile-now", { method: "POST", credentials: "same-origin" }); + } catch (_) {} + reconcileBtn.disabled = false; + reconcileBtn.textContent = "Reconcile now"; + renderCard(); + }); + } + + // Re-render the card whenever the cookbook modal becomes visible. + function watchCookbookOpen() { + const modal = document.getElementById("cookbook-modal"); + if (!modal) return; + let lastHidden = modal.classList.contains("hidden"); + const mo = new MutationObserver(() => { + const nowHidden = modal.classList.contains("hidden"); + if (lastHidden && !nowHidden) renderCard(); + lastHidden = nowHidden; + }); + mo.observe(modal, { attributes: true, attributeFilter: ["class"] }); + // Also render on first open if modal is already visible at load time. + if (!lastHidden) renderCard(); + } + document.addEventListener("DOMContentLoaded", watchCookbookOpen); + // Settings tab may load AFTER DOMContentLoaded; recheck once. + setTimeout(watchCookbookOpen, 500); })();