From 94e0f20f98767488c0b6872c38629d2ee67ccd05 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 17 Mar 2026 20:27:51 -0400 Subject: [PATCH] feat: add ansible routes and templates (browse, run, stream, detail) Co-Authored-By: Claude Sonnet 4.6 --- fablednetmon/ansible/routes.py | 161 ++++++++++++++++++ fablednetmon/templates/ansible/browse.html | 102 +++++++++++ fablednetmon/templates/ansible/index.html | 43 +++++ .../templates/ansible/run_detail.html | 53 ++++++ .../templates/ansible/run_started.html | 32 ++++ 5 files changed, 391 insertions(+) create mode 100644 fablednetmon/ansible/routes.py create mode 100644 fablednetmon/templates/ansible/browse.html create mode 100644 fablednetmon/templates/ansible/index.html create mode 100644 fablednetmon/templates/ansible/run_detail.html create mode 100644 fablednetmon/templates/ansible/run_started.html diff --git a/fablednetmon/ansible/routes.py b/fablednetmon/ansible/routes.py new file mode 100644 index 0000000..2ce9758 --- /dev/null +++ b/fablednetmon/ansible/routes.py @@ -0,0 +1,161 @@ +# fablednetmon/ansible/routes.py +from __future__ import annotations +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 fablednetmon.ansible import executor, sources as src_module +from fablednetmon.auth.middleware import require_role +from fablednetmon.models.ansible import AnsibleRun, AnsibleRunStatus +from fablednetmon.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//") +@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() + + 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) + + import asyncio + asyncio.create_task( + executor.start_run( + current_app._get_current_object(), # type: ignore[attr-defined] + run_id, + playbook_path, + inventory_path, + source["path"], + ) + ) + + return await render_template( + "ansible/run_started.html", + run=run, + source=source, + ) + + +@ansible_bp.get("/runs//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/") +@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) diff --git a/fablednetmon/templates/ansible/browse.html b/fablednetmon/templates/ansible/browse.html new file mode 100644 index 0000000..fbd64ea --- /dev/null +++ b/fablednetmon/templates/ansible/browse.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} +{% block title %}Browse Playbooks — FabledNetMon{% endblock %} +{% block content %} +
+

Browse Playbooks

+ ← Run History +
+ +{% if view_contents is defined %} +
+
+

{{ view_path }}

+ ← Back to browse +
+
{{ view_contents }}
+
+{% endif %} + +{% if not source_data %} +{% if view_contents is not defined %} +
+

No Ansible sources configured. Add sources under ansible.sources in config.yaml.

+
+{% endif %} +{% else %} +{% for sd in source_data %} +
+
+

{{ sd.source.name }}

+ {{ sd.source.type }} + {{ sd.source.path }} +
+ + {% if not sd.playbooks %} +

No playbooks found at this path.

+ {% else %} + + + + + + + + + {% for pb in sd.playbooks %} + + + + + {% endfor %} + +
Playbook
{{ pb }} + View + Run +
+ + {# Run trigger form for this source #} + + {% endif %} +
+{% endfor %} +{% endif %} +{% endblock %} diff --git a/fablednetmon/templates/ansible/index.html b/fablednetmon/templates/ansible/index.html new file mode 100644 index 0000000..a198a3b --- /dev/null +++ b/fablednetmon/templates/ansible/index.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}Ansible Runs — FabledNetMon{% endblock %} +{% block content %} +
+

Ansible Runs

+ Browse Playbooks +
+{% if not runs %} +
+

No runs yet. Browse playbooks to trigger a run.

+
+{% else %} +
+ + + + + + + + + + + + {% for run in runs %} + {% set status_color = {"running": "#a0a000", "success": "#40a040", "failed": "#a04040", "interrupted": "#806040"}[run.status.value] %} + + + + + + + + {% endfor %} + +
PlaybookSourceStatusStarted
{{ run.playbook_path }}{{ run.source_name }} + {{ run.status.value.upper() }} + {{ run.started_at.strftime("%Y-%m-%d %H:%M") }} + View +
+
+{% endif %} +{% endblock %} diff --git a/fablednetmon/templates/ansible/run_detail.html b/fablednetmon/templates/ansible/run_detail.html new file mode 100644 index 0000000..707088c --- /dev/null +++ b/fablednetmon/templates/ansible/run_detail.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}Run {{ run.id[:8] }} — FabledNetMon{% endblock %} +{% block content %} +
+ ← Runs +

Run Detail

+
+ +{% set status_color = {"running": "#a0a000", "success": "#40a040", "failed": "#a04040", "interrupted": "#806040"}[run.status.value] %} +
+
+ ID{{ run.id }} + Playbook{{ run.playbook_path }} + Inventory{{ run.inventory_path }} + Source{{ run.source_name }} + Status{{ run.status.value.upper() }} + Started{{ run.started_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} + {% if run.finished_at %} + Finished{{ run.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} + {% endif %} +
+
+ +
+
+ Output +
+ {% if run.status.value == "running" %} +
{{ run.output or "" }}
+
+ Streaming… +
+ + {% else %} +
{{ run.output or "(no output)" }}
+ {% endif %} +
+{% endblock %} diff --git a/fablednetmon/templates/ansible/run_started.html b/fablednetmon/templates/ansible/run_started.html new file mode 100644 index 0000000..318463c --- /dev/null +++ b/fablednetmon/templates/ansible/run_started.html @@ -0,0 +1,32 @@ +
+
+ RUNNING + {{ run.playbook_path }} + Full view → +
+
+
+        
+
+
+ Streaming output… +
+ +