feat: rename to FabledScryer, multi-dashboard system, plugin management, branding
- Rename package fablednetmon → fabledscryer throughout - Multi-dashboard: ownership, per-user defaults, HTMX edit (add/remove/reorder) - Read-only share tokens scoped to individual dashboards - Dashboard edit is HTMX-driven (no page reloads) - Plugin management system: remote catalog, download/install, hot-reload, in-app restart - plugin_index.py: fetch/cache remote index.yaml; default URL → bvandeusen/fabledscryer-plugins - plugin_manager.py: download_and_install_plugin, hot_reload_plugin, restart_app - ZIP extraction handles GitHub archive formats (name-v1.0.0/, name-main/) - Settings split into tabbed sections: General, Notifications, Ansible, Plugins - Plugins tab: catalog browser (HTMX), install/activate/update/restart actions - UI/branding: dark palette (#07071a), crystal ball SVG logo, animated star field, Libertinus Serif applied to headings, nav, labels, and section titles - Widget registry (core/widgets.py) for dashboard plugin integration - UPS widget.html (dashboard card) and settings/_tabs.html include - Migrations 0005–0008: dashboards, is_default, ownership, share tokens - docs/plugins/: writing-a-plugin.md updated with publishing guide, index.yaml.example template for fabledscryer-plugins repo Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
{# 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 %}
|
||||
<div class="card" style="margin-bottom:0;padding:1rem 1.25rem;">
|
||||
<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 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 is_loaded %}
|
||||
{# Already active — update available if versions differ #}
|
||||
<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-ghost btn-sm" style="font-size:0.78rem;">Update</button>
|
||||
</form>
|
||||
|
||||
{% 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 found in 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,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,16 @@
|
||||
{# 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/"),
|
||||
("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,22 @@
|
||||
{# 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" %}
|
||||
|
||||
<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>.
|
||||
</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>
|
||||
</div>
|
||||
<div style="margin-top:1rem;">
|
||||
<button type="submit" class="btn">Save</button>
|
||||
</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,161 @@
|
||||
{# 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 (replaced by _restart_pending.html after action) #}
|
||||
<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;">
|
||||
No plugins found in the plugin directory.
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" action="/settings/plugins/">
|
||||
<div style="display:grid;gap:1rem;max-width:720px;">
|
||||
|
||||
{# Plugin index URL #}
|
||||
<div class="card" style="padding:1rem 1.25rem;">
|
||||
<div class="section-title" style="margin-bottom:0.6rem;">Plugin Index URL</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<input type="url" name="plugins.index_url"
|
||||
value="{{ settings.get('plugins.index_url', '') }}"
|
||||
placeholder="https://raw.githubusercontent.com/your-org/fabledscryer-plugins/main/index.yaml"
|
||||
style="font-size:0.85rem;">
|
||||
</div>
|
||||
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.4rem;">
|
||||
URL to the remote <code>index.yaml</code> used by the plugin catalog below.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for plugin in discovered_plugins %}
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
|
||||
{# Plugin header: enable toggle + name/description #}
|
||||
<div style="display:flex;align-items:flex-start;gap:0.75rem;margin-bottom:{% if plugin.get('config') %}1rem{% else %}0{% endif %};">
|
||||
<input type="checkbox"
|
||||
name="plugin.{{ plugin._dir }}.enabled"
|
||||
id="plugin-{{ plugin._dir }}-enabled"
|
||||
{% if plugin._enabled %}checked{% endif %}
|
||||
style="margin-top:0.2rem;flex-shrink:0;">
|
||||
<label for="plugin-{{ plugin._dir }}-enabled" style="margin:0;cursor:pointer;">
|
||||
<span style="font-weight:600;color:var(--text);">{{ plugin.get('name', plugin._dir) }}</span>
|
||||
<span style="color:var(--text-muted);font-size:0.78rem;margin-left:0.4rem;">v{{ plugin.get('version', '?') }}</span>
|
||||
{% if plugin.get('description') %}
|
||||
<div style="color:var(--text-muted);font-size:0.83rem;margin-top:0.15rem;font-weight:normal;">{{ plugin.description }}</div>
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{# Config fields — only shown when there are config keys #}
|
||||
{% if plugin.get('config') %}
|
||||
<div style="border-top:1px solid var(--border);padding-top:1rem;display:grid;gap:0.6rem;">
|
||||
|
||||
{% for cfg_key, cfg_default in plugin.config.items() %}
|
||||
|
||||
{% if cfg_default is mapping %}
|
||||
{# Nested dict group #}
|
||||
<div style="padding:0.6rem 0.75rem;background:var(--bg);border-radius:5px;border:1px solid var(--border);">
|
||||
<div style="font-size:0.75rem;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>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</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);">Enable/disable changes take effect after a restart.</span>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{# ── Plugin Catalog ────────────────────────────────────────────────────────── #}
|
||||
<div style="margin-top:2rem;">
|
||||
<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>
|
||||
|
||||
{% set index_url = settings.get('plugins.index_url', '') %}
|
||||
{% if index_url %}
|
||||
<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;">
|
||||
Configure a Plugin Index URL above to browse available plugins.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user