Files
FabledSteward/fabledscryer/ansible/routes.py
T
bvandeusen 230b542015 feat: rename to FabledScryer, multi-dashboard system, plugin management, branding
- Rename package fablednetmon → fabledscryer throughout
- Multi-dashboard: ownership, per-user defaults, HTMX edit (add/remove/reorder)
- Read-only share tokens scoped to individual dashboards
- Dashboard edit is HTMX-driven (no page reloads)
- Plugin management system: remote catalog, download/install, hot-reload, in-app restart
- plugin_index.py: fetch/cache remote index.yaml; default URL → bvandeusen/fabledscryer-plugins
- plugin_manager.py: download_and_install_plugin, hot_reload_plugin, restart_app
  - ZIP extraction handles GitHub archive formats (name-v1.0.0/, name-main/)
- Settings split into tabbed sections: General, Notifications, Ansible, Plugins
- Plugins tab: catalog browser (HTMX), install/activate/update/restart actions
- UI/branding: dark palette (#07071a), crystal ball SVG logo, animated star field,
  Libertinus Serif applied to headings, nav, labels, and section titles
- Widget registry (core/widgets.py) for dashboard plugin integration
- UPS widget.html (dashboard card) and settings/_tabs.html include
- Migrations 0005–0008: dashboards, is_default, ownership, share tokens
- docs/plugins/: writing-a-plugin.md updated with publishing guide,
  index.yaml.example template for fabledscryer-plugins repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:27:56 -04:00

170 lines
5.3 KiB
Python

# fabledscryer/ansible/routes.py
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timezone
from pathlib import Path
from quart import (
Blueprint, Response, current_app, redirect, render_template,
request, session, url_for,
)
from sqlalchemy import select, update
from fabledscryer.ansible import executor, sources as src_module
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.ansible import AnsibleRun, AnsibleRunStatus
from fabledscryer.models.users import UserRole
ansible_bp = Blueprint("ansible", __name__, url_prefix="/ansible")
def _get_sources() -> list[dict]:
ansible_cfg = current_app.config.get("ANSIBLE", {})
return src_module.get_sources(ansible_cfg)
@ansible_bp.get("/")
@require_role(UserRole.viewer)
async def index():
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(AnsibleRun).order_by(AnsibleRun.started_at.desc()).limit(50)
)
runs = result.scalars().all()
return await render_template("ansible/index.html", runs=runs)
@ansible_bp.get("/browse")
@require_role(UserRole.viewer)
async def browse():
all_sources = _get_sources()
source_data = []
for source in all_sources:
playbooks = src_module.discover_playbooks(source["path"])
inventories = src_module.discover_inventories(source["path"])
source_data.append({
"source": source,
"playbooks": playbooks,
"inventories": inventories,
})
return await render_template("ansible/browse.html", source_data=source_data)
@ansible_bp.get("/browse/<source_name>/<path:playbook_path>")
@require_role(UserRole.viewer)
async def view_playbook(source_name: str, playbook_path: str):
all_sources = _get_sources()
source = next((s for s in all_sources if s["name"] == source_name), None)
if source is None:
return "Source not found", 404
contents = src_module.read_playbook(source["path"], playbook_path)
if contents is None:
return "Playbook not found", 404
return await render_template(
"ansible/browse.html",
source_data=[],
view_source=source_name,
view_path=playbook_path,
view_contents=contents,
)
@ansible_bp.post("/runs")
@require_role(UserRole.operator)
async def create_run():
form = await request.form
playbook_path = form.get("playbook_path", "").strip()
source_name = form.get("source_name", "").strip()
inventory_path = form.get("inventory_path", "").strip()
if not playbook_path or not inventory_path:
return "playbook_path and inventory_path are required", 400
all_sources = _get_sources()
source = next((s for s in all_sources if s["name"] == source_name), None)
if source is None:
return "Source not found", 404
run_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
run = AnsibleRun(
id=run_id,
playbook_path=playbook_path,
inventory_path=inventory_path,
source_name=source_name,
triggered_by=session["user_id"],
status=AnsibleRunStatus.running,
started_at=now,
)
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(run)
task = asyncio.create_task(
executor.start_run(
current_app._get_current_object(), # type: ignore[attr-defined]
run_id,
playbook_path,
inventory_path,
source["path"],
)
)
task.add_done_callback(
lambda t: t.exception() and current_app.logger.error(
"Ansible run %s raised: %s", run_id, t.exception()
)
)
return await render_template(
"ansible/run_started.html",
run=run,
source=source,
)
@ansible_bp.get("/runs/<run_id>/stream")
@require_role(UserRole.viewer)
async def stream_run(run_id: str):
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(AnsibleRun).where(AnsibleRun.id == run_id))
run = result.scalar_one_or_none()
if run is None:
return "Not found", 404
async def generate():
if run.status != AnsibleRunStatus.running:
yield f"event: done\ndata: {run.status.value}\n\n"
return
q = executor.register_listener(run_id)
try:
while True:
msg = await q.get()
if isinstance(msg, executor._Done):
yield f"event: done\ndata: {msg.status}\n\n"
break
# Escape newlines in SSE data field
safe = msg.replace("\n", " ")
yield f"event: output\ndata: {safe}\n\n"
finally:
executor.deregister_listener(run_id, q)
return Response(
generate(),
content_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@ansible_bp.get("/runs/<run_id>")
@require_role(UserRole.viewer)
async def run_detail(run_id: str):
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(AnsibleRun).where(AnsibleRun.id == run_id))
run = result.scalar_one_or_none()
if run is None:
return "Not found", 404
return await render_template("ansible/run_detail.html", run=run)