diff --git a/fabledscryer/settings/routes.py b/fabledscryer/settings/routes.py index 6a9a723..ca07098 100644 --- a/fabledscryer/settings/routes.py +++ b/fabledscryer/settings/routes.py @@ -136,30 +136,117 @@ async def save_notifications(): # ── Ansible Sources ─────────────────────────────────────────────────────────── -@settings_bp.get("/ansible/") -@require_role(UserRole.admin) -async def ansible(): +async def _get_ansible_sources() -> list[dict]: async with current_app.db_sessionmaker() as db: settings = await get_all_settings(db) - return await render_template("settings/ansible.html", settings=settings) + sources = settings.get("ansible.sources", []) + return sources if isinstance(sources, list) else [] -@settings_bp.post("/ansible/") -@require_role(UserRole.admin) -async def save_ansible(): - form = await request.form - sources_raw = form.get("ansible.sources", "[]") - try: - sources = json.loads(sources_raw) - if not isinstance(sources, list): - sources = [] - except json.JSONDecodeError: - sources = [] +async def _save_ansible_sources(sources: list[dict]) -> None: async with current_app.db_sessionmaker() as db: async with db.begin(): await set_setting(db, "ansible.sources", sources) await _reload_app_config() - return redirect(url_for("settings.ansible")) + + +async def _ansible_sources_partial(sources: list[dict]): + return await render_template("settings/_ansible_sources.html", sources=sources) + + +@settings_bp.get("/ansible/") +@require_role(UserRole.admin) +async def ansible(): + sources = await _get_ansible_sources() + return await render_template("settings/ansible.html", sources=sources) + + +@settings_bp.post("/ansible/sources/add") +@require_role(UserRole.admin) +async def ansible_add_source(): + form = await request.form + name = form.get("name", "").strip() + src_type = form.get("type", "local").strip() + if not name: + sources = await _get_ansible_sources() + return await _ansible_sources_partial(sources) + entry: dict = {"name": name, "type": src_type} + if src_type == "git": + entry["url"] = form.get("url", "").strip() + entry["branch"] = form.get("branch", "main").strip() or "main" + try: + entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600)) + except ValueError: + entry["pull_interval_seconds"] = 3600 + else: + entry["path"] = form.get("path", "").strip() + sources = await _get_ansible_sources() + sources.append(entry) + await _save_ansible_sources(sources) + return await _ansible_sources_partial(sources) + + +@settings_bp.post("/ansible/sources//remove") +@require_role(UserRole.admin) +async def ansible_remove_source(idx: int): + sources = await _get_ansible_sources() + if 0 <= idx < len(sources): + sources.pop(idx) + await _save_ansible_sources(sources) + return await _ansible_sources_partial(sources) + + +@settings_bp.post("/ansible/sources//save") +@require_role(UserRole.admin) +async def ansible_save_source(idx: int): + sources = await _get_ansible_sources() + if not (0 <= idx < len(sources)): + return await _ansible_sources_partial(sources) + form = await request.form + src_type = form.get("type", "local").strip() + entry: dict = { + "name": form.get("name", sources[idx].get("name", "")).strip(), + "type": src_type, + } + if src_type == "git": + entry["url"] = form.get("url", "").strip() + entry["branch"] = form.get("branch", "main").strip() or "main" + try: + entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600)) + except ValueError: + entry["pull_interval_seconds"] = 3600 + else: + entry["path"] = form.get("path", "").strip() + sources[idx] = entry + await _save_ansible_sources(sources) + return await _ansible_sources_partial(sources) + + +@settings_bp.post("/ansible/sources//sync") +@require_role(UserRole.admin) +async def ansible_sync_source(idx: int): + """Trigger a git pull for a git source and return a status badge.""" + sources = await _get_ansible_sources() + if not (0 <= idx < len(sources)): + return ('Source not found', 404) + source = sources[idx] + if source.get("type") != "git": + return 'Not a git source' + from fabledscryer.ansible.sources import git_pull + from fabledscryer.core.settings import to_ansible_cfg + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + ansible_cfg = to_ansible_cfg(settings) + from fabledscryer.ansible.sources import get_sources + resolved = next((s for s in get_sources(ansible_cfg) if s["name"] == source["name"]), None) + if resolved is None: + return 'Could not resolve source path' + try: + await git_pull(resolved) + return 'Synced' + except Exception as exc: + logger.exception("git pull failed for source %r", source["name"]) + return f'Sync failed: {exc}' # ── Auth (OIDC / LDAP) ──────────────────────────────────────────────────────── diff --git a/fabledscryer/templates/ansible/browse.html b/fabledscryer/templates/ansible/browse.html index 6479252..8f969ab 100644 --- a/fabledscryer/templates/ansible/browse.html +++ b/fabledscryer/templates/ansible/browse.html @@ -3,23 +3,23 @@ {% block content %}

Browse Playbooks

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

{{ view_path }}

- ← Back to browse + ← Back to browse
-
{{ view_contents }}
+
{{ 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.

+

No Ansible sources configured. Add sources in Settings → Ansible.

{% endif %} {% else %} @@ -27,8 +27,8 @@

{{ sd.source.name }}

- {{ sd.source.type }} - {{ sd.source.path }} + {{ sd.source.type }} + {{ sd.source.path }}
{% if not sd.playbooks %} diff --git a/fabledscryer/templates/ansible/run_detail.html b/fabledscryer/templates/ansible/run_detail.html index 8194d9d..c3001f9 100644 --- a/fabledscryer/templates/ansible/run_detail.html +++ b/fabledscryer/templates/ansible/run_detail.html @@ -2,33 +2,33 @@ {% block title %}Run {{ run.id[:8] }} — Fabled Scryer{% endblock %} {% block content %}
- ← Runs + ← Runs

Run Detail

-{% set status_color = {"running": "#a0a000", "success": "#40a040", "failed": "#a04040", "interrupted": "#806040"}.get(run.status.value, "#808080") %} +{% set status_color = {"running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %}
- 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") }} + 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") }} + Finished{{ run.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} {% endif %}
-
- Output +
+ Output
{% if run.status.value == "running" %}
{{ run.output or "" }}
-
+ style="margin:0;padding:1rem;background:var(--bg);font-size:0.78rem;color:var(--green);max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "" }} +
Streaming…
{% else %} -
{{ run.output or "(no output)" }}
+
{{ run.output or "(no output)" }}
{% endif %}
{% endblock %} diff --git a/fabledscryer/templates/settings/_ansible_sources.html b/fabledscryer/templates/settings/_ansible_sources.html new file mode 100644 index 0000000..9d07c17 --- /dev/null +++ b/fabledscryer/templates/settings/_ansible_sources.html @@ -0,0 +1,206 @@ +{# settings/_ansible_sources.html — HTMX partial for Ansible source management #} +
+ + {% if sources %} +
+ {% for src in sources %} + {% set idx = loop.index0 %} + +
+ + {# ── View row ──────────────────────────────────────────────────────── #} +
+ + + {{ src.type }} + + +
+
{{ src.name }}
+
+ {% if src.type == 'git' %} + {{ src.url or '(no URL)' }} + branch: {{ src.branch or 'main' }} + {% if src.pull_interval_seconds %}· pull every {{ src.pull_interval_seconds }}s{% endif %} + {% else %} + {{ src.path or '(no path)' }} + {% endif %} +
+
+ +
+ Browse + + {% if src.type == 'git' %} + + + {% endif %} + + + + +
+
+ + {# ── Inline edit form ──────────────────────────────────────────────── #} + + +
+ {% endfor %} +
+ {% else %} +
+ No sources configured. Add one below. +
+ {% endif %} + + {# ── Add source ────────────────────────────────────────────────────────── #} +
+ + Add Source + New ▾ + + +
+
+ +
+ + +
+ +
+ + +
+ + + +
+
+ + +
+
+ +
+ +
+
+ +
+ + diff --git a/fabledscryer/templates/settings/ansible.html b/fabledscryer/templates/settings/ansible.html index 3a8ee9e..2660902 100644 --- a/fabledscryer/templates/settings/ansible.html +++ b/fabledscryer/templates/settings/ansible.html @@ -5,18 +5,15 @@ {% set active_tab = "ansible" %} {% include "settings/_tabs.html" %} -
-
-

Ansible Sources

-

- JSON array of source objects. Each requires name, type - (git or local), and either url or path. +

+
+
Ansible Sources
+ Run History → +
+

+ Sources are directories of playbooks — either a local path or a git repository + cloned automatically. Add as many as you need; each can be browsed and run independently.

- + {% include "settings/_ansible_sources.html" %}
-
- -
- {% endblock %}