Cookbook scheduler: reuse the standard calendar event card + auto-create Cookbook calendar
Drop the custom Schedule modal in favor of opening the calendar's existing event-creation form pre-filled with the model's name + cookbook YAML in the description. The user lands in the same event editor they already know from regular calendar use, just pointed at the auto-created "Cookbook" calendar. Backend: - POST /api/cookbook/schedule/ensure-calendar — idempotent: creates a calendar named "Cookbook" if one doesn't exist for the current user, saves its href into cookbook_schedule_calendar_href, flips cookbook_scheduler_enabled on. Verifies the saved href against /api/calendar/calendars on every call so a manually-deleted calendar self-heals. Frontend: - calendar.js: expose window.cookbookOpenScheduleForm(draft) which opens the calendar modal (if not open), calls _showEventForm, then pre-fills summary / description / rrule / calendar dropdown. Force-expands the "Add details" section so the user can see which calendar it's heading into. - cookbookSchedule.js: Schedule-button click now calls ensure-calendar, builds the cookbook: YAML block, and routes to window.cookbookOpenScheduleForm instead of openModal(). The legacy custom modal stays as a fallback for the case where calendar.js hasn't loaded. UX tweak: - cookbookServe.js: replace the standalone "Schedule…" text button with a small icon-only button (clock SVG) glued to the right edge of Launch. The pair forms one visual unit — Launch on the left, schedule-now on the right — sharing a thin divider. CSS handles the rounded corners + divider.
This commit is contained in:
@@ -205,4 +205,62 @@ def setup_cookbook_schedule_routes() -> APIRouter:
|
||||
from src.cookbook_scheduler import _reconcile_once
|
||||
return await _reconcile_once()
|
||||
|
||||
@router.post("/ensure-calendar")
|
||||
async def ensure_calendar(request: Request):
|
||||
"""Ensure a calendar named \"Cookbook\" exists for the current user
|
||||
and is registered as the scheduler calendar in settings. Idempotent.
|
||||
|
||||
Used by the Schedule button so the user never has to pick: the
|
||||
first click creates the calendar and wires it up; subsequent
|
||||
clicks return the existing href.
|
||||
"""
|
||||
require_admin(request)
|
||||
from src.settings import get_setting, _save_settings, _load_settings
|
||||
existing = get_setting("cookbook_schedule_calendar_href", "") or ""
|
||||
# Verify the saved href still exists in /api/calendar/calendars
|
||||
# (the user might have deleted the calendar manually) by hitting
|
||||
# the list endpoint loopback.
|
||||
import httpx
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
|
||||
live: list = []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get("http://localhost:7000/api/calendar/calendars", headers=headers)
|
||||
live = (r.json() or {}).get("calendars", []) if r.status_code < 400 else []
|
||||
except Exception:
|
||||
live = []
|
||||
if existing and any(c.get("href") == existing for c in live):
|
||||
match = next(c for c in live if c.get("href") == existing)
|
||||
return {"ok": True, "href": existing, "name": match.get("name"), "created": False}
|
||||
# No usable cookbook calendar — see if one named "Cookbook" already
|
||||
# exists (perhaps from a previous setup), else create a fresh one.
|
||||
match = next((c for c in live if (c.get("name") or "").lower() == "cookbook"), None)
|
||||
if match:
|
||||
href = match.get("href")
|
||||
else:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.post(
|
||||
"http://localhost:7000/api/calendar/calendars",
|
||||
params={"name": "Cookbook", "color": "#3b82f6"},
|
||||
headers=headers,
|
||||
)
|
||||
data = r.json() if r.content else {}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"create calendar failed: {exc}")
|
||||
if not data.get("ok"):
|
||||
raise HTTPException(500, f"create calendar failed: {data}")
|
||||
href = data.get("id") or ""
|
||||
if not href:
|
||||
raise HTTPException(500, "no href returned from calendar create")
|
||||
# Persist into settings (bypasses DEFAULT_SETTINGS guard by using
|
||||
# _load/_save directly since we know this key is whitelisted).
|
||||
s = _load_settings()
|
||||
s["cookbook_schedule_calendar_href"] = href
|
||||
if not s.get("cookbook_scheduler_enabled"):
|
||||
s["cookbook_scheduler_enabled"] = True
|
||||
_save_settings(s)
|
||||
return {"ok": True, "href": href, "name": "Cookbook", "created": True}
|
||||
|
||||
return router
|
||||
|
||||
@@ -3346,3 +3346,42 @@ window.addEventListener('calendar-refresh', () => {
|
||||
const calendarModule = { openCalendar, closeCalendar, isCalendarOpen };
|
||||
export { openCalendar, openCalendarTo, closeCalendar, isCalendarOpen };
|
||||
export default calendarModule;
|
||||
|
||||
// ── Cookbook scheduler hook ─────────────────────────────────────────────
|
||||
// Lets the Cookbook tab's Schedule button open the standard calendar
|
||||
// event-create form pre-filled with the model's name + cookbook YAML in
|
||||
// the description, on the auto-created Cookbook calendar. Keeps the
|
||||
// Schedule UX identical to creating any other calendar event — no
|
||||
// custom modal, just the existing flow that users already know.
|
||||
window.cookbookOpenScheduleForm = function (draft) {
|
||||
// Open the calendar first so #cal-body exists for _showEventForm.
|
||||
if (!_open) openCalendar();
|
||||
// Defer a tick so the modal DOM is mounted before we touch it.
|
||||
setTimeout(() => {
|
||||
_showEventForm(null, _today(), _today());
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const sumEl = document.getElementById('cal-f-sum');
|
||||
if (sumEl && draft && draft.summary) sumEl.value = draft.summary;
|
||||
const descEl = document.getElementById('cal-f-desc');
|
||||
if (descEl && draft && draft.description) descEl.value = draft.description;
|
||||
const rrEl = document.getElementById('cal-f-rrule');
|
||||
if (rrEl && draft && draft.rrule) rrEl.value = draft.rrule;
|
||||
// Calendar selector lives behind the "Add details" expand; force-
|
||||
// expand so the user sees it's heading into the Cookbook calendar.
|
||||
const form = document.querySelector('.cal-form-bespoke');
|
||||
if (form) form.classList.add('is-expanded');
|
||||
const calSel = document.getElementById('cal-f-cal');
|
||||
if (calSel && draft && draft.calendar_href) {
|
||||
const opt = Array.from(calSel.options).find(o => o.value === draft.calendar_href);
|
||||
if (opt) calSel.value = draft.calendar_href;
|
||||
}
|
||||
// Focus the title so the user can immediately type if they want
|
||||
// to rename the event (rare, but cheap to enable).
|
||||
if (sumEl) sumEl.focus();
|
||||
} catch (e) {
|
||||
console.warn('cookbook schedule prefill failed:', e);
|
||||
}
|
||||
}, 60);
|
||||
}, _open ? 0 : 80);
|
||||
};
|
||||
|
||||
@@ -201,16 +201,18 @@
|
||||
}
|
||||
|
||||
// Click-binding: any .hwfit-serve-schedule button inside a serve
|
||||
// panel opens the modal with the panel's current config.
|
||||
document.addEventListener("click", (e) => {
|
||||
// panel routes to the STANDARD calendar event-creation form, with the
|
||||
// model's name pre-filled as the event title and a `cookbook:` YAML
|
||||
// block in the description. The event lands on the auto-created
|
||||
// "Cookbook" calendar so the reconciler picks it up. The custom
|
||||
// openModal() above is kept as a fallback in case the calendar
|
||||
// module hasn't loaded.
|
||||
document.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest && e.target.closest(".hwfit-serve-schedule");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Reach into the serve panel to read current config. cookbookServe.js
|
||||
// stores the active config on the panel root via data attributes
|
||||
// that we read here without coupling further.
|
||||
const panel = btn.closest("[data-cookbook-serve-panel]") || btn.closest(".doclib-card-expanded") || btn.closest(".doclib-card");
|
||||
const ds = panel ? panel.dataset || {} : {};
|
||||
const config = {
|
||||
@@ -221,7 +223,47 @@
|
||||
host: ds.host || "",
|
||||
port: ds.port ? Number(ds.port) : undefined,
|
||||
};
|
||||
openModal(config);
|
||||
|
||||
// Ensure the Cookbook calendar exists and is configured. Returns
|
||||
// the href to feed into the event form.
|
||||
btn.disabled = true;
|
||||
let calHref = "";
|
||||
try {
|
||||
const r = await fetch("/api/cookbook/schedule/ensure-calendar", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
});
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
calHref = data.href || "";
|
||||
}
|
||||
} catch (_) {}
|
||||
btn.disabled = false;
|
||||
|
||||
// Build the cookbook: YAML block that goes into the event description.
|
||||
// The reconciler parses this to know HOW to launch when the window
|
||||
// opens. If only the title is set, the reconciler title-matches
|
||||
// against saved presets.
|
||||
const yamlLines = ["cookbook:"];
|
||||
for (const k of ["preset", "repo_id", "cmd", "host", "port"]) {
|
||||
if (config[k]) yamlLines.push(` ${k}: ${config[k]}`);
|
||||
}
|
||||
if (yamlLines.length === 1 && config.title) {
|
||||
yamlLines.push(` preset: ${config.title}`);
|
||||
}
|
||||
const draft = {
|
||||
summary: config.title,
|
||||
description: yamlLines.join("\n"),
|
||||
rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", // default: weekdays
|
||||
calendar_href: calHref,
|
||||
};
|
||||
|
||||
if (typeof window.cookbookOpenScheduleForm === "function") {
|
||||
window.cookbookOpenScheduleForm(draft);
|
||||
} else {
|
||||
// Fallback to the legacy in-house modal if the calendar module
|
||||
// hasn't loaded for some reason.
|
||||
openModal(config);
|
||||
}
|
||||
});
|
||||
|
||||
// Reveal Schedule buttons once we confirm the feature is enabled.
|
||||
|
||||
@@ -744,12 +744,14 @@ function _rerenderCachedModels() {
|
||||
// pushes Cancel + Launch to the right.
|
||||
panelHtml += `<span class="hwfit-serve-actions-spacer"></span>`;
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-cancel" type="button" title="Close this configuration panel">Cancel</button>`;
|
||||
// Schedule button — opens a modal that converts the current serve
|
||||
// config into a calendar event. Hidden unless the scheduler feature
|
||||
// flag is on (cookbookSchedule.js reads /api/cookbook/schedule/upcoming
|
||||
// on init and toggles visibility).
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-schedule" type="button" title="Schedule this model to run on a recurring window" style="display:none;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;flex-shrink:0;"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>Schedule…</button>`;
|
||||
// Launch + Schedule pair. The little ° button sits flush against
|
||||
// Launch and opens the calendar event-creation form pre-filled
|
||||
// with this model's config. Hidden until cookbookSchedule.js
|
||||
// confirms the scheduler feature flag is on.
|
||||
panelHtml += `<span class="hwfit-serve-launch-group">`;
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-launch"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;flex-shrink:0;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Launch</button>`;
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-schedule" type="button" title="Schedule this model on a recurring window" aria-label="Schedule" style="display:none;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg></button>`;
|
||||
panelHtml += `</span>`;
|
||||
panelHtml += `</div>`;
|
||||
panelHtml += `</div>`;
|
||||
|
||||
|
||||
@@ -35829,3 +35829,29 @@ body.theme-frosted .modal {
|
||||
.cookbook-schedule-modal { width: 96vw; }
|
||||
.cookbook-schedule-day { padding: 8px 14px; font-size: 14px; }
|
||||
}
|
||||
|
||||
/* Launch+Schedule paired button group. The ° button is a tiny icon
|
||||
button glued to the right side of Launch with a thin divider. */
|
||||
.hwfit-serve-launch-group {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.hwfit-serve-launch-group .hwfit-serve-launch {
|
||||
border-top-right-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
.hwfit-serve-launch-group .hwfit-serve-schedule {
|
||||
border-top-left-radius: 0 !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-left: 1px solid rgba(255,255,255,.18) !important;
|
||||
padding: 0 8px !important;
|
||||
min-width: 26px;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.hwfit-serve-launch-group .hwfit-serve-schedule svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user