refactor: rename package directory fabledscryer → roundtable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 mustered. 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>
|
||||
@@ -0,0 +1,111 @@
|
||||
{# settings/_catalog_partial.html — HTMX partial for plugin catalog #}
|
||||
<div id="plugin-catalog">
|
||||
|
||||
{% if error %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0.75rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;margin-bottom:0.75rem;">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if catalog %}
|
||||
<div style="display:grid;gap:0.75rem;">
|
||||
{% for plugin in catalog %}
|
||||
{% set is_installed = plugin.name in installed_names %}
|
||||
{% set is_loaded = plugin.name in loaded_names %}
|
||||
{% set installed_ver = installed_versions.get(plugin.name, "") %}
|
||||
{# update_available: catalog version is strictly newer than what's on disk #}
|
||||
{% set update_available = is_installed and installed_ver and installed_ver != plugin.version %}
|
||||
|
||||
<div class="card" style="margin-bottom:0;padding:1rem 1.25rem;{% if update_available %}border-color:var(--gold);{% endif %}">
|
||||
<div style="display:flex;align-items:flex-start;gap:1rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;">
|
||||
<span style="font-weight:600;color:var(--text);font-size:0.9rem;">{{ plugin.name }}</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.version }}</span>
|
||||
|
||||
{% if update_available %}
|
||||
<span class="badge badge-gold" style="font-size:0.68rem;">
|
||||
Update available — installed v{{ installed_ver }}
|
||||
</span>
|
||||
{% elif is_loaded %}
|
||||
<span class="badge badge-green" style="font-size:0.68rem;">Active</span>
|
||||
{% elif is_installed %}
|
||||
<span class="badge" style="background:var(--bg-elevated);color:var(--text-muted);font-size:0.68rem;">Installed</span>
|
||||
{% endif %}
|
||||
|
||||
{% for tag in plugin.tags %}
|
||||
<span style="font-size:0.68rem;padding:0.1em 0.4em;background:var(--bg-elevated);color:var(--text-muted);border-radius:3px;">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if plugin.description %}
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);margin-top:0.25rem;">{{ plugin.description }}</div>
|
||||
{% endif %}
|
||||
<div style="font-size:0.75rem;color:var(--text-dim);margin-top:0.15rem;display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
|
||||
{% if plugin.author %}<span>by {{ plugin.author }}</span>{% endif %}
|
||||
{% if plugin.license %}<span style="color:var(--text-muted);">{{ plugin.license }}</span>{% endif %}
|
||||
{% if plugin.min_app_version %}<span style="color:var(--text-muted);">requires v{{ plugin.min_app_version }}+</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;flex-shrink:0;align-items:center;">
|
||||
{% if plugin.homepage %}
|
||||
<a href="{{ plugin.homepage }}" target="_blank" rel="noopener"
|
||||
class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Docs</a>
|
||||
{% elif plugin.repository_url %}
|
||||
<a href="{{ plugin.repository_url }}" target="_blank" rel="noopener"
|
||||
class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Source</a>
|
||||
{% endif %}
|
||||
|
||||
{% if update_available %}
|
||||
{# Installed but outdated — offer update (download + restart) #}
|
||||
<form method="post"
|
||||
action="/settings/plugins/install/{{ plugin.name }}"
|
||||
hx-post="/settings/plugins/install/{{ plugin.name }}"
|
||||
hx-target="#catalog-result-{{ plugin.name }}"
|
||||
hx-swap="outerHTML"
|
||||
style="margin:0;">
|
||||
<input type="hidden" name="download_url" value="{{ plugin.download_url }}">
|
||||
<input type="hidden" name="checksum_sha256" value="{{ plugin.checksum_sha256 }}">
|
||||
<button type="submit" class="btn btn-warn btn-sm" style="font-size:0.78rem;">Update</button>
|
||||
</form>
|
||||
|
||||
{% elif is_loaded %}
|
||||
{# Active and up to date — no action needed #}
|
||||
|
||||
{% elif is_installed %}
|
||||
{# Installed but not active — offer hot-reload #}
|
||||
<form method="post"
|
||||
action="/settings/plugins/reload/{{ plugin.name }}"
|
||||
hx-post="/settings/plugins/reload/{{ plugin.name }}"
|
||||
hx-target="#catalog-result-{{ plugin.name }}"
|
||||
hx-swap="outerHTML"
|
||||
style="margin:0;">
|
||||
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Activate</button>
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
{# Not installed — offer install #}
|
||||
<form method="post"
|
||||
action="/settings/plugins/install/{{ plugin.name }}"
|
||||
hx-post="/settings/plugins/install/{{ plugin.name }}"
|
||||
hx-target="#catalog-result-{{ plugin.name }}"
|
||||
hx-swap="outerHTML"
|
||||
style="margin:0;">
|
||||
<input type="hidden" name="download_url" value="{{ plugin.download_url }}">
|
||||
<input type="hidden" name="checksum_sha256" value="{{ plugin.checksum_sha256 }}">
|
||||
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Install</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Inline result placeholder — replaced by _install_result.html after action #}
|
||||
<div id="catalog-result-{{ plugin.name }}"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif not error %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;">No plugins listed in the catalog.</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
{# settings/_install_result.html — inline result after install/reload action #}
|
||||
<div id="catalog-result-{{ name }}"
|
||||
style="margin-top:0.6rem;padding:0.5rem 0.75rem;border-radius:5px;font-size:0.82rem;
|
||||
background:{% if success %}var(--green-dim){% else %}var(--red-dim){% endif %};
|
||||
color:{% if success %}var(--green){% else %}#ff8090{% endif %};">
|
||||
{{ message }}
|
||||
{% if restart_required %}
|
||||
·
|
||||
<form method="post" action="/settings/plugins/restart/"
|
||||
hx-post="/settings/plugins/restart/"
|
||||
hx-target="#restart-banner"
|
||||
hx-swap="outerHTML"
|
||||
style="display:inline;margin:0;">
|
||||
<button type="submit"
|
||||
style="background:none;border:none;padding:0;color:var(--yellow);cursor:pointer;font-size:0.82rem;text-decoration:underline;">
|
||||
Restart now
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
{# settings/_plugin_repos.html — HTMX partial for plugin repository management #}
|
||||
<div id="plugin-repos">
|
||||
|
||||
{% if repos %}
|
||||
<div style="display:grid;gap:0.6rem;margin-bottom:0.6rem;">
|
||||
{% for repo in repos %}
|
||||
{% set idx = loop.index0 %}
|
||||
|
||||
<div class="card" style="padding:0;overflow:visible;">
|
||||
|
||||
{# ── View row ────────────────────────────────────────────────────────── #}
|
||||
<div id="plugin-repo-view-{{ idx }}"
|
||||
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:0.88rem;">{{ repo.name or repo.url }}</div>
|
||||
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ repo.url }}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.4rem;flex-shrink:0;">
|
||||
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
|
||||
onclick="toggleRepoEdit({{ idx }})">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" style="font-size:0.78rem;"
|
||||
hx-post="/settings/plugins/repos/{{ idx }}/remove"
|
||||
hx-target="#plugin-repos"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Remove repository '{{ repo.name or repo.url }}'?">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Inline edit form ──────────────────────────────────────────────── #}
|
||||
<div id="plugin-repo-edit-{{ idx }}"
|
||||
style="display:none;border-top:1px solid var(--border);padding:1rem;">
|
||||
<form hx-post="/settings/plugins/repos/{{ idx }}/save"
|
||||
hx-target="#plugin-repos"
|
||||
hx-swap="outerHTML">
|
||||
<div style="display:grid;grid-template-columns:1fr 2fr;gap:0.6rem;">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">Name</label>
|
||||
<input type="text" name="name" value="{{ repo.name }}" placeholder="My Plugins">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">Index URL</label>
|
||||
<input type="url" name="url" value="{{ repo.url }}"
|
||||
placeholder="https://example.com/plugins/index.yaml" required>
|
||||
</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="toggleRepoEdit({{ idx }})">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="color:var(--text-muted);font-size:0.88rem;text-align:center;padding:1.5rem;margin-bottom:0.6rem;">
|
||||
No repositories charted. Add one below.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Add repository ────────────────────────────────────────────────────────── #}
|
||||
<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 Repository</span>
|
||||
<span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
|
||||
</summary>
|
||||
<form hx-post="/settings/plugins/repos/add"
|
||||
hx-target="#plugin-repos"
|
||||
hx-swap="outerHTML"
|
||||
style="padding:1rem;padding-top:0;border-top:1px solid var(--border);">
|
||||
<div style="display:grid;grid-template-columns:1fr 2fr;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 Plugins">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">Index URL</label>
|
||||
<input type="url" name="url"
|
||||
placeholder="https://example.com/plugins/index.yaml" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm" style="margin-top:0.75rem;width:100%;">
|
||||
Add Repository
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleRepoEdit(idx) {
|
||||
var edit = document.getElementById('plugin-repo-edit-' + idx);
|
||||
edit.style.display = edit.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,6 @@
|
||||
{# settings/_restart_pending.html — shown after restart is triggered #}
|
||||
<div id="restart-banner"
|
||||
style="padding:0.75rem 1rem;border-radius:6px;background:var(--gold-dim);
|
||||
border:1px solid var(--gold);color:var(--gold);font-size:0.85rem;">
|
||||
Restart initiated — the app will reload in a moment. Refresh this page in a few seconds.
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
{# settings/_tabs.html — include at top of each settings section #}
|
||||
<h1 class="page-title">Settings</h1>
|
||||
<div style="display:flex;gap:0;border-bottom:1px solid var(--border-mid);margin-bottom:1.5rem;">
|
||||
{% set tabs = [
|
||||
("general", "General", "/settings/general/"),
|
||||
("notifications", "Notifications", "/settings/notifications/"),
|
||||
("reports", "Reports", "/settings/reports/"),
|
||||
("auth", "Auth", "/settings/auth/"),
|
||||
("ansible", "Ansible", "/settings/ansible/"),
|
||||
("plugins", "Plugins", "/settings/plugins/"),
|
||||
] %}
|
||||
{% for key, label, href in tabs %}
|
||||
<a href="{{ href }}"
|
||||
style="padding:0.6rem 1.1rem;font-size:0.875rem;border-bottom:2px solid {{ 'var(--accent)' if active_tab == key else 'transparent' }};color:{{ 'var(--text)' if active_tab == key else 'var(--text-muted)' }};font-weight:{{ '500' if active_tab == key else 'normal' }};text-decoration:none;">
|
||||
{{ label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
{# fabledscryer/templates/settings/ansible.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — Ansible — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
{% set active_tab = "ansible" %}
|
||||
{% include "settings/_tabs.html" %}
|
||||
|
||||
<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>
|
||||
{% include "settings/_ansible_sources.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,178 @@
|
||||
{# fabledscryer/templates/settings/auth.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — Auth — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
{% set active_tab = "auth" %}
|
||||
{% include "settings/_tabs.html" %}
|
||||
|
||||
<form method="post" action="/settings/auth/">
|
||||
<div style="display:grid;gap:1.25rem;max-width:640px;">
|
||||
|
||||
{# ── OIDC ─────────────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1.25rem;">OIDC / SSO</h2>
|
||||
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1rem;">
|
||||
Supports any OIDC provider — Authentik, Keycloak, Authelia, etc. Uses Authorization Code flow.
|
||||
Local admin login always works regardless of OIDC status.
|
||||
</p>
|
||||
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<input type="checkbox" name="oidc.enabled" id="oidc-enabled"
|
||||
{% if settings['oidc.enabled'] %}checked{% endif %}>
|
||||
<label for="oidc-enabled" style="margin:0;">Enable OIDC login</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Discovery URL</label>
|
||||
<input type="text" name="oidc.discovery_url" value="{{ settings['oidc.discovery_url'] }}"
|
||||
placeholder="https://auth.example.com/application/o/fabledscryer/.well-known/openid-configuration">
|
||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.2rem;">
|
||||
Authentik: <code>https://<host>/application/o/<slug>/.well-known/openid-configuration</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Client ID</label>
|
||||
<input type="text" name="oidc.client_id" value="{{ settings['oidc.client_id'] }}"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Client Secret <span style="color:var(--text-dim);font-size:0.8rem;">(blank = keep current)</span></label>
|
||||
<input type="password" name="oidc.client_secret" placeholder="••••••••"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Scopes</label>
|
||||
<input type="text" name="oidc.scopes" value="{{ settings['oidc.scopes'] }}"
|
||||
placeholder="openid profile email">
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Username claim</label>
|
||||
<input type="text" name="oidc.username_claim" value="{{ settings['oidc.username_claim'] }}"
|
||||
placeholder="preferred_username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email claim</label>
|
||||
<input type="text" name="oidc.email_claim" value="{{ settings['oidc.email_claim'] }}"
|
||||
placeholder="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Groups claim</label>
|
||||
<input type="text" name="oidc.groups_claim" value="{{ settings['oidc.groups_claim'] }}"
|
||||
placeholder="groups">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Admin group name</label>
|
||||
<input type="text" name="oidc.admin_group" value="{{ settings['oidc.admin_group'] }}"
|
||||
placeholder="fabledscryer-admins">
|
||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.2rem;">Members → admin role</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Operator group name</label>
|
||||
<input type="text" name="oidc.operator_group" value="{{ settings['oidc.operator_group'] }}"
|
||||
placeholder="fabledscryer-operators">
|
||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.2rem;">Members → operator role; others → viewer</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── LDAP ─────────────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1.25rem;">LDAP</h2>
|
||||
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1rem;">
|
||||
Binds user credentials against an LDAP/AD directory. Requires the
|
||||
<code>ldap3</code> Python package (<code>pip install ldap3</code>).
|
||||
If both LDAP and OIDC are enabled, the login form tries LDAP; OIDC uses the SSO button.
|
||||
</p>
|
||||
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<input type="checkbox" name="ldap.enabled" id="ldap-enabled"
|
||||
{% if settings['ldap.enabled'] %}checked{% endif %}>
|
||||
<label for="ldap-enabled" style="margin:0;">Enable LDAP login</label>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr auto auto;gap:1rem;align-items:end;">
|
||||
<div class="form-group">
|
||||
<label>LDAP host</label>
|
||||
<input type="text" name="ldap.host" value="{{ settings['ldap.host'] }}"
|
||||
placeholder="ldap.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Port</label>
|
||||
<input type="number" name="ldap.port" value="{{ settings['ldap.port'] }}" style="width:80px;">
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;padding-bottom:0.1rem;">
|
||||
<input type="checkbox" name="ldap.tls" id="ldap-tls"
|
||||
{% if settings['ldap.tls'] %}checked{% endif %}>
|
||||
<label for="ldap-tls" style="margin:0;white-space:nowrap;">StartTLS</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Service account bind DN</label>
|
||||
<input type="text" name="ldap.bind_dn" value="{{ settings['ldap.bind_dn'] }}"
|
||||
placeholder="cn=svc-fabledscryer,ou=service,dc=example,dc=com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Bind password <span style="color:var(--text-dim);font-size:0.8rem;">(blank = keep current)</span></label>
|
||||
<input type="password" name="ldap.bind_password" placeholder="••••••••"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Base DN</label>
|
||||
<input type="text" name="ldap.base_dn" value="{{ settings['ldap.base_dn'] }}"
|
||||
placeholder="ou=users,dc=example,dc=com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>User search filter</label>
|
||||
<input type="text" name="ldap.user_filter" value="{{ settings['ldap.user_filter'] }}"
|
||||
placeholder="(uid={username})">
|
||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.2rem;">
|
||||
<code>{username}</code> is replaced with the login username. AD example: <code>(sAMAccountName={username})</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Admin group DN</label>
|
||||
<input type="text" name="ldap.admin_group_dn" value="{{ settings['ldap.admin_group_dn'] }}"
|
||||
placeholder="cn=scryer-admins,ou=groups,dc=example,dc=com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Operator group DN</label>
|
||||
<input type="text" name="ldap.operator_group_dn" value="{{ settings['ldap.operator_group_dn'] }}"
|
||||
placeholder="cn=scryer-operators,ou=groups,dc=example,dc=com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Username attribute</label>
|
||||
<input type="text" name="ldap.attr_username" value="{{ settings['ldap.attr_username'] }}"
|
||||
placeholder="uid">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email attribute</label>
|
||||
<input type="text" name="ldap.attr_email" value="{{ settings['ldap.attr_email'] }}"
|
||||
placeholder="mail">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div style="margin-top:1.25rem;">
|
||||
<button type="submit" class="btn">Save</button>
|
||||
<span style="color:var(--text-muted);font-size:0.82rem;margin-left:1rem;">
|
||||
Changes take effect immediately.
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{# fabledscryer/templates/settings/general.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — General — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
{% set active_tab = "general" %}
|
||||
{% include "settings/_tabs.html" %}
|
||||
|
||||
<form method="post" action="/settings/general/">
|
||||
<div class="card" style="max-width:640px;">
|
||||
<h2 class="section-title" style="margin-bottom:1.25rem;">General</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Session lifetime <span style="color:var(--text-muted);font-size:0.8rem;">(hours)</span></label>
|
||||
<input type="number" name="session.lifetime_hours" min="1" max="720"
|
||||
value="{{ settings['session.lifetime_hours'] }}" style="max-width:120px;">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Data retention <span style="color:var(--text-muted);font-size:0.8rem;">(days)</span></label>
|
||||
<input type="number" name="data.retention_days" min="1"
|
||||
value="{{ settings['data.retention_days'] }}" style="max-width:120px;">
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">Old ping/DNS results older than this are pruned by the cleanup job.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Monitor poll interval <span style="color:var(--text-muted);font-size:0.8rem;">(seconds)</span></label>
|
||||
<input type="number" name="monitors.poll_interval_seconds" min="10"
|
||||
value="{{ settings['monitors.poll_interval_seconds'] }}" style="max-width:120px;">
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">Requires restart to take effect.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem;display:flex;align-items:center;gap:1rem;">
|
||||
<button type="submit" class="btn">Save</button>
|
||||
<span style="font-size:0.82rem;color:var(--text-muted);">Changes to session lifetime take effect immediately.</span>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,157 @@
|
||||
{# fabledscryer/templates/settings/index.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="page-title">Settings</h1>
|
||||
<form method="post" action="/settings/">
|
||||
<div style="display:grid;gap:1.5rem;">
|
||||
|
||||
{# ── General ──────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">General</h2>
|
||||
<div class="form-group">
|
||||
<label>Session lifetime (hours)</label>
|
||||
<input type="number" name="session.lifetime_hours" min="1" max="720"
|
||||
value="{{ settings['session.lifetime_hours'] }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Data retention (days)</label>
|
||||
<input type="number" name="data.retention_days" min="1"
|
||||
value="{{ settings['data.retention_days'] }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Monitor poll interval (seconds)</label>
|
||||
<input type="number" name="monitors.poll_interval_seconds" min="10"
|
||||
value="{{ settings['monitors.poll_interval_seconds'] }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── SMTP ─────────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Email (SMTP)</h2>
|
||||
<div class="form-group">
|
||||
<label>SMTP host</label>
|
||||
<input type="text" name="smtp.host" value="{{ settings['smtp.host'] }}" placeholder="mail.example.com">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Port</label>
|
||||
<input type="number" name="smtp.port" value="{{ settings['smtp.port'] }}">
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;padding-top:1.5rem;">
|
||||
<input type="checkbox" name="smtp.tls" id="smtp-tls"
|
||||
{% if settings['smtp.tls'] %}checked{% endif %}>
|
||||
<label for="smtp-tls" style="margin:0;">Use TLS</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" name="smtp.username" value="{{ settings['smtp.username'] }}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password <span style="color:#606080;font-size:0.8rem;">(leave blank to keep current)</span></label>
|
||||
<input type="password" name="smtp.password" placeholder="••••••••" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Recipients <span style="color:#606080;font-size:0.8rem;">(comma-separated)</span></label>
|
||||
<input type="text" name="smtp.recipients"
|
||||
value="{{ settings['smtp.recipients'] | join(', ') }}"
|
||||
placeholder="admin@example.com, ops@example.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Webhook ──────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Webhook</h2>
|
||||
<div class="form-group">
|
||||
<label>Webhook URL <span style="color:#606080;font-size:0.8rem;">(leave blank to disable)</span></label>
|
||||
<input type="text" name="webhook.url" value="{{ settings['webhook.url'] }}"
|
||||
placeholder="https://discord.com/api/webhooks/...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Payload template <span style="color:#606080;font-size:0.8rem;">(Jinja2, must produce valid JSON)</span></label>
|
||||
<textarea name="webhook.template" rows="3" style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ settings['webhook.template'] }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Ansible Sources ──────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Ansible Sources</h2>
|
||||
<p style="color:#606080;font-size:0.85rem;margin-bottom:0.75rem;">JSON array of source objects. Each requires <code>name</code>, <code>type</code> (git or local), and either <code>url</code> or <code>path</code>.</p>
|
||||
<textarea name="ansible.sources" rows="8" style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ settings['ansible.sources'] | tojson(indent=2) }}</textarea>
|
||||
</div>
|
||||
|
||||
{# ── Plugins ──────────────────────────────────────────────────────────── #}
|
||||
{% if discovered_plugins %}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Plugins</h2>
|
||||
{% for plugin in discovered_plugins %}
|
||||
<div style="border:1px solid #2a2a4a;border-radius:4px;padding:1rem;margin-bottom:0.75rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.75rem;">
|
||||
<input type="checkbox" name="plugin.{{ plugin._dir }}.enabled"
|
||||
id="plugin-{{ plugin._dir }}-enabled"
|
||||
{% if plugin._enabled %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-enabled"
|
||||
style="margin:0;font-weight:bold;color:#c0c0e0;">
|
||||
{{ plugin.get('name', plugin._dir) }}
|
||||
<span style="color:#606080;font-size:0.8rem;font-weight:normal;">
|
||||
v{{ plugin.get('version', '?') }} — {{ plugin.get('description', '') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{% for cfg_key, cfg_default in plugin.get('config', {}).items() %}
|
||||
{% if cfg_default is mapping %}
|
||||
{# Nested dict — render each sub-key as its own field #}
|
||||
<div style="margin-left:1.5rem;margin-bottom:0.6rem;">
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:0.35rem;text-transform:uppercase;letter-spacing:0.04em;">{{ cfg_key }}</div>
|
||||
{% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %}
|
||||
{% for sub_key, sub_default in cfg_default.items() %}
|
||||
<div class="form-group" style="margin-left:1rem;">
|
||||
{% if sub_default is sameas false or sub_default is sameas true %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
|
||||
id="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
|
||||
{% if sub_cfg.get(sub_key, sub_default) %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
|
||||
style="margin:0;font-size:0.85rem;">{{ sub_key }}</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<label style="font-size:0.85rem;">{{ sub_key }}</label>
|
||||
<input type="text"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
|
||||
value="{{ sub_cfg.get(sub_key, sub_default) }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif cfg_default is sameas false or cfg_default is sameas true %}
|
||||
<div class="form-group" style="margin-left:1.5rem;display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
|
||||
id="plugin-{{ plugin._dir }}-{{ cfg_key }}"
|
||||
{% if plugin._config.get(cfg_key, cfg_default) %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}" style="margin:0;font-size:0.85rem;">{{ cfg_key }}</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group" style="margin-left:1.5rem;">
|
||||
<label style="font-size:0.85rem;">{{ cfg_key }}</label>
|
||||
<input type="text" name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
|
||||
value="{{ plugin._config.get(cfg_key, cfg_default) }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<div style="margin-top:1.5rem;">
|
||||
<button type="submit" class="btn">Save Settings</button>
|
||||
<span style="color:#606080;font-size:0.85rem;margin-left:1rem;">
|
||||
Changes take effect immediately. Monitor interval, plugin enable/disable require a restart.
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{# fabledscryer/templates/settings/notifications.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — Notifications — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
{% set active_tab = "notifications" %}
|
||||
{% include "settings/_tabs.html" %}
|
||||
|
||||
<form method="post" action="/settings/notifications/">
|
||||
<div style="display:grid;gap:1.25rem;max-width:640px;">
|
||||
|
||||
{# ── SMTP ──────────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1.25rem;">Email (SMTP)</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>SMTP host</label>
|
||||
<input type="text" name="smtp.host" value="{{ settings['smtp.host'] }}"
|
||||
placeholder="mail.example.com">
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Port</label>
|
||||
<input type="number" name="smtp.port" value="{{ settings['smtp.port'] }}">
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;padding-top:1.5rem;">
|
||||
<input type="checkbox" name="smtp.tls" id="smtp-tls"
|
||||
{% if settings['smtp.tls'] %}checked{% endif %}>
|
||||
<label for="smtp-tls" style="margin:0;">Use TLS</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" name="smtp.username" value="{{ settings['smtp.username'] }}"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Password <span style="color:var(--text-muted);font-size:0.8rem;">(leave blank to keep current)</span></label>
|
||||
<input type="password" name="smtp.password" placeholder="••••••••" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Recipients <span style="color:var(--text-muted);font-size:0.8rem;">(comma-separated)</span></label>
|
||||
<input type="text" name="smtp.recipients"
|
||||
value="{{ settings['smtp.recipients'] | join(', ') }}"
|
||||
placeholder="admin@example.com, ops@example.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Webhook ───────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1.25rem;">Webhook</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>URL <span style="color:var(--text-muted);font-size:0.8rem;">(leave blank to disable)</span></label>
|
||||
<input type="text" name="webhook.url" value="{{ settings['webhook.url'] }}"
|
||||
placeholder="https://discord.com/api/webhooks/...">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Payload template <span style="color:var(--text-muted);font-size:0.8rem;">(Jinja2, must produce valid JSON)</span></label>
|
||||
<textarea name="webhook.template" rows="4"
|
||||
style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ settings['webhook.template'] }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div style="margin-top:1rem;">
|
||||
<button type="submit" class="btn">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,134 @@
|
||||
{# fabledscryer/templates/settings/plugin_detail.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ plugin.get('name', plugin._dir) }} Settings — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
{% set active_tab = "plugins" %}
|
||||
{% include "settings/_tabs.html" %}
|
||||
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
|
||||
<a href="/settings/plugins/" class="btn btn-ghost btn-sm">← Plugins</a>
|
||||
<h1 class="page-title" style="margin-bottom:0;">{{ plugin.get('name', plugin._dir) }}</h1>
|
||||
<span style="color:var(--text-muted);font-size:0.82rem;">v{{ plugin.get('version', '?') }}</span>
|
||||
{% if fail_reason %}
|
||||
<span style="font-size:0.75rem;padding:0.15em 0.55em;border-radius:3px;
|
||||
background:color-mix(in srgb,var(--red) 15%,var(--bg));color:var(--red);">Error</span>
|
||||
{% elif is_loaded %}
|
||||
<span style="font-size:0.75rem;padding:0.15em 0.55em;border-radius:3px;
|
||||
background:color-mix(in srgb,var(--green) 12%,var(--bg));color:var(--green);">Loaded</span>
|
||||
{% elif plugin._enabled %}
|
||||
<span style="font-size:0.75rem;padding:0.15em 0.55em;border-radius:3px;
|
||||
background:var(--bg-elevated);color:var(--text-muted);">Enabled (restart required)</span>
|
||||
{% else %}
|
||||
<span style="font-size:0.75rem;padding:0.15em 0.55em;border-radius:3px;
|
||||
background:var(--bg-elevated);color:var(--text-muted);">Disabled</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if fail_reason %}
|
||||
<div style="display:flex;align-items:flex-start;gap:0.5rem;padding:0.6rem 0.9rem;margin-bottom:1rem;
|
||||
background:color-mix(in srgb,var(--red) 10%,var(--bg));
|
||||
border:1px solid color-mix(in srgb,var(--red) 30%,var(--border));
|
||||
border-radius:6px;font-size:0.84rem;max-width:720px;">
|
||||
<span style="color:var(--red);flex-shrink:0;">✕</span>
|
||||
<span><strong style="color:var(--red);">Failed to load</strong>
|
||||
<span style="color:var(--text-muted);margin-left:0.3rem;">— {{ fail_reason }}</span>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if plugin.get('description') %}
|
||||
<p style="color:var(--text-muted);font-size:0.88rem;margin-bottom:1.25rem;max-width:620px;">{{ plugin.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings/plugins/{{ plugin._dir }}/" style="max-width:720px;">
|
||||
|
||||
{# ── Enable toggle ────────────────────────────────────────────────────────── #}
|
||||
<div class="card" style="padding:1rem 1.25rem;margin-bottom:1rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.enabled"
|
||||
id="plugin-{{ plugin._dir }}-enabled"
|
||||
{% if plugin._enabled %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-enabled" style="margin:0;font-weight:600;cursor:pointer;">
|
||||
Enable this plugin
|
||||
</label>
|
||||
</div>
|
||||
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.4rem;margin-left:1.6rem;">
|
||||
Enable/disable changes take effect after a restart.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Config fields ─────────────────────────────────────────────────────────── #}
|
||||
{% if plugin.get('config') %}
|
||||
<div class="card" style="padding:1.25rem;margin-bottom:1rem;">
|
||||
<div class="section-title" style="margin-bottom:1rem;">Configuration</div>
|
||||
<div style="display:grid;gap:0.75rem;">
|
||||
|
||||
{% for cfg_key, cfg_default in plugin.config.items() %}
|
||||
|
||||
{% if cfg_default is iterable and cfg_default is not string and cfg_default is not mapping %}
|
||||
<div style="display:flex;align-items:flex-start;gap:0.5rem;padding:0.5rem 0.75rem;
|
||||
background:var(--bg);border-radius:5px;border:1px solid var(--border);
|
||||
font-size:0.82rem;color:var(--text-muted);">
|
||||
<span style="flex-shrink:0;">📋</span>
|
||||
<span><strong style="color:var(--text);">{{ cfg_key }}</strong> — list config, edit in
|
||||
<code>plugin.yaml</code> ({{ cfg_default | length }} item{{ 's' if cfg_default | length != 1 }}).</span>
|
||||
</div>
|
||||
|
||||
{% elif cfg_default is mapping %}
|
||||
<div style="padding:0.75rem;background:var(--bg);border-radius:5px;border:1px solid var(--border);">
|
||||
<div style="font-size:0.74rem;color:var(--text-muted);text-transform:uppercase;
|
||||
letter-spacing:0.05em;margin-bottom:0.6rem;">{{ cfg_key }}</div>
|
||||
{% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for sub_key, sub_default in cfg_default.items() %}
|
||||
{% if sub_default is sameas false or sub_default is sameas true %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
|
||||
id="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
|
||||
{% if sub_cfg.get(sub_key, sub_default) %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
|
||||
style="margin:0;font-size:0.85rem;color:var(--text);">{{ sub_key }}</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.82rem;">{{ sub_key }}</label>
|
||||
<input type="text"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
|
||||
value="{{ sub_cfg.get(sub_key, sub_default) }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif cfg_default is sameas false or cfg_default is sameas true %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
|
||||
id="plugin-{{ plugin._dir }}-{{ cfg_key }}"
|
||||
{% if plugin._config.get(cfg_key, cfg_default) %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}"
|
||||
style="margin:0;font-size:0.85rem;color:var(--text);">{{ cfg_key }}</label>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.82rem;">{{ cfg_key }}</label>
|
||||
<input type="{% if 'password' in cfg_key or 'secret' in cfg_key %}password{% else %}text{% endif %}"
|
||||
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
|
||||
value="{% if 'password' in cfg_key or 'secret' in cfg_key %}{% else %}{{ plugin._config.get(cfg_key, cfg_default) }}{% endif %}"
|
||||
{% if 'password' in cfg_key or 'secret' in cfg_key %}placeholder="••••••••"{% endif %}>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn">Save</button>
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,116 @@
|
||||
{# fabledscryer/templates/settings/plugins.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — Plugins — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
{% set active_tab = "plugins" %}
|
||||
{% include "settings/_tabs.html" %}
|
||||
|
||||
{# ── Restart banner ────────────────────────────────────────────────────────── #}
|
||||
<div id="restart-banner"></div>
|
||||
|
||||
{# ── Installed Plugins ─────────────────────────────────────────────────────── #}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
||||
<div class="section-title">Installed Plugins</div>
|
||||
<form method="post" action="/settings/plugins/restart/"
|
||||
hx-post="/settings/plugins/restart/"
|
||||
hx-target="#restart-banner"
|
||||
hx-swap="outerHTML"
|
||||
onsubmit="return confirm('Restart the app now? This will briefly interrupt all monitoring.')"
|
||||
style="margin:0;">
|
||||
<button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Restart App</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if not discovered_plugins %}
|
||||
<div class="card" style="color:var(--text-muted);font-size:0.9rem;margin-bottom:1.5rem;">
|
||||
No plugins installed. The table awaits new seats.
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="display:grid;gap:0.6rem;max-width:720px;margin-bottom:1.5rem;">
|
||||
{% for plugin in discovered_plugins %}
|
||||
{% set fail_reason = plugin_failures.get(plugin._dir) %}
|
||||
<div class="card" style="padding:0.85rem 1rem;display:flex;align-items:center;gap:0.75rem;">
|
||||
|
||||
{# Status indicator #}
|
||||
{% if fail_reason %}
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:var(--red);flex-shrink:0;" title="{{ fail_reason }}"></span>
|
||||
{% elif plugin._enabled %}
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:var(--green);flex-shrink:0;"></span>
|
||||
{% else %}
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:var(--border-mid);flex-shrink:0;"></span>
|
||||
{% endif %}
|
||||
|
||||
{# Name + version + description #}
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;">
|
||||
<span style="font-weight:600;font-size:0.9rem;color:var(--text);">{{ plugin.get('name', plugin._dir) }}</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.get('version', '?') }}</span>
|
||||
{% if fail_reason %}
|
||||
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
|
||||
background:color-mix(in srgb,var(--red) 15%,var(--bg));
|
||||
color:var(--red);">Error</span>
|
||||
{% elif plugin._enabled %}
|
||||
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
|
||||
background:color-mix(in srgb,var(--green) 12%,var(--bg));
|
||||
color:var(--green);">Enabled</span>
|
||||
{% else %}
|
||||
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
|
||||
background:var(--bg-elevated);color:var(--text-muted);">Disabled</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if plugin.get('description') %}
|
||||
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.1rem;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ plugin.description }}</div>
|
||||
{% endif %}
|
||||
{% if fail_reason %}
|
||||
<div style="font-size:0.77rem;color:var(--red);margin-top:0.1rem;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"
|
||||
title="{{ fail_reason }}">{{ fail_reason }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Settings button #}
|
||||
<a href="/settings/plugins/{{ plugin._dir }}/"
|
||||
class="btn btn-ghost btn-sm" style="flex-shrink:0;font-size:0.78rem;">Settings →</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Plugin Repositories ───────────────────────────────────────────────────── #}
|
||||
<div style="margin-bottom:2rem;max-width:720px;">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Plugin Repositories</div>
|
||||
<p style="color:var(--text-muted);font-size:0.84rem;margin-bottom:0.75rem;">
|
||||
Repositories are remote <code>index.yaml</code> files that Scryer checks for available
|
||||
and updated plugins. Add repos from other developers to expand the plugin catalog.
|
||||
</p>
|
||||
{% include "settings/_plugin_repos.html" %}
|
||||
</div>
|
||||
|
||||
{# ── Plugin Catalog ────────────────────────────────────────────────────────── #}
|
||||
<div style="max-width:720px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
||||
<div class="section-title">Plugin Catalog</div>
|
||||
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
|
||||
hx-get="/settings/plugins/catalog/?refresh=1"
|
||||
hx-target="#plugin-catalog"
|
||||
hx-swap="outerHTML">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if repos %}
|
||||
<div id="plugin-catalog"
|
||||
hx-get="/settings/plugins/catalog/"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML">
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">Loading catalog…</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="plugin-catalog" style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
Add a plugin repository above to browse available plugins.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,91 @@
|
||||
{# fabledscryer/templates/settings/reports.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — Reports — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
{% set active_tab = "reports" %}
|
||||
{% include "settings/_tabs.html" %}
|
||||
|
||||
{% if flash %}
|
||||
<div style="padding:0.75rem 1rem;border-radius:6px;margin-bottom:1.25rem;
|
||||
background:{% if flash.ok %}var(--green-dim){% else %}var(--red-dim){% endif %};
|
||||
border:1px solid {% if flash.ok %}var(--green){% else %}var(--red){% endif %};
|
||||
color:{% if flash.ok %}var(--green){% else %}var(--red){% endif %};
|
||||
font-size:0.88rem;">
|
||||
{{ flash.message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="max-width:560px;">
|
||||
|
||||
{# ── Schedule ─────────────────────────────────────────────────────────────── #}
|
||||
<form method="post" action="/settings/reports/">
|
||||
<div class="card" style="margin-bottom:1.25rem;">
|
||||
<h2 class="section-title" style="margin-bottom:1.25rem;">Weekly Digest</h2>
|
||||
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<input type="checkbox" name="reports.enabled" id="reports-enabled"
|
||||
{% if settings['reports.enabled'] %}checked{% endif %}>
|
||||
<label for="reports-enabled" style="margin:0;">Enable weekly email digest</label>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:0.5rem;">
|
||||
<div class="form-group">
|
||||
<label>Send on day</label>
|
||||
<select name="reports.schedule_day">
|
||||
{% for i, day in [(0,"Monday"),(1,"Tuesday"),(2,"Wednesday"),(3,"Thursday"),(4,"Friday"),(5,"Saturday"),(6,"Sunday")] %}
|
||||
<option value="{{ i }}" {% if settings['reports.schedule_day'] == i %}selected{% endif %}>
|
||||
{{ day }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>At hour (UTC)</label>
|
||||
<select name="reports.schedule_hour">
|
||||
{% for h in range(24) %}
|
||||
<option value="{{ h }}" {% if settings['reports.schedule_hour'] == h %}selected{% endif %}>
|
||||
{{ "%02d:00"|format(h) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
|
||||
Requires SMTP configured in <a href="/settings/notifications/">Notifications</a>.
|
||||
{% if settings['reports.last_sent_at'] %}
|
||||
Last sent: {{ settings['reports.last_sent_at'][:16] }} UTC.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div style="margin-top:1rem;">
|
||||
<button type="submit" class="btn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# ── Report contents ─────────────────────────────────────────────────────── #}
|
||||
<div class="card" style="margin-bottom:1.25rem;">
|
||||
<h2 class="section-title" style="margin-bottom:0.75rem;">Report contents</h2>
|
||||
<div style="display:grid;gap:0.35rem;font-size:0.85rem;color:var(--text-muted);">
|
||||
<div>• <strong style="color:var(--text);">Uptime summary</strong> — 7-day % per ping-enabled host</div>
|
||||
<div>• <strong style="color:var(--text);">Hosts currently down</strong> — highlighted at top if any</div>
|
||||
<div>• <strong style="color:var(--text);">Active alerts</strong> — firing, acknowledged, pending rules</div>
|
||||
<div>• <strong style="color:var(--text);">Alert events (7d)</strong> — fired and resolved counts</div>
|
||||
<div>• <strong style="color:var(--text);">Top Traefik routers</strong> — avg req/s over last 24h (if plugin enabled)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Send now ─────────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:0.75rem;">Send Now</h2>
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:1rem;">
|
||||
Send a report immediately regardless of schedule. Useful for testing.
|
||||
</p>
|
||||
<form method="post" action="/settings/reports/send-now/">
|
||||
<button type="submit" class="btn btn-ghost">Send report now</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user