mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
b98ee04e2f
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.
267 lines
12 KiB
Python
267 lines
12 KiB
Python
"""Cookbook schedule routes — turns the Cookbook \"Schedule\" modal into
|
|
calendar events on the designated schedule calendar, and exposes a
|
|
diagnostic /upcoming endpoint for the UI.
|
|
|
|
All routes live under /api/cookbook/schedule/* so the whole file can be
|
|
removed by deleting one router-registration line in app.py. The setup
|
|
function is a no-op when `cookbook_scheduler_enabled` is False.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Request
|
|
|
|
from core.middleware import require_admin
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_DAYS = {"MO", "TU", "WE", "TH", "FR", "SA", "SU"}
|
|
_HHMM_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
|
|
|
|
|
|
def setup_cookbook_schedule_routes() -> APIRouter:
|
|
router = APIRouter(prefix="/api/cookbook/schedule", tags=["cookbook-schedule"])
|
|
|
|
@router.get("/upcoming")
|
|
async def upcoming(request: Request, hours: int = 24):
|
|
"""Next N hours of scheduled events with reconciler status.
|
|
|
|
Drives the "what's running, what's queued" badges in the
|
|
Cookbook UI. Cheap read — no SSH, just calendar + state file.
|
|
"""
|
|
require_admin(request)
|
|
from src.settings import get_setting
|
|
if not get_setting("cookbook_scheduler_enabled", False):
|
|
return {"enabled": False, "events": []}
|
|
calendar_href = get_setting("cookbook_schedule_calendar_href", "") or ""
|
|
if not calendar_href:
|
|
return {"enabled": True, "calendar_href": "", "events": []}
|
|
|
|
hours = max(1, min(int(hours or 24), 24 * 14))
|
|
now = datetime.now(timezone.utc)
|
|
end = now + timedelta(hours=hours)
|
|
from src.cookbook_scheduler import _fetch_calendar_events, _read_state
|
|
events = await _fetch_calendar_events(calendar_href, now - timedelta(minutes=5), end)
|
|
state = _read_state()
|
|
tracked = (state.get("scheduler") or {}).get("events") or {}
|
|
out: List[Dict[str, Any]] = []
|
|
for ev in events:
|
|
uid = ev.get("uid") or ev.get("id") or ""
|
|
if not uid:
|
|
continue
|
|
t = tracked.get(uid) or {}
|
|
out.append({
|
|
"uid": uid,
|
|
"title": ev.get("summary") or "",
|
|
"start": ev.get("dtstart") or ev.get("start"),
|
|
"end": ev.get("dtend") or ev.get("end"),
|
|
"status": t.get("status") or "scheduled",
|
|
"reason": t.get("reason") or "",
|
|
"session_id": t.get("session_id") or "",
|
|
})
|
|
return {"enabled": True, "calendar_href": calendar_href, "events": out}
|
|
|
|
@router.post("/from-cookbook")
|
|
async def schedule_from_cookbook(request: Request, body: Dict[str, Any] = Body(default_factory=dict)):
|
|
"""Create one or more calendar events from the Cookbook Schedule modal.
|
|
|
|
Body shape:
|
|
{
|
|
"model": "Qwen3.5-397B-A17B-AWQ", # display title
|
|
"preset": "Qwen3.5-397B-A17B-AWQ", # optional, matched to saved preset
|
|
"repo_id": "...", # optional, for non-preset launches
|
|
"cmd": "vllm serve ...", # optional
|
|
"host": "pewds@192.168.1.12", # optional
|
|
"port": 8003, # optional
|
|
"slots": [
|
|
{"start": "09:00", "end": "17:00"}, # one or more time windows per day
|
|
{"start": "21:00", "end": "23:30"}
|
|
],
|
|
"days": ["MO","TU","WE","TH","FR"], # weekdays this repeats
|
|
"until": "2026-12-31", # optional end date, else forever
|
|
"start_date": "2026-06-05" # optional first day, else today
|
|
}
|
|
|
|
Creates one calendar event per slot (so split-shift schedules
|
|
are visible as separate blocks). All events share the same
|
|
RRULE so they can be edited together by changing one.
|
|
"""
|
|
require_admin(request)
|
|
from src.settings import get_setting
|
|
if not get_setting("cookbook_scheduler_enabled", False):
|
|
raise HTTPException(400, "Cookbook scheduler is not enabled in Settings.")
|
|
calendar_href = get_setting("cookbook_schedule_calendar_href", "") or ""
|
|
if not calendar_href:
|
|
raise HTTPException(400, "No Cookbook schedule calendar is configured in Settings.")
|
|
|
|
title = (body.get("model") or body.get("title") or "").strip()
|
|
if not title:
|
|
raise HTTPException(400, "model (title) is required")
|
|
slots = body.get("slots") or []
|
|
if not isinstance(slots, list) or not slots:
|
|
raise HTTPException(400, "at least one time slot is required")
|
|
for s in slots:
|
|
if not isinstance(s, dict):
|
|
raise HTTPException(400, "slot must be an object")
|
|
if not _HHMM_RE.match(str(s.get("start") or "")):
|
|
raise HTTPException(400, f"slot.start must be HH:MM, got {s.get('start')!r}")
|
|
if not _HHMM_RE.match(str(s.get("end") or "")):
|
|
raise HTTPException(400, f"slot.end must be HH:MM, got {s.get('end')!r}")
|
|
days = [d for d in (body.get("days") or []) if d in _DAYS]
|
|
if not days:
|
|
# Default to every day if the user didn't pick.
|
|
days = list(_DAYS)
|
|
|
|
# Compose the cookbook: YAML block dropped into event DESCRIPTION
|
|
# so the reconciler knows how to launch.
|
|
yaml_lines = ["cookbook:"]
|
|
for k in ("preset", "repo_id", "cmd", "host", "port"):
|
|
v = body.get(k)
|
|
if v:
|
|
yaml_lines.append(f" {k}: {v}")
|
|
if len(yaml_lines) == 1:
|
|
# Fall back: the title alone is the preset name. Reconciler
|
|
# will preset-match against saved presets at launch time.
|
|
yaml_lines.append(f" preset: {title}")
|
|
description = "\n".join(yaml_lines)
|
|
|
|
# First-occurrence date defaults to today (UTC) so the schedule
|
|
# applies starting now. RRULE-BYDAY handles day filtering.
|
|
start_date = body.get("start_date")
|
|
if start_date:
|
|
try:
|
|
d0 = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
except ValueError:
|
|
raise HTTPException(400, "start_date must be YYYY-MM-DD")
|
|
else:
|
|
d0 = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
until = (body.get("until") or "").strip()
|
|
until_clause = ""
|
|
if until:
|
|
try:
|
|
u = datetime.strptime(until, "%Y-%m-%d")
|
|
until_clause = f";UNTIL={u.strftime('%Y%m%dT235959Z')}"
|
|
except ValueError:
|
|
raise HTTPException(400, "until must be YYYY-MM-DD")
|
|
|
|
rrule = f"FREQ=WEEKLY;BYDAY={','.join(days)}{until_clause}"
|
|
|
|
# Create one event per slot. Call /api/calendar/events directly
|
|
# so we don't reinvent CalDAV plumbing.
|
|
import httpx
|
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
|
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
|
|
created: List[str] = []
|
|
for slot in slots:
|
|
sh, sm = [int(x) for x in str(slot["start"]).split(":")]
|
|
eh, em = [int(x) for x in str(slot["end"]).split(":")]
|
|
dtstart = d0.replace(hour=sh, minute=sm)
|
|
dtend = d0.replace(hour=eh, minute=em)
|
|
if dtend <= dtstart:
|
|
# Overnight: schedule the end on the next day.
|
|
dtend = dtend + timedelta(days=1)
|
|
ev_body = {
|
|
"summary": title,
|
|
"dtstart": dtstart.isoformat(),
|
|
"dtend": dtend.isoformat(),
|
|
"all_day": False,
|
|
"description": description,
|
|
"calendar_href": calendar_href,
|
|
"rrule": rrule,
|
|
"color": "#3b82f6",
|
|
}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
r = await client.post(
|
|
"http://localhost:7000/api/calendar/events",
|
|
json=ev_body, headers=headers,
|
|
)
|
|
if r.status_code >= 400:
|
|
logger.warning(f"schedule: calendar event create failed: {r.status_code} {r.text[:200]}")
|
|
continue
|
|
data = r.json()
|
|
except Exception as e:
|
|
logger.warning(f"schedule: calendar event create errored: {e}")
|
|
continue
|
|
uid = data.get("uid") or data.get("id") or ""
|
|
if uid:
|
|
created.append(uid)
|
|
if not created:
|
|
raise HTTPException(500, "Failed to create any calendar events for this schedule")
|
|
return {"ok": True, "created": created, "slots": len(slots), "rrule": rrule}
|
|
|
|
@router.post("/reconcile-now")
|
|
async def reconcile_now(request: Request):
|
|
"""Manual kick of the reconciler. Useful for testing + the
|
|
\"Run now\" button in the Cookbook UI."""
|
|
require_admin(request)
|
|
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
|