- Schedules web UI (/settings/crons): list, add, edit, pause/resume, delete jobs - cron task type: full orchestrator tool loop on a schedule, result → notification channel - parse_schedule: monthly/yearly formats (monthly:DD:HH:MM, yearly:MM:DD:HH:MM) - HA inbound webhook tools toggle: orchestrator loop vs. direct LLM, configurable in UI - ae_db_query/describe/show_view: SELECT-only Aether MariaDB access (admin, per-user creds) - /settings/integrations: admin-only page for Aether DB credentials - Schedules nav link added to all settings pages - pymysql added to requirements - Docs updated: HELP.md, MASTER.md, CLAUDE.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
480 lines
17 KiB
Python
480 lines
17 KiB
Python
"""
|
|
Schedules web UI — GET/POST /settings/crons/*
|
|
|
|
Lets users view, add, toggle, and remove cron jobs without going through the AI.
|
|
Cron data lives in home/{user}/persona/{persona}/CRONS.json.
|
|
Scheduler registration mirrors what tools/cron.py does so changes take effect immediately.
|
|
"""
|
|
|
|
import html as _html
|
|
import logging
|
|
import secrets
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import jwt
|
|
from fastapi import APIRouter, Form, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
from auth_utils import COOKIE_NAME, decode_token
|
|
from cron_runner import load_crons, save_crons, parse_schedule
|
|
from persona import list_user_personas
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
_STATIC = Path(__file__).parent.parent / "static"
|
|
_LAST_PERSONA_COOKIE = "cx_last_persona"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _get_session_user(request: Request) -> str | None:
|
|
token = request.cookies.get(COOKIE_NAME)
|
|
if not token:
|
|
return None
|
|
try:
|
|
return decode_token(token)
|
|
except jwt.InvalidTokenError:
|
|
return None
|
|
|
|
|
|
def _preferred_persona(request: Request, username: str) -> str:
|
|
names = list_user_personas(username)
|
|
if not names:
|
|
return ""
|
|
cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "")
|
|
if cookie_val in names:
|
|
return cookie_val
|
|
return names[0]
|
|
|
|
|
|
def _integrations_nav(username: str) -> str:
|
|
from auth_utils import _read_auth
|
|
role = _read_auth(username).get("role", "user")
|
|
if role == "admin":
|
|
return '<a href="/settings/integrations" class="nav-link">Integrations</a>'
|
|
return ""
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _short_id() -> str:
|
|
return "c_" + secrets.token_urlsafe(6)
|
|
|
|
|
|
def _scheduler_add(job: dict, sched_kwargs: dict) -> None:
|
|
import asyncio
|
|
try:
|
|
import scheduler as sched_module
|
|
from cron_runner import run_job
|
|
s = sched_module.get_scheduler()
|
|
if s and s.running:
|
|
sched_id = f"{job['user']}:{job['persona']}:{job['id']}"
|
|
s.add_job(
|
|
lambda j=job: asyncio.ensure_future(run_job(j)),
|
|
"cron",
|
|
id=sched_id,
|
|
replace_existing=True,
|
|
**sched_kwargs,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("scheduler_add failed: %s", e)
|
|
|
|
|
|
def _scheduler_remove(job_id: str) -> None:
|
|
try:
|
|
import scheduler as sched_module
|
|
s = sched_module.get_scheduler()
|
|
if s and s.running:
|
|
s.remove_job(job_id)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _scheduler_pause(job_id: str) -> None:
|
|
try:
|
|
import scheduler as sched_module
|
|
s = sched_module.get_scheduler()
|
|
if s and s.running:
|
|
s.pause_job(job_id)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _scheduler_resume(job_id: str) -> None:
|
|
try:
|
|
import scheduler as sched_module
|
|
s = sched_module.get_scheduler()
|
|
if s and s.running:
|
|
s.resume_job(job_id)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
_TYPE_CLASS = {
|
|
"remind": "badge-remind", "note": "badge-note", "message": "badge-message",
|
|
"brief": "badge-brief", "task": "badge-task",
|
|
}
|
|
|
|
|
|
def _render_cron_list(username: str) -> str:
|
|
personas = list_user_personas(username)
|
|
if not personas:
|
|
return '<div class="empty-state">No personas found.</div>'
|
|
|
|
all_empty = True
|
|
html_parts: list[str] = []
|
|
|
|
for persona in personas:
|
|
crons = load_crons(username, persona)
|
|
if not crons:
|
|
continue
|
|
all_empty = False
|
|
|
|
rows = []
|
|
for c in crons:
|
|
cid = _html.escape(c["id"])
|
|
label = _html.escape(c.get("label", ""))
|
|
schedule = _html.escape(c.get("schedule", ""))
|
|
job_type = _html.escape(c.get("type", ""))
|
|
payload = _html.escape(c.get("payload", ""))
|
|
enabled = c.get("enabled", True)
|
|
last_run = (c.get("last_run") or "")[:10] or "never"
|
|
pers_esc = _html.escape(persona)
|
|
|
|
type_class = _TYPE_CLASS.get(c.get("type", ""), "badge-note")
|
|
status_cls = "badge-enabled" if enabled else "badge-paused"
|
|
status_txt = "enabled" if enabled else "paused"
|
|
toggle_txt = "Pause" if enabled else "Resume"
|
|
|
|
rows.append(f"""
|
|
<tr>
|
|
<td>{label}</td>
|
|
<td><code>{schedule}</code></td>
|
|
<td><span class="badge {type_class}">{job_type}</span></td>
|
|
<td class="payload-cell" title="{payload}">{payload}</td>
|
|
<td>{last_run}</td>
|
|
<td><span class="badge {status_cls}">{status_txt}</span></td>
|
|
<td>
|
|
<div class="cron-actions">
|
|
<a href="/settings/crons/edit?cron_id={cid}&persona={pers_esc}"
|
|
class="btn-cron">Edit</a>
|
|
<form method="POST" action="/settings/crons/toggle" style="display:inline">
|
|
<input type="hidden" name="cron_id" value="{cid}">
|
|
<input type="hidden" name="persona" value="{pers_esc}">
|
|
<button type="submit" class="btn-cron">{toggle_txt}</button>
|
|
</form>
|
|
<form method="POST" action="/settings/crons/remove" style="display:inline"
|
|
onsubmit="return confirm('Delete this schedule?')">
|
|
<input type="hidden" name="cron_id" value="{cid}">
|
|
<input type="hidden" name="persona" value="{pers_esc}">
|
|
<button type="submit" class="btn-cron btn-cron-del">Delete</button>
|
|
</form>
|
|
</div>
|
|
</td>
|
|
</tr>""")
|
|
|
|
html_parts.append(f"""
|
|
<div class="persona-group">
|
|
<p class="persona-group-label">{_html.escape(persona)}</p>
|
|
<table class="cron-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Label</th><th>Schedule</th><th>Type</th>
|
|
<th>Payload</th><th>Last run</th><th>Status</th><th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>{"".join(rows)}
|
|
</tbody>
|
|
</table>
|
|
</div>""")
|
|
|
|
if all_empty:
|
|
return '<div class="empty-state">No schedules yet. Add one below.</div>'
|
|
|
|
return "\n".join(html_parts)
|
|
|
|
|
|
def _persona_options(username: str, selected: str = "") -> str:
|
|
personas = list_user_personas(username)
|
|
return "\n".join(
|
|
f'<option value="{_html.escape(p)}"{"selected" if p == selected else ""}>{_html.escape(p)}</option>'
|
|
for p in personas
|
|
)
|
|
|
|
|
|
_TYPE_OPTIONS = ("remind", "note", "message", "brief", "task")
|
|
_TYPE_LABELS = {
|
|
"remind": "remind — append to REMINDERS.md",
|
|
"note": "note — append to SCRATCH.md",
|
|
"message": "message — send payload as-is",
|
|
"brief": "brief — LLM response, no tools",
|
|
"task": "task — full orchestrator tool loop",
|
|
}
|
|
|
|
|
|
def _render_edit_form(job: dict, persona: str) -> str:
|
|
cid = _html.escape(job["id"])
|
|
pers_esc = _html.escape(persona)
|
|
label = _html.escape(job.get("label", ""))
|
|
schedule = _html.escape(job.get("schedule", ""))
|
|
payload = _html.escape(job.get("payload", ""))
|
|
cur_type = job.get("type", "remind")
|
|
|
|
type_opts = "\n".join(
|
|
f'<option value="{t}" {"selected" if t == cur_type else ""}>{_html.escape(_TYPE_LABELS.get(t, t))}</option>'
|
|
for t in _TYPE_OPTIONS
|
|
)
|
|
|
|
return f"""
|
|
<div class="section" style="border: 2px solid var(--pg-accent); border-radius: 8px; padding: 1rem;">
|
|
<h2 style="margin-top:0">Edit schedule</h2>
|
|
<form method="POST" action="/settings/crons/save">
|
|
<input type="hidden" name="cron_id" value="{cid}">
|
|
<input type="hidden" name="persona" value="{pers_esc}">
|
|
<div class="add-form-grid">
|
|
<div class="field">
|
|
<label>Persona</label>
|
|
<input type="text" value="{pers_esc}" disabled style="opacity:0.5">
|
|
</div>
|
|
<div class="field">
|
|
<label for="edit_job_type">Type</label>
|
|
<select id="edit_job_type" name="job_type">{type_opts}</select>
|
|
</div>
|
|
<div class="field">
|
|
<label for="edit_label">Label</label>
|
|
<input type="text" id="edit_label" name="label" value="{label}" required autocomplete="off">
|
|
</div>
|
|
<div class="field">
|
|
<label for="edit_schedule">Schedule</label>
|
|
<input type="text" id="edit_schedule" name="schedule" value="{schedule}"
|
|
required autocomplete="off" spellcheck="false">
|
|
<p class="hint">
|
|
hourly · daily · daily:HH:MM · weekly:DOW · weekly:DOW:HH:MM ·
|
|
monthly · monthly:DD · monthly:DD:HH:MM · yearly:MM:DD · yearly:MM:DD:HH:MM
|
|
</p>
|
|
</div>
|
|
<div class="field field-full">
|
|
<label for="edit_payload">Payload / prompt</label>
|
|
<textarea id="edit_payload" name="payload" rows="3" required>{payload}</textarea>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex; gap:0.5rem; align-items:center; margin-top:0.5rem">
|
|
<button type="submit" class="btn-submit" style="margin-top:0">Save changes</button>
|
|
<a href="/settings/crons" style="font-size:0.85rem; color:var(--pg-muted)">Cancel</a>
|
|
</div>
|
|
</form>
|
|
</div>"""
|
|
|
|
|
|
def _render_page(username: str, back_persona: str = "", success: str = "", error: str = "",
|
|
edit_html: str = "") -> str:
|
|
html = (_STATIC / "crons.html").read_text()
|
|
html = html.replace("{{ edit_html }}", edit_html)
|
|
html = html.replace("{{ cron_list_html }}", _render_cron_list(username))
|
|
html = html.replace("{{ persona_options }}", _persona_options(username, back_persona))
|
|
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
|
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
|
|
html = html.replace("{{ integrations_nav }}", _integrations_nav(username))
|
|
if success:
|
|
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{_html.escape(success)}</p>')
|
|
if error:
|
|
html = html.replace("<!-- ERROR -->", f'<p class="error">{_html.escape(error)}</p>')
|
|
return html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/settings/crons", include_in_schema=False)
|
|
async def crons_page(request: Request):
|
|
username = _get_session_user(request)
|
|
if not username:
|
|
return RedirectResponse("/login", status_code=302)
|
|
back_persona = _preferred_persona(request, username)
|
|
return HTMLResponse(_render_page(username, back_persona))
|
|
|
|
|
|
@router.post("/settings/crons/add", include_in_schema=False)
|
|
async def cron_add(
|
|
request: Request,
|
|
persona: str = Form(""),
|
|
label: str = Form(""),
|
|
schedule: str = Form(""),
|
|
job_type: str = Form(""),
|
|
payload: str = Form(""),
|
|
):
|
|
username = _get_session_user(request)
|
|
if not username:
|
|
return RedirectResponse("/login", status_code=302)
|
|
back_persona = _preferred_persona(request, username)
|
|
|
|
label = label.strip()
|
|
schedule = schedule.strip()
|
|
payload = payload.strip()
|
|
persona = persona.strip()
|
|
|
|
_VALID_TYPES = ("remind", "note", "message", "brief", "task")
|
|
if job_type not in _VALID_TYPES:
|
|
return HTMLResponse(_render_page(username, back_persona, error=f"Invalid type: {job_type}"))
|
|
|
|
try:
|
|
sched_kwargs = parse_schedule(schedule)
|
|
except ValueError as e:
|
|
return HTMLResponse(_render_page(username, back_persona, error=f"Bad schedule: {e}"))
|
|
|
|
if not label:
|
|
return HTMLResponse(_render_page(username, back_persona, error="Label is required."))
|
|
if not payload:
|
|
return HTMLResponse(_render_page(username, back_persona, error="Payload is required."))
|
|
|
|
crons = load_crons(username, persona)
|
|
job = {
|
|
"id": _short_id(),
|
|
"user": username,
|
|
"persona": persona,
|
|
"label": label,
|
|
"schedule": schedule,
|
|
"type": job_type,
|
|
"payload": payload,
|
|
"enabled": True,
|
|
"created_at": _now(),
|
|
"last_run": None,
|
|
}
|
|
crons.append(job)
|
|
save_crons(crons, username, persona)
|
|
_scheduler_add(job, sched_kwargs)
|
|
|
|
logger.info("cron added via UI: %s %s [%s]", job["id"], schedule, job_type)
|
|
return HTMLResponse(_render_page(username, back_persona, success=f"Schedule '{label}' added."))
|
|
|
|
|
|
@router.post("/settings/crons/toggle", include_in_schema=False)
|
|
async def cron_toggle(
|
|
request: Request,
|
|
cron_id: str = Form(""),
|
|
persona: str = Form(""),
|
|
):
|
|
username = _get_session_user(request)
|
|
if not username:
|
|
return RedirectResponse("/login", status_code=302)
|
|
back_persona = _preferred_persona(request, username)
|
|
|
|
crons = load_crons(username, persona)
|
|
for c in crons:
|
|
if c["id"] == cron_id:
|
|
c["enabled"] = not c.get("enabled", True)
|
|
save_crons(crons, username, persona)
|
|
sched_id = f"{username}:{persona}:{cron_id}"
|
|
if c["enabled"]:
|
|
_scheduler_resume(sched_id)
|
|
action = "resumed"
|
|
else:
|
|
_scheduler_pause(sched_id)
|
|
action = "paused"
|
|
logger.info("cron %s %s via UI", cron_id, action)
|
|
return HTMLResponse(_render_page(username, back_persona, success=f"Schedule {action}."))
|
|
|
|
return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))
|
|
|
|
|
|
@router.post("/settings/crons/remove", include_in_schema=False)
|
|
async def cron_remove(
|
|
request: Request,
|
|
cron_id: str = Form(""),
|
|
persona: str = Form(""),
|
|
):
|
|
username = _get_session_user(request)
|
|
if not username:
|
|
return RedirectResponse("/login", status_code=302)
|
|
back_persona = _preferred_persona(request, username)
|
|
|
|
crons = load_crons(username, persona)
|
|
before = len(crons)
|
|
crons = [c for c in crons if c["id"] != cron_id]
|
|
if len(crons) == before:
|
|
return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))
|
|
|
|
save_crons(crons, username, persona)
|
|
_scheduler_remove(f"{username}:{persona}:{cron_id}")
|
|
logger.info("cron %s removed via UI", cron_id)
|
|
return HTMLResponse(_render_page(username, back_persona, success="Schedule deleted."))
|
|
|
|
|
|
@router.get("/settings/crons/edit", include_in_schema=False)
|
|
async def cron_edit_page(request: Request, cron_id: str = "", persona: str = ""):
|
|
username = _get_session_user(request)
|
|
if not username:
|
|
return RedirectResponse("/login", status_code=302)
|
|
back_persona = _preferred_persona(request, username)
|
|
|
|
crons = load_crons(username, persona)
|
|
job = next((c for c in crons if c["id"] == cron_id), None)
|
|
if not job:
|
|
return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))
|
|
|
|
edit_html = _render_edit_form(job, persona)
|
|
return HTMLResponse(_render_page(username, back_persona, edit_html=edit_html))
|
|
|
|
|
|
@router.post("/settings/crons/save", include_in_schema=False)
|
|
async def cron_save(
|
|
request: Request,
|
|
cron_id: str = Form(""),
|
|
persona: str = Form(""),
|
|
label: str = Form(""),
|
|
schedule: str = Form(""),
|
|
job_type: str = Form(""),
|
|
payload: str = Form(""),
|
|
):
|
|
username = _get_session_user(request)
|
|
if not username:
|
|
return RedirectResponse("/login", status_code=302)
|
|
back_persona = _preferred_persona(request, username)
|
|
|
|
label = label.strip()
|
|
schedule = schedule.strip()
|
|
payload = payload.strip()
|
|
|
|
if job_type not in _TYPE_OPTIONS:
|
|
return HTMLResponse(_render_page(username, back_persona, error=f"Invalid type: {job_type}"))
|
|
if not label:
|
|
return HTMLResponse(_render_page(username, back_persona, error="Label is required."))
|
|
if not payload:
|
|
return HTMLResponse(_render_page(username, back_persona, error="Payload is required."))
|
|
|
|
try:
|
|
sched_kwargs = parse_schedule(schedule)
|
|
except ValueError as e:
|
|
# Re-render with the edit form still open so the user can fix the schedule
|
|
crons = load_crons(username, persona)
|
|
job = next((c for c in crons if c["id"] == cron_id), None)
|
|
edit_html = _render_edit_form(job, persona) if job else ""
|
|
return HTMLResponse(_render_page(username, back_persona, error=f"Bad schedule: {e}",
|
|
edit_html=edit_html))
|
|
|
|
crons = load_crons(username, persona)
|
|
for c in crons:
|
|
if c["id"] == cron_id:
|
|
c["label"] = label
|
|
c["schedule"] = schedule
|
|
c["type"] = job_type
|
|
c["payload"] = payload
|
|
save_crons(crons, username, persona)
|
|
# Replace the live scheduler job with the updated schedule
|
|
sched_id = f"{username}:{persona}:{cron_id}"
|
|
_scheduler_remove(sched_id)
|
|
if c.get("enabled", True):
|
|
_scheduler_add(c, sched_kwargs)
|
|
logger.info("cron %s updated via UI [%s]", cron_id, schedule)
|
|
return HTMLResponse(_render_page(username, back_persona,
|
|
success=f"Schedule '{label}' updated."))
|
|
|
|
return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))
|