Cookbook scheduler: calendar events drive model serve windows (experimental, feature-flagged)
Add a calendar-driven scheduler so a user can pick a model in Cookbook, click "Schedule…" instead of "Launch", choose time windows + days of the week + (optional) end date, and have Odysseus auto-launch the serve when the window starts and hard-kill it when the window ends. The calendar IS the source of truth — events on a designated calendar are interpreted as serve schedules, so editing the event in the calendar UI immediately changes the schedule.
Whole feature is gated by setting `cookbook_scheduler_enabled` (default False). Disabling the setting silences the reconciler and the API refuses requests; setting + three new files = entire surface, easy to revert.
New files:
- src/cookbook_scheduler.py — background reconciler: ticks every 60s, reads next ±90s of calendar events on the designated calendar, launches/kills serves to match. Honors "refuse if GPUs busy" (skips with reason, no retry). Adopts pre-existing manual serves matching the event's model so window-end cleanup still applies. Tags scheduler-owned tasks with `_scheduledBy: <event_uid>` so it never kills serves it doesn't own.
- routes/cookbook_schedule_routes.py — POST /api/cookbook/schedule/from-cookbook builds RRULE+ICS events from the modal's input (model, slots[], days[], until). GET /upcoming returns the next 24h with per-event status (scheduled / running / adopted / skipped / failed / ended) for the UI. POST /reconcile-now manually kicks the reconciler.
- static/js/cookbookSchedule.js — Schedule button click handler + modal. Daily/hourly time slot picker, multi-slot ("+ add another time slot"), weekday chips with Weekdays/Weekend/Every-day quicksets, optional Until date. Calls /from-cookbook on save. Whole module is a single IIFE; deleting the file plus its <script> tag removes the UI surface.
Existing files touched (minimal):
- app.py: register the new router + add the reconcile loop as a startup task (~10 lines, all in one block). Reconcile loop checks the feature flag on every tick, so leaving it running with the flag off costs ~one settings lookup per minute.
- static/index.html: one new <script> tag for cookbookSchedule.js.
- static/js/cookbookServe.js: add a "Schedule…" button next to the existing Launch button. Hidden by default; cookbookSchedule.js reveals it after confirming the feature flag is on.
- static/style.css: ~80 lines for the modal styles (mobile-aware via @media).
User choices baked in:
- Calendar events are the source of truth.
- Refuse to launch if GPUs busy (skip + log reason in scheduler.events[uid].reason).
- Hard kill at event end.
- No retry on a skipped event within the window.
- Multi-slot per day supported (one calendar event per slot, shared RRULE).
- Pre-existing manual serves get adopted at window start so they're killed at end.
Known follow-ups (not in this commit):
- Settings UI to pick the schedule calendar + toggle the feature flag.
- Calendar event color/badge for status (running/skipped/failed).
- "Lazy launch on first request" — currently launches at event start. Replacing _launch_serve with a proxy that defers vllm until the first chat request is a contained future change.
This commit is contained in:
@@ -2266,6 +2266,7 @@
|
||||
<script type="module" src="/static/js/chatStream.js"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260520m"></script>
|
||||
<script type="module" src="/static/js/cookbook.js"></script>
|
||||
<script src="/static/js/cookbookSchedule.js"></script>
|
||||
<script type="module" src="/static/js/search-chat.js"></script>
|
||||
<script type="module" src="/static/js/compare/index.js"></script>
|
||||
<script type="module" src="/static/js/theme.js"></script>
|
||||
|
||||
244
static/js/cookbookSchedule.js
Normal file
244
static/js/cookbookSchedule.js
Normal file
@@ -0,0 +1,244 @@
|
||||
// Cookbook Schedule modal. Click the "Schedule…" button in a serve
|
||||
// panel → opens this modal → user picks days + time slots → POST to
|
||||
// /api/cookbook/schedule/from-cookbook which writes the calendar event.
|
||||
//
|
||||
// Whole feature is gated on /api/cookbook/schedule/upcoming returning
|
||||
// `enabled: true`. If the server says it's disabled, this module hides
|
||||
// all Schedule buttons and never opens the modal.
|
||||
//
|
||||
// To remove the feature entirely: delete this file + the `<script>`
|
||||
// tag that loads it + the `.hwfit-serve-schedule` button in
|
||||
// cookbookServe.js. No other code depends on it.
|
||||
|
||||
(function () {
|
||||
const DAYS = [
|
||||
{ key: "MO", label: "Mon" },
|
||||
{ key: "TU", label: "Tue" },
|
||||
{ key: "WE", label: "Wed" },
|
||||
{ key: "TH", label: "Thu" },
|
||||
{ key: "FR", label: "Fri" },
|
||||
{ key: "SA", label: "Sat" },
|
||||
{ key: "SU", label: "Sun" },
|
||||
];
|
||||
const WEEKDAYS = ["MO", "TU", "WE", "TH", "FR"];
|
||||
|
||||
let _enabledCache = null;
|
||||
|
||||
async function isEnabled() {
|
||||
if (_enabledCache !== null) return _enabledCache;
|
||||
try {
|
||||
const r = await fetch("/api/cookbook/schedule/upcoming?hours=1", { credentials: "same-origin" });
|
||||
if (!r.ok) { _enabledCache = false; return false; }
|
||||
const data = await r.json();
|
||||
_enabledCache = !!data.enabled;
|
||||
return _enabledCache;
|
||||
} catch (_) {
|
||||
_enabledCache = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function slotRowHtml(start, end) {
|
||||
return `
|
||||
<div class="cookbook-schedule-slot">
|
||||
<input type="time" class="cookbook-schedule-start" value="${esc(start || "09:00")}" />
|
||||
<span class="cookbook-schedule-dash">–</span>
|
||||
<input type="time" class="cookbook-schedule-end" value="${esc(end || "17:00")}" />
|
||||
<button type="button" class="cookbook-schedule-slot-remove" title="Remove slot">×</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function dayChipsHtml(selected) {
|
||||
const sel = new Set(selected || WEEKDAYS);
|
||||
return DAYS.map(d =>
|
||||
`<label class="cookbook-schedule-day${sel.has(d.key) ? " active" : ""}">
|
||||
<input type="checkbox" value="${d.key}" ${sel.has(d.key) ? "checked" : ""} />
|
||||
${esc(d.label)}
|
||||
</label>`).join("");
|
||||
}
|
||||
|
||||
function openModal(config) {
|
||||
// config = {title, preset, repo_id, cmd, host, port}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "cookbook-schedule-modal-backdrop";
|
||||
wrap.innerHTML = `
|
||||
<div class="cookbook-schedule-modal">
|
||||
<div class="cookbook-schedule-modal-header">
|
||||
<strong>Schedule: ${esc(config.title || config.preset || "model")}</strong>
|
||||
<button type="button" class="cookbook-schedule-close" title="Close">×</button>
|
||||
</div>
|
||||
<div class="cookbook-schedule-modal-body">
|
||||
<div class="cookbook-schedule-section">
|
||||
<label class="cookbook-schedule-section-label">When</label>
|
||||
<div class="cookbook-schedule-slots">
|
||||
${slotRowHtml("09:00", "17:00")}
|
||||
</div>
|
||||
<button type="button" class="cookbook-schedule-add-slot">+ add another time slot</button>
|
||||
</div>
|
||||
<div class="cookbook-schedule-section">
|
||||
<label class="cookbook-schedule-section-label">Repeat on</label>
|
||||
<div class="cookbook-schedule-days">${dayChipsHtml(WEEKDAYS)}</div>
|
||||
<div class="cookbook-schedule-day-quickset">
|
||||
<button type="button" data-set="weekdays">Weekdays</button>
|
||||
<button type="button" data-set="weekend">Weekend</button>
|
||||
<button type="button" data-set="all">Every day</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cookbook-schedule-section">
|
||||
<label class="cookbook-schedule-section-label">Until</label>
|
||||
<div class="cookbook-schedule-until">
|
||||
<label><input type="radio" name="until-mode" value="forever" checked /> Forever</label>
|
||||
<label><input type="radio" name="until-mode" value="date" /> Until
|
||||
<input type="date" class="cookbook-schedule-until-date" disabled />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cookbook-schedule-error" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="cookbook-schedule-modal-footer">
|
||||
<button type="button" class="cookbook-btn cookbook-schedule-cancel">Cancel</button>
|
||||
<button type="button" class="cookbook-btn cookbook-schedule-save">Save schedule</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
|
||||
const $ = (sel) => wrap.querySelector(sel);
|
||||
const $$ = (sel) => Array.from(wrap.querySelectorAll(sel));
|
||||
|
||||
const close = () => wrap.remove();
|
||||
$(".cookbook-schedule-close").onclick = close;
|
||||
$(".cookbook-schedule-cancel").onclick = close;
|
||||
wrap.addEventListener("click", (e) => { if (e.target === wrap) close(); });
|
||||
|
||||
// Add / remove slot rows.
|
||||
$(".cookbook-schedule-add-slot").onclick = () => {
|
||||
const slots = $(".cookbook-schedule-slots");
|
||||
const tmp = document.createElement("div");
|
||||
tmp.innerHTML = slotRowHtml("18:00", "23:00");
|
||||
slots.appendChild(tmp.firstElementChild);
|
||||
};
|
||||
wrap.addEventListener("click", (e) => {
|
||||
if (e.target.classList && e.target.classList.contains("cookbook-schedule-slot-remove")) {
|
||||
const slots = $$(".cookbook-schedule-slot");
|
||||
if (slots.length > 1) e.target.closest(".cookbook-schedule-slot").remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Day quickset chips.
|
||||
$$(".cookbook-schedule-day-quickset button").forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const sel = btn.dataset.set;
|
||||
const want = sel === "weekdays" ? new Set(WEEKDAYS)
|
||||
: sel === "weekend" ? new Set(["SA", "SU"])
|
||||
: new Set(DAYS.map(d => d.key));
|
||||
$$(".cookbook-schedule-day input").forEach(inp => {
|
||||
inp.checked = want.has(inp.value);
|
||||
inp.closest(".cookbook-schedule-day").classList.toggle("active", inp.checked);
|
||||
});
|
||||
};
|
||||
});
|
||||
$$(".cookbook-schedule-day input").forEach(inp => {
|
||||
inp.onchange = () => inp.closest(".cookbook-schedule-day").classList.toggle("active", inp.checked);
|
||||
});
|
||||
|
||||
// Until-date radio enables / disables the date picker.
|
||||
$$('input[name="until-mode"]').forEach(r => {
|
||||
r.onchange = () => {
|
||||
const datePicker = $(".cookbook-schedule-until-date");
|
||||
datePicker.disabled = $('input[name="until-mode"]:checked').value !== "date";
|
||||
};
|
||||
});
|
||||
|
||||
$(".cookbook-schedule-save").onclick = async () => {
|
||||
const slots = $$(".cookbook-schedule-slot").map(row => ({
|
||||
start: row.querySelector(".cookbook-schedule-start").value,
|
||||
end: row.querySelector(".cookbook-schedule-end").value,
|
||||
}));
|
||||
const days = $$(".cookbook-schedule-day input:checked").map(i => i.value);
|
||||
const untilMode = $('input[name="until-mode"]:checked').value;
|
||||
const untilDate = untilMode === "date" ? $(".cookbook-schedule-until-date").value : "";
|
||||
|
||||
const errEl = $(".cookbook-schedule-error");
|
||||
errEl.style.display = "none";
|
||||
|
||||
const body = {
|
||||
model: config.title || config.preset || "",
|
||||
preset: config.preset,
|
||||
repo_id: config.repo_id,
|
||||
cmd: config.cmd,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
slots, days,
|
||||
};
|
||||
if (untilDate) body.until = untilDate;
|
||||
|
||||
try {
|
||||
const r = await fetch("/api/cookbook/schedule/from-cookbook", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
errEl.textContent = data.detail || data.error || `HTTP ${r.status}`;
|
||||
errEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
close();
|
||||
if (window.toast) window.toast(`Scheduled ${slots.length} window(s) on ${days.length} day(s).`, "success");
|
||||
} catch (e) {
|
||||
errEl.textContent = String(e);
|
||||
errEl.style.display = "block";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Click-binding: any .hwfit-serve-schedule button inside a serve
|
||||
// panel opens the modal with the panel's current config.
|
||||
document.addEventListener("click", (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 = {
|
||||
title: ds.modelName || ds.preset || panel?.querySelector(".doclib-card-title")?.textContent?.trim() || "model",
|
||||
preset: ds.preset || "",
|
||||
repo_id: ds.repoId || "",
|
||||
cmd: ds.cmd || "",
|
||||
host: ds.host || "",
|
||||
port: ds.port ? Number(ds.port) : undefined,
|
||||
};
|
||||
openModal(config);
|
||||
});
|
||||
|
||||
// Reveal Schedule buttons once we confirm the feature is enabled.
|
||||
async function refreshScheduleButtonVisibility() {
|
||||
const enabled = await isEnabled();
|
||||
document.querySelectorAll(".hwfit-serve-schedule").forEach(btn => {
|
||||
btn.style.display = enabled ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
// Periodically re-check (cheap) so toggling the feature in Settings
|
||||
// takes effect without a full reload.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
refreshScheduleButtonVisibility();
|
||||
setInterval(refreshScheduleButtonVisibility, 30000);
|
||||
});
|
||||
// Also re-check whenever a serve panel expands.
|
||||
const obs = new MutationObserver(() => refreshScheduleButtonVisibility());
|
||||
obs.observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
@@ -744,6 +744,11 @@ 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>`;
|
||||
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 += `</div>`;
|
||||
panelHtml += `</div>`;
|
||||
|
||||
@@ -35736,3 +35736,96 @@ body.theme-frosted .modal {
|
||||
is already ≥16px and never zoomed — leave it so we don't shrink it. */
|
||||
.doc-email-richbody.doc-font-m { font-size: 16px !important; }
|
||||
}
|
||||
|
||||
/* ── Cookbook Schedule modal (feature-flagged) ─────────────────────── */
|
||||
.cookbook-schedule-modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
.cookbook-schedule-modal {
|
||||
background: var(--bg-secondary, #1e1e22);
|
||||
color: var(--text-primary, #e6e6e6);
|
||||
border: 1px solid var(--border, #2d2d33);
|
||||
border-radius: 12px; width: min(560px, 92vw); max-height: 90vh;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.5);
|
||||
}
|
||||
.cookbook-schedule-modal-header {
|
||||
padding: 14px 18px; border-bottom: 1px solid var(--border, #2d2d33);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.cookbook-schedule-close {
|
||||
background: transparent; border: none; color: inherit;
|
||||
font-size: 22px; line-height: 1; cursor: pointer; padding: 0 6px;
|
||||
}
|
||||
.cookbook-schedule-modal-body { padding: 16px 18px; overflow-y: auto; }
|
||||
.cookbook-schedule-section { margin-bottom: 16px; }
|
||||
.cookbook-schedule-section-label {
|
||||
display: block; font-size: 12px; opacity: .7;
|
||||
margin-bottom: 6px; text-transform: uppercase; letter-spacing: .04em;
|
||||
}
|
||||
.cookbook-schedule-slot {
|
||||
display: flex; align-items: center; gap: 6px; margin-bottom: 6px;
|
||||
}
|
||||
.cookbook-schedule-slot input[type="time"] {
|
||||
background: var(--bg-primary, #131316); color: inherit;
|
||||
border: 1px solid var(--border, #2d2d33); border-radius: 6px;
|
||||
padding: 6px 8px; font: inherit;
|
||||
}
|
||||
.cookbook-schedule-slot-remove {
|
||||
background: transparent; border: 1px solid var(--border, #2d2d33);
|
||||
color: inherit; border-radius: 6px; width: 28px; height: 28px;
|
||||
cursor: pointer; font-size: 18px; line-height: 1;
|
||||
}
|
||||
.cookbook-schedule-add-slot {
|
||||
background: transparent; color: var(--accent, #6aa8ff);
|
||||
border: 1px dashed var(--border, #2d2d33); border-radius: 6px;
|
||||
padding: 6px 10px; cursor: pointer; font: inherit; margin-top: 4px;
|
||||
}
|
||||
.cookbook-schedule-days {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px;
|
||||
}
|
||||
.cookbook-schedule-day {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 6px 10px; border: 1px solid var(--border, #2d2d33);
|
||||
border-radius: 999px; cursor: pointer; font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
.cookbook-schedule-day input { display: none; }
|
||||
.cookbook-schedule-day.active {
|
||||
background: var(--accent, #6aa8ff); color: #fff;
|
||||
border-color: var(--accent, #6aa8ff);
|
||||
}
|
||||
.cookbook-schedule-day-quickset {
|
||||
display: flex; gap: 6px; flex-wrap: wrap;
|
||||
}
|
||||
.cookbook-schedule-day-quickset button {
|
||||
background: transparent; color: var(--text-secondary, #aaa);
|
||||
border: 1px solid var(--border, #2d2d33); border-radius: 6px;
|
||||
padding: 4px 8px; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.cookbook-schedule-until { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.cookbook-schedule-until label {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.cookbook-schedule-until-date {
|
||||
background: var(--bg-primary, #131316); color: inherit;
|
||||
border: 1px solid var(--border, #2d2d33); border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.cookbook-schedule-error {
|
||||
color: #ff6b6b; background: rgba(255,107,107,.08);
|
||||
border: 1px solid rgba(255,107,107,.3); border-radius: 6px;
|
||||
padding: 8px 12px; font-size: 13px;
|
||||
}
|
||||
.cookbook-schedule-modal-footer {
|
||||
padding: 12px 18px; border-top: 1px solid var(--border, #2d2d33);
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
}
|
||||
|
||||
/* Mobile (Firefox + others): single-column slot rows + larger touch targets. */
|
||||
@media (max-width: 600px) {
|
||||
.cookbook-schedule-modal { width: 96vw; }
|
||||
.cookbook-schedule-day { padding: 8px 14px; font-size: 14px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user