stage-33c pulse: admin cockpit page

This commit is contained in:
m17hr1l
2026-06-06 16:04:39 +02:00
parent 4d67605371
commit 26fbe08b65
2 changed files with 221 additions and 0 deletions

View 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)

View 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 %}