Files
odysseus/static/js/cookbookSchedule.js
pewdiepie-archdaemon a19b6d2d4d 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.
2026-06-05 02:35:23 +09:00

245 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
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 });
})();