stage-33c pulse: admin cockpit page
This commit is contained in:
120
src/psyc/cockpit/pulse_routes.py
Normal file
120
src/psyc/cockpit/pulse_routes.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Cockpit routes for the Pulse scheduler — admin-gated.
|
||||
|
||||
The integration is intentionally single-call: `register(app, TEMPLATES)` adds
|
||||
the routes AND wires the FastAPI startup hook that launches the background
|
||||
scheduler loop. Caller in app.py just imports + invokes register().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from psyc import log
|
||||
from psyc.lines import pulse
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
|
||||
TICK_INTERVAL_SECONDS = 30
|
||||
|
||||
|
||||
def _admin_ok(request: Request) -> bool:
|
||||
"""Mirror of the local helper in app.py — admin session is just session['admin_ok']."""
|
||||
return bool(request.session.get("admin_ok"))
|
||||
|
||||
|
||||
def _relative(dt: Optional[datetime]) -> str:
|
||||
"""Human-friendly "3m ago" / "in 12m" / "now". None → '—'."""
|
||||
if dt is None:
|
||||
return "—"
|
||||
now = datetime.now(timezone.utc)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
delta = (dt - now).total_seconds()
|
||||
past = delta < 0
|
||||
secs = abs(int(delta))
|
||||
if secs < 5:
|
||||
return "now"
|
||||
if secs < 60:
|
||||
unit = f"{secs}s"
|
||||
elif secs < 3600:
|
||||
unit = f"{secs // 60}m"
|
||||
elif secs < 86400:
|
||||
unit = f"{secs // 3600}h"
|
||||
else:
|
||||
unit = f"{secs // 86400}d"
|
||||
return f"{unit} ago" if past else f"in {unit}"
|
||||
|
||||
|
||||
def register(app: FastAPI, templates: Jinja2Templates) -> None:
|
||||
"""Attach the /admin/pulse routes and the background scheduler loop to `app`."""
|
||||
|
||||
@app.get("/admin/pulse", response_class=HTMLResponse)
|
||||
def pulse_view(request: Request) -> HTMLResponse:
|
||||
if not _admin_ok(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
flash = request.query_params.get("flash", "")
|
||||
pipelines = pulse.state()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin_pulse.html",
|
||||
{
|
||||
"pipelines": pipelines,
|
||||
"kill_switch": pulse.kill_switch_state(),
|
||||
"tick_interval": TICK_INTERVAL_SECONDS,
|
||||
"relative": _relative,
|
||||
"flash": flash,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/admin/pulse/kill")
|
||||
def pulse_toggle_kill(request: Request) -> RedirectResponse:
|
||||
if not _admin_ok(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
new = not pulse.kill_switch_state()
|
||||
pulse.set_kill_switch(new)
|
||||
flash = "kill switch ARMED — all pipelines halted" if new else "kill switch disarmed — pulse resumes"
|
||||
return RedirectResponse(f"/admin/pulse?flash={flash}", status_code=303)
|
||||
|
||||
@app.post("/admin/pulse/{name}/update")
|
||||
def pulse_update(
|
||||
request: Request,
|
||||
name: str,
|
||||
mode: str = Form(...),
|
||||
cadence_seconds: int = Form(...),
|
||||
enabled: Optional[str] = Form(None),
|
||||
) -> RedirectResponse:
|
||||
if not _admin_ok(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
try:
|
||||
pulse.set_mode(name, pulse.PulseMode(mode))
|
||||
pulse.set_cadence(name, int(cadence_seconds))
|
||||
pulse.set_enabled(name, enabled is not None)
|
||||
flash = f"updated {name}: mode={mode}, cadence={cadence_seconds}s, enabled={enabled is not None}"
|
||||
except (ValueError, KeyError) as exc:
|
||||
_log.warning("pulse.update.error", name=name, error=str(exc))
|
||||
flash = f"update failed: {exc}"
|
||||
return RedirectResponse(f"/admin/pulse?flash={flash}", status_code=303)
|
||||
|
||||
@app.post("/admin/pulse/{name}/run")
|
||||
def pulse_run_now(request: Request, name: str) -> RedirectResponse:
|
||||
if not _admin_ok(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
try:
|
||||
outcome, result = pulse.run_now(name)
|
||||
flash = f"{name} → {outcome}: {result[:120]}"
|
||||
except ValueError as exc:
|
||||
flash = f"run failed: {exc}"
|
||||
return RedirectResponse(f"/admin/pulse?flash={flash}", status_code=303)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _start_pulse_loop() -> None:
|
||||
# Fire-and-forget; the loop catches its own exceptions and self-restarts.
|
||||
asyncio.create_task(pulse.start_background_loop(interval_seconds=TICK_INTERVAL_SECONDS))
|
||||
_log.info("pulse.routes.registered", tick=TICK_INTERVAL_SECONDS)
|
||||
101
src/psyc/cockpit/templates/admin_pulse.html
Normal file
101
src/psyc/cockpit/templates/admin_pulse.html
Normal file
@@ -0,0 +1,101 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Pulse — psyc admin{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h1>Pulse — autonomous heartbeat</h1>
|
||||
<span class="count">{{ pipelines|length }} pipeline{{ '' if pipelines|length == 1 else 's' }}</span>
|
||||
</div>
|
||||
<p class="page-intro">Cron-style scheduler that drives every psyc line on a cadence without human input. Each pipeline has an autonomy mode and a cadence in seconds. The kill switch halts everything instantly — it overrides cadence, mode, and the enabled flag.</p>
|
||||
<p class="back"><a href="/admin">← back to admin</a></p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="verdict verdict-clean">{{ flash }}</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Global kill switch</h2>
|
||||
<span class="count">{{ 'ARMED' if kill_switch else 'OFF' }}</span>
|
||||
</div>
|
||||
{% if kill_switch %}
|
||||
<div class="verdict" style="background:rgba(248,113,113,0.12); border:1px solid rgba(248,113,113,0.45); color:#fca5a5; padding:14px 18px; border-radius:6px; font-weight:700; letter-spacing:0.04em;">
|
||||
✗ KILL SWITCH ARMED — every pipeline is paused. tick() returns "skipped" for everything. Run-now is also blocked. Toggle off to resume.
|
||||
</div>
|
||||
<form method="post" action="/admin/pulse/kill" style="margin-top:14px;">
|
||||
<button type="submit" class="btn btn-approve">Disarm kill switch</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="verdict verdict-clean">✓ Pulse is live — the background loop ticks every {{ tick_interval }}s.</div>
|
||||
<form method="post" action="/admin/pulse/kill" style="margin-top:14px;"
|
||||
onsubmit="return confirm('Arm the kill switch? Every pipeline halts immediately.');">
|
||||
<button type="submit" class="btn btn-reject">Arm kill switch</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Pipelines</h2>
|
||||
<span class="count">{{ pipelines|selectattr('enabled')|list|length }} enabled</span>
|
||||
</div>
|
||||
<p class="page-intro">Mode: <code>auto-execute</code> fires the line, <code>auto-propose</code> stages proposals for human approval, <code>manual</code> runs only when you press “Run now”. Cadence is the gap between ticks; the loop wakes up every {{ tick_interval }}s.</p>
|
||||
|
||||
<table class="ledger">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:24%;">Pipeline</th>
|
||||
<th>Mode · cadence · enabled</th>
|
||||
<th style="width:11%;">Last fired</th>
|
||||
<th style="width:11%;">Next fire</th>
|
||||
<th>Last result</th>
|
||||
<th style="width:1%;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in pipelines %}
|
||||
<tr class="ledger-row">
|
||||
<td>
|
||||
<strong>{{ p.title }}</strong>
|
||||
<div class="lg-sub"><code>{{ p.name }}</code> · {{ p.description }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/pulse/{{ p.name }}/update" class="queue-action" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||
<select name="mode" style="background:var(--panel-2); color:var(--text); border:1px solid var(--border); border-radius:4px; padding:4px 6px;">
|
||||
<option value="auto-execute" {% if p.mode.value == 'auto-execute' %}selected{% endif %}>auto-execute</option>
|
||||
<option value="auto-propose" {% if p.mode.value == 'auto-propose' %}selected{% endif %}>auto-propose</option>
|
||||
<option value="manual" {% if p.mode.value == 'manual' %}selected{% endif %}>manual</option>
|
||||
</select>
|
||||
<input type="number" name="cadence_seconds" value="{{ p.cadence_seconds }}" min="1" step="1"
|
||||
style="background:var(--panel-2); color:var(--text); border:1px solid var(--border); border-radius:4px; padding:4px 6px; width:84px;" title="cadence in seconds">
|
||||
<label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
|
||||
<input type="checkbox" name="enabled" value="1" {% if p.enabled %}checked{% endif %}> enabled
|
||||
</label>
|
||||
<button type="submit" class="btn">save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="lg-ts">
|
||||
{% if p.last_fired %}<span title="{{ p.last_fired.isoformat() }}">{{ relative(p.last_fired) }}</span>{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="lg-ts">
|
||||
{% if p.next_fire %}<span title="{{ p.next_fire.isoformat() }}">{{ relative(p.next_fire) }}</span>{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="lg-sub" style="max-width:0;">
|
||||
{% if p.last_outcome == 'ok' %}<span style="color: var(--green);">✓</span>
|
||||
{% elif p.last_outcome == 'err' %}<span style="color: var(--red);">✗</span>
|
||||
{% elif p.last_outcome == 'skipped' %}<span style="color: var(--muted);">⊘</span>
|
||||
{% endif %}
|
||||
{{ (p.last_result or '—')[:60] }}{% if (p.last_result or '')|length > 60 %}…{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/pulse/{{ p.name }}/run" class="queue-action">
|
||||
<button type="submit" class="btn btn-enforce" title="Fire now, regardless of cadence">Run now</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user