feat: Ansible source manager UI — replace raw JSON textarea

- Replace the raw JSON textarea in Settings → Ansible with a proper
  per-source card UI: add, inline-edit, remove, and git-sync each
  source independently via HTMX without a page reload
- Each source card shows type badge (git/local), URL or path, branch,
  and pull interval; git sources get a Sync button that triggers
  git pull and returns inline status
- Add source form uses a type-aware select that shows URL/branch/interval
  fields for git or path field for local, collapsed in a details accordion
- Add new settings routes: sources/add, sources/<idx>/remove,
  sources/<idx>/save, sources/<idx>/sync
- Update browse.html and run_detail.html to use CSS vars throughout
  (removed hardcoded hex colors); link empty state to Settings page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 17:24:35 -04:00
parent edc9b752d2
commit 56283df5a1
5 changed files with 338 additions and 48 deletions
+103 -16
View File
@@ -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/<int:idx>/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/<int:idx>/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/<int:idx>/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 ('<span style="color:var(--red);font-size:0.8rem;">Source not found</span>', 404)
source = sources[idx]
if source.get("type") != "git":
return '<span style="color:var(--text-muted);font-size:0.8rem;">Not a git source</span>'
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 '<span style="color:var(--red);font-size:0.8rem;">Could not resolve source path</span>'
try:
await git_pull(resolved)
return '<span style="color:var(--green);font-size:0.8rem;">Synced</span>'
except Exception as exc:
logger.exception("git pull failed for source %r", source["name"])
return f'<span style="color:var(--red);font-size:0.8rem;">Sync failed: {exc}</span>'
# ── Auth (OIDC / LDAP) ────────────────────────────────────────────────────────
+6 -6
View File
@@ -3,23 +3,23 @@
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Browse Playbooks</h1>
<a href="/ansible/" style="color:#a0a0ff;font-size:0.9rem;">← Run History</a>
<a href="/ansible/" class="btn btn-ghost btn-sm">← Run History</a>
</div>
{% if view_contents is defined %}
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
<h3 class="section-title">{{ view_path }}</h3>
<a href="/ansible/browse" style="color:#a0a0ff;font-size:0.85rem;">← Back to browse</a>
<a href="/ansible/browse" class="btn btn-ghost btn-sm">← Back to browse</a>
</div>
<pre style="background:#0a0a1a;padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:#c0c0c0;max-height:600px;overflow-y:auto;">{{ view_contents }}</pre>
<pre style="background:var(--bg);padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:var(--text-muted);max-height:600px;overflow-y:auto;">{{ view_contents }}</pre>
</div>
{% endif %}
{% if not source_data %}
{% if view_contents is not defined %}
<div class="card">
<p style="color:#606080;">No Ansible sources configured. Add sources under <code>ansible.sources</code> in <code>config.yaml</code>.</p>
<p style="color:var(--text-muted);">No Ansible sources configured. <a href="/settings/ansible/">Add sources in Settings → Ansible</a>.</p>
</div>
{% endif %}
{% else %}
@@ -27,8 +27,8 @@
<div class="card" style="margin-bottom:1.5rem;">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;">
<h3 class="section-title">{{ sd.source.name }}</h3>
<span style="color:#404060;font-size:0.8rem;background:#12122a;padding:0.2rem 0.5rem;border-radius:3px;">{{ sd.source.type }}</span>
<span style="color:#505070;font-size:0.8rem;">{{ sd.source.path }}</span>
<span style="color:var(--text-muted);font-size:0.8rem;background:var(--bg-elevated);padding:0.2rem 0.5rem;border-radius:3px;">{{ sd.source.type }}</span>
<span style="color:var(--text-dim);font-size:0.8rem;">{{ sd.source.path }}</span>
</div>
{% if not sd.playbooks %}
+14 -14
View File
@@ -2,33 +2,33 @@
{% block title %}Run {{ run.id[:8] }} — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
<a href="/ansible/" style="color:#6060c0;font-size:0.9rem;">← Runs</a>
<a href="/ansible/" class="btn btn-ghost btn-sm">← Runs</a>
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
</div>
{% 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)") %}
<div class="card">
<div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1rem;font-size:0.9rem;margin-bottom:1rem;">
<span style="color:#606080;">ID</span><span style="color:#a0a0c0;font-family:monospace;">{{ run.id }}</span>
<span style="color:#606080;">Playbook</span><span style="color:#e0e0e0;">{{ run.playbook_path }}</span>
<span style="color:#606080;">Inventory</span><span style="color:#e0e0e0;">{{ run.inventory_path }}</span>
<span style="color:#606080;">Source</span><span style="color:#e0e0e0;">{{ run.source_name }}</span>
<span style="color:#606080;">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span>
<span style="color:#606080;">Started</span><span style="color:#a0a0c0;">{{ run.started_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
<span style="color:var(--text-muted);">ID</span><span style="color:var(--text-dim);font-family:monospace;">{{ run.id }}</span>
<span style="color:var(--text-muted);">Playbook</span><span>{{ run.playbook_path }}</span>
<span style="color:var(--text-muted);">Inventory</span><span>{{ run.inventory_path }}</span>
<span style="color:var(--text-muted);">Source</span><span>{{ run.source_name }}</span>
<span style="color:var(--text-muted);">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span>
<span style="color:var(--text-muted);">Started</span><span style="color:var(--text-dim);">{{ run.started_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
{% if run.finished_at %}
<span style="color:#606080;">Finished</span><span style="color:#a0a0c0;">{{ run.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
<span style="color:var(--text-muted);">Finished</span><span style="color:var(--text-dim);">{{ run.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
{% endif %}
</div>
</div>
<div class="card" style="padding:0;">
<div style="padding:0.75rem 1rem;background:#12122a;border-bottom:1px solid #2a2a4a;">
<span style="color:#a0a0c0;font-size:0.85rem;">Output</span>
<div style="padding:0.75rem 1rem;background:var(--bg-elevated);border-bottom:1px solid var(--border);">
<span style="color:var(--text-muted);font-size:0.85rem;">Output</span>
</div>
{% if run.status.value == "running" %}
<pre id="live-output"
style="margin:0;padding:1rem;background:#080810;font-size:0.78rem;color:#a0c0a0;max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "" }}</pre>
<div id="run-done-status" style="padding:0.5rem 1rem;font-size:0.85rem;color:#606080;">
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 "" }}</pre>
<div id="run-done-status" style="padding:0.5rem 1rem;font-size:0.85rem;color:var(--text-muted);">
Streaming…
</div>
<script>
@@ -47,7 +47,7 @@
})();
</script>
{% else %}
<pre style="margin:0;padding:1rem;background:#080810;font-size:0.78rem;color:#a0c0a0;max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "(no output)" }}</pre>
<pre style="margin:0;padding:1rem;background:var(--bg);font-size:0.78rem;color:var(--text-muted);max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "(no output)" }}</pre>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,206 @@
{# settings/_ansible_sources.html — HTMX partial for Ansible source management #}
<div id="ansible-sources">
{% if sources %}
<div style="display:grid;gap:0.75rem;margin-bottom:0.75rem;">
{% for src in sources %}
{% set idx = loop.index0 %}
<div class="card" style="padding:0;overflow:visible;">
{# ── View row ──────────────────────────────────────────────────────── #}
<div id="ansible-source-view-{{ idx }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
<span style="flex-shrink:0;font-size:0.7rem;font-weight:600;text-transform:uppercase;
letter-spacing:0.06em;padding:0.15em 0.5em;border-radius:3px;
background:{% if src.type == 'git' %}var(--accent-faint){% else %}var(--bg-elevated){% endif %};
color:{% if src.type == 'git' %}var(--accent){% else %}var(--text-muted){% endif %};">
{{ src.type }}
</span>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.9rem;">{{ src.name }}</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.1rem;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{% if src.type == 'git' %}
{{ src.url or '(no URL)' }}
<span style="color:var(--text-dim);margin-left:0.5rem;">branch: {{ src.branch or 'main' }}</span>
{% if src.pull_interval_seconds %}<span style="color:var(--text-dim);margin-left:0.5rem;">· pull every {{ src.pull_interval_seconds }}s</span>{% endif %}
{% else %}
{{ src.path or '(no path)' }}
{% endif %}
</div>
</div>
<div style="display:flex;gap:0.4rem;flex-shrink:0;align-items:center;">
<a href="/ansible/browse" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Browse</a>
{% if src.type == 'git' %}
<span id="ansible-sync-status-{{ idx }}"></span>
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
hx-post="/settings/ansible/sources/{{ idx }}/sync"
hx-target="#ansible-sync-status-{{ idx }}"
hx-swap="innerHTML"
hx-indicator="#ansible-sync-status-{{ idx }}">Sync</button>
{% endif %}
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
onclick="toggleAnsibleEdit({{ idx }})">Edit</button>
<button class="btn btn-danger btn-sm" style="font-size:0.78rem;"
hx-post="/settings/ansible/sources/{{ idx }}/remove"
hx-target="#ansible-sources"
hx-swap="outerHTML"
hx-confirm="Remove source '{{ src.name }}'?">×</button>
</div>
</div>
{# ── Inline edit form ──────────────────────────────────────────────── #}
<div id="ansible-source-edit-{{ idx }}" style="display:none;
border-top:1px solid var(--border);padding:1rem;">
<form hx-post="/settings/ansible/sources/{{ idx }}/save"
hx-target="#ansible-sources"
hx-swap="outerHTML">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.6rem;">
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Name</label>
<input type="text" name="name" value="{{ src.name }}" required>
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Type</label>
<select name="type" id="ansible-edit-type-{{ idx }}"
onchange="updateAnsibleEditFields({{ idx }})">
<option value="local" {% if src.type == 'local' %}selected{% endif %}>local</option>
<option value="git" {% if src.type == 'git' %}selected{% endif %}>git</option>
</select>
</div>
<div id="ansible-edit-git-fields-{{ idx }}"
style="display:{% if src.type == 'git' %}contents{% else %}none{% endif %};
grid-column:1/-1;display:{% if src.type == 'git' %}grid{% else %}none{% endif %};
grid-template-columns:1fr 1fr;gap:0.6rem;grid-column:1/-1;">
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">URL</label>
<input type="url" name="url" value="{{ src.url or '' }}" placeholder="https://github.com/org/repo.git">
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Branch</label>
<input type="text" name="branch" value="{{ src.branch or 'main' }}" placeholder="main">
</div>
<div class="form-group" style="margin:0;grid-column:1/-1;">
<label style="font-size:0.8rem;">Pull interval (seconds)</label>
<input type="number" name="pull_interval_seconds" value="{{ src.pull_interval_seconds or 3600 }}" min="60">
</div>
</div>
<div id="ansible-edit-local-fields-{{ idx }}"
style="display:{% if src.type == 'local' %}grid{% else %}none{% endif %};
grid-template-columns:1fr;gap:0.6rem;grid-column:1/-1;">
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Path</label>
<input type="text" name="path" value="{{ src.path or '' }}" placeholder="/opt/playbooks">
</div>
</div>
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
<button type="submit" class="btn btn-sm">Save</button>
<button type="button" class="btn btn-ghost btn-sm"
onclick="toggleAnsibleEdit({{ idx }})">Cancel</button>
</div>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="color:var(--text-muted);font-size:0.9rem;text-align:center;padding:2rem;">
No sources configured. Add one below.
</div>
{% endif %}
{# ── Add source ────────────────────────────────────────────────────────── #}
<details class="card" style="padding:0;overflow:hidden;">
<summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;
cursor:pointer;list-style:none;user-select:none;">
<span style="flex:1;font-weight:600;font-size:0.875rem;color:var(--text);">Add Source</span>
<span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
</summary>
<form hx-post="/settings/ansible/sources/add"
hx-target="#ansible-sources"
hx-swap="outerHTML"
style="padding:1rem;padding-top:0;border-top:1px solid var(--border);">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.6rem;padding-top:1rem;">
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Name</label>
<input type="text" name="name" placeholder="my-playbooks" required>
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Type</label>
<select name="type" id="ansible-add-type"
onchange="updateAnsibleAddFields()">
<option value="local">local</option>
<option value="git">git</option>
</select>
</div>
<div id="ansible-add-git-fields"
style="display:none;grid-template-columns:1fr 1fr;gap:0.6rem;grid-column:1/-1;">
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">URL</label>
<input type="url" name="url" placeholder="https://github.com/org/repo.git">
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Branch</label>
<input type="text" name="branch" placeholder="main" value="main">
</div>
<div class="form-group" style="margin:0;grid-column:1/-1;">
<label style="font-size:0.8rem;">Pull interval (seconds)</label>
<input type="number" name="pull_interval_seconds" value="3600" min="60">
</div>
</div>
<div id="ansible-add-local-fields"
style="display:grid;grid-template-columns:1fr;gap:0.6rem;grid-column:1/-1;">
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Path</label>
<input type="text" name="path" placeholder="/opt/playbooks">
</div>
</div>
</div>
<button type="submit" class="btn btn-sm" style="margin-top:0.75rem;width:100%;">
Add Source
</button>
</form>
</details>
</div>
<script>
function toggleAnsibleEdit(idx) {
var view = document.getElementById('ansible-source-view-' + idx);
var edit = document.getElementById('ansible-source-edit-' + idx);
var showingEdit = edit.style.display !== 'none';
edit.style.display = showingEdit ? 'none' : 'block';
}
function updateAnsibleEditFields(idx) {
var t = document.getElementById('ansible-edit-type-' + idx).value;
document.getElementById('ansible-edit-git-fields-' + idx).style.display = t === 'git' ? 'grid' : 'none';
document.getElementById('ansible-edit-local-fields-' + idx).style.display = t === 'local' ? 'grid' : 'none';
}
function updateAnsibleAddFields() {
var t = document.getElementById('ansible-add-type').value;
document.getElementById('ansible-add-git-fields').style.display = t === 'git' ? 'grid' : 'none';
document.getElementById('ansible-add-local-fields').style.display = t === 'local' ? 'grid' : 'none';
}
</script>
+9 -12
View File
@@ -5,18 +5,15 @@
{% set active_tab = "ansible" %}
{% include "settings/_tabs.html" %}
<form method="post" action="/settings/ansible/">
<div class="card" style="max-width:720px;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Ansible Sources</h2>
<p style="color:var(--text-muted);font-size:0.85rem;margin-bottom:1rem;">
JSON array of source objects. Each requires <code>name</code>, <code>type</code>
(<code>git</code> or <code>local</code>), and either <code>url</code> or <code>path</code>.
<div style="max-width:720px;">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.75rem;">
<div class="section-title">Ansible Sources</div>
<a href="/ansible/" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Run History →</a>
</div>
<p style="color:var(--text-muted);font-size:0.84rem;margin-bottom:1rem;">
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.
</p>
<textarea name="ansible.sources" rows="12"
style="font-family:ui-monospace,monospace;font-size:0.85rem;width:100%;">{{ settings['ansible.sources'] | tojson(indent=2) }}</textarea>
{% include "settings/_ansible_sources.html" %}
</div>
<div style="margin-top:1rem;">
<button type="submit" class="btn">Save</button>
</div>
</form>
{% endblock %}