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,15 @@
|
||||
{#
|
||||
Reusable time-range selector.
|
||||
Requires `current_range` to be in template context.
|
||||
Places a hidden input #time-range (name="range") that HTMX hx-include picks up,
|
||||
plus a visible button group that calls setTimeRange() defined in base.html.
|
||||
#}
|
||||
<div style="display:flex;align-items:center;gap:0.2rem;">
|
||||
<input type="hidden" id="time-range" name="range" value="{{ current_range }}">
|
||||
{% for key in ["1h", "6h", "24h", "7d", "30d"] %}
|
||||
<button type="button"
|
||||
class="range-btn{% if current_range == key %} active{% endif %}"
|
||||
data-range="{{ key }}"
|
||||
onclick="setTimeRange('{{ key }}')">{{ key }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Alerts — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="page-title">Alerts</h1>
|
||||
|
||||
{% if active %}
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Active</h2>
|
||||
<div class="card-flush" style="margin-bottom:2rem;">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>State</th>
|
||||
<th>Rule</th>
|
||||
<th>Resource</th>
|
||||
<th>Metric</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for state, rule in active %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if state.state.value == 'firing' %}
|
||||
<span class="badge badge-red">FIRING</span>
|
||||
{% elif state.state.value == 'acknowledged' %}
|
||||
<span class="badge badge-yellow">ACK</span>
|
||||
{% else %}
|
||||
<span class="badge badge-dim">PENDING</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ rule.name }}</td>
|
||||
<td style="color:var(--text-muted);">{{ rule.resource_name }}</td>
|
||||
<td style="color:var(--text-muted);">{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }}</td>
|
||||
<td class="td-actions">
|
||||
{% if state.state.value == 'firing' %}
|
||||
<form method="post" action="/alerts/{{ rule.id }}/acknowledge" style="display:inline;">
|
||||
<button type="submit" class="btn btn-sm btn-warn">Acknowledge</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:var(--text-muted);margin-bottom:2rem;">No active alerts.</p>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
|
||||
<h2 class="section-title">Rules</h2>
|
||||
<a class="btn" href="/alerts/rules/new">New Rule</a>
|
||||
</div>
|
||||
{% if rules %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Condition</th>
|
||||
<th>Consec.</th>
|
||||
<th>Enabled</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rule in rules %}
|
||||
<tr>
|
||||
<td>{{ rule.name }}</td>
|
||||
<td style="color:var(--text-muted);">
|
||||
{{ rule.source_module }}/{{ rule.resource_name }}:{{ rule.metric_name }}
|
||||
{{ rule.operator.value }} {{ rule.threshold }}
|
||||
</td>
|
||||
<td style="color:var(--text-muted);">{{ rule.consecutive_failures_required }}</td>
|
||||
<td>
|
||||
{% if rule.enabled %}<span class="badge badge-green">yes</span>{% else %}<span class="badge badge-dim">no</span>{% endif %}
|
||||
</td>
|
||||
<td class="td-actions">
|
||||
<form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;">
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete rule {{ rule.name }}?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:var(--text-muted);">No alert rules configured.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}New Alert Rule — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="max-width:560px;margin:2rem auto;">
|
||||
<h1 class="page-title">New Alert Rule</h1>
|
||||
<div class="card">
|
||||
<form method="post" action="/alerts/rules">
|
||||
<div class="form-group">
|
||||
<label>Rule Name</label>
|
||||
<input type="text" name="name" required autofocus placeholder="e.g. Home server down">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Source Module</label>
|
||||
<input type="text" name="source_module" required placeholder="ping, dns, traefik, ...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Resource Name</label>
|
||||
<input type="text" name="resource_name" required placeholder="host name, router name, ...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Metric Name</label>
|
||||
<input type="text" name="metric_name" required placeholder="up, response_time_ms, ...">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Operator</label>
|
||||
<select name="operator">
|
||||
{% for val, label in operators %}
|
||||
<option value="{{ val }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Threshold</label>
|
||||
<input type="number" name="threshold" step="any" required placeholder="0.5">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Consecutive failures required before FIRING (default: 1)</label>
|
||||
<input type="number" name="consecutive_failures_required" value="1" min="1">
|
||||
</div>
|
||||
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
|
||||
<button type="submit" class="btn">Create Rule</button>
|
||||
<a href="/alerts/" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,101 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Browse Playbooks — Fabled Scryer{% endblock %}
|
||||
{% 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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% for sd in source_data %}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{% if not sd.playbooks %}
|
||||
<p style="color:#606080;font-size:0.9rem;">No playbooks found at this path.</p>
|
||||
{% else %}
|
||||
<table class="table" style="margin-bottom:1rem;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Playbook</th>
|
||||
<th style="width:120px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pb in sd.playbooks %}
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">{{ pb }}</td>
|
||||
<td class="td-actions">
|
||||
<a href="/ansible/browse/{{ sd.source.name }}/{{ pb }}" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">View</a>
|
||||
<a href="#run-form-{{ sd.source.name }}"
|
||||
onclick="document.getElementById('run-form-{{ sd.source.name }}').style.display='block';
|
||||
document.getElementById('run-playbook-{{ sd.source.name }}').value='{{ pb }}';
|
||||
document.getElementById('run-playbook-display-{{ sd.source.name }}').value='{{ pb }}';
|
||||
return false;"
|
||||
class="btn btn-sm btn-ghost">Run</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{# Run trigger form for this source #}
|
||||
<div id="run-form-{{ sd.source.name }}" style="display:none;background:var(--bg-elevated);border-radius:6px;padding:1rem;border:1px solid var(--border-mid);">
|
||||
<h4 class="section-title" style="margin-bottom:0.75rem;">Trigger Run</h4>
|
||||
<form hx-post="/ansible/runs" hx-target="#run-result-{{ sd.source.name }}" hx-swap="innerHTML">
|
||||
<input type="hidden" name="source_name" value="{{ sd.source.name }}">
|
||||
<input type="hidden" id="run-playbook-{{ sd.source.name }}" name="playbook_path" value="">
|
||||
<div class="form-group">
|
||||
<label>Playbook</label>
|
||||
<input type="text" id="run-playbook-display-{{ sd.source.name }}"
|
||||
readonly style="color:var(--text-muted);" value="">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Inventory</label>
|
||||
<select name="inventory_path">
|
||||
{% for inv in sd.inventories %}
|
||||
<option value="{{ inv }}">{{ inv }}</option>
|
||||
{% endfor %}
|
||||
<option value="">— Enter manually below —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Or enter inventory path manually</label>
|
||||
<input type="text" id="manual-inv-{{ sd.source.name }}" placeholder="inventories/production/hosts">
|
||||
</div>
|
||||
<div style="display:flex;gap:0.75rem;align-items:center;">
|
||||
<button type="button" class="btn"
|
||||
onclick="var manual=document.getElementById('manual-inv-{{ sd.source.name }}').value.trim();
|
||||
if(manual){this.closest('form').querySelector('[name=inventory_path]').value=manual;}
|
||||
htmx.trigger(this.closest('form'),'submit');">Execute</button>
|
||||
<a href="#" onclick="document.getElementById('run-form-{{ sd.source.name }}').style.display='none';return false;"
|
||||
class="btn btn-ghost btn-sm">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
<div id="run-result-{{ sd.source.name }}" style="margin-top:1rem;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ansible Runs — Fabled Scryer{% endblock %}
|
||||
{% 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;">Ansible Runs</h1>
|
||||
<a href="/ansible/browse" class="btn">Browse Playbooks</a>
|
||||
</div>
|
||||
{% if not runs %}
|
||||
<div class="card">
|
||||
<p style="color:var(--text-muted);">No runs yet. <a href="/ansible/browse">Browse playbooks</a> to trigger a run.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Playbook</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in runs %}
|
||||
{% set status_color = {"running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %}
|
||||
<tr>
|
||||
<td style="font-size:0.9rem;">{{ run.playbook_path }}</td>
|
||||
<td style="color:var(--text-muted);font-size:0.9rem;">{{ run.source_name }}</td>
|
||||
<td>
|
||||
<span style="color:{{ status_color }};font-size:0.85rem;font-weight:bold;">{{ run.status.value.upper() }}</span>
|
||||
</td>
|
||||
<td style="color:var(--text-muted);font-size:0.85rem;">{{ run.started_at.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td class="td-actions">
|
||||
<a href="/ansible/runs/{{ run.id }}" class="btn btn-sm btn-ghost">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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>
|
||||
<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") %}
|
||||
<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>
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% 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;">
|
||||
Streaming…
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var es = new EventSource('/ansible/runs/{{ run.id }}/stream');
|
||||
var pre = document.getElementById('live-output');
|
||||
var status = document.getElementById('run-done-status');
|
||||
es.addEventListener('output', function(e) {
|
||||
pre.textContent += e.data + '\n';
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
});
|
||||
es.addEventListener('done', function(e) {
|
||||
es.close();
|
||||
status.textContent = 'Finished: ' + e.data;
|
||||
});
|
||||
})();
|
||||
</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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
<div style="background:#0f0f1e;border:1px solid #2a2a4a;border-radius:6px;padding:1rem;">
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:0.75rem;">
|
||||
<span style="color:#a0a000;font-weight:bold;">RUNNING</span>
|
||||
<span style="color:#a0a0c0;font-size:0.9rem;">{{ run.playbook_path }}</span>
|
||||
<a href="/ansible/runs/{{ run.id }}" style="color:#6060c0;font-size:0.82rem;margin-left:auto;">Full view →</a>
|
||||
</div>
|
||||
<pre id="sse-output-{{ run.id }}"
|
||||
style="background:#080810;padding:0.75rem;border-radius:4px;font-family:monospace;font-size:0.78rem;color:#a0c0a0;max-height:400px;overflow-y:auto;white-space:pre-wrap;margin:0 0 0.5rem 0;">
|
||||
</pre>
|
||||
<div id="sse-status-{{ run.id }}" style="font-size:0.85rem;color:#606080;">
|
||||
Streaming output…
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var es = new EventSource('/ansible/runs/{{ run.id }}/stream');
|
||||
var pre = document.getElementById('sse-output-{{ run.id }}');
|
||||
var status = document.getElementById('sse-status-{{ run.id }}');
|
||||
es.addEventListener('output', function(e) {
|
||||
pre.textContent += e.data + '\n';
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
});
|
||||
es.addEventListener('done', function(e) {
|
||||
es.close();
|
||||
status.textContent = 'Finished: ' + e.data;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="max-width:400px;margin:4rem auto;">
|
||||
<div class="card">
|
||||
<h2 class="page-title">Sign In</h2>
|
||||
<form method="post" action="/login">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}First-Run Setup — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="max-width:400px;margin:4rem auto;">
|
||||
<div class="card">
|
||||
<h2 class="page-title" style="margin-bottom:0.5rem;">First-Run Setup</h2>
|
||||
<p style="margin-bottom:1.5rem;color:var(--text-muted);font-size:0.9rem;">Create the initial admin account.</p>
|
||||
<form method="post" action="/setup">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" name="email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Create Admin Account</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,355 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Fabled Scryer{% endblock %}</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libertinus+Serif:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #07071a;
|
||||
--bg-card: #0f0f24;
|
||||
--bg-elevated: #17172e;
|
||||
--border: #1e1e3e;
|
||||
--border-mid: #28284c;
|
||||
--text: #d2cef0;
|
||||
--text-muted: #6858a0;
|
||||
--text-dim: #282860;
|
||||
--accent: #7c5ef4;
|
||||
--accent-hover: #9070f8;
|
||||
--gold: #c9a84c;
|
||||
--gold-dim: #221b00;
|
||||
--green: #26d96b;
|
||||
--green-dim: #0e3a1e;
|
||||
--yellow: #e6b818;
|
||||
--yellow-dim: #3a3000;
|
||||
--orange: #e07828;
|
||||
--orange-dim: #3a1e00;
|
||||
--red: #e83848;
|
||||
--red-dim: #3a0e14;
|
||||
--font-serif: 'Libertinus Serif', Georgia, serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); }
|
||||
code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
|
||||
|
||||
/* Star field */
|
||||
#star-field { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
|
||||
@keyframes star-orbit { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
@keyframes star-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
background: var(--bg-card);
|
||||
padding: 0 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border-mid);
|
||||
height: 48px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
nav .brand {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
margin-right: 2rem;
|
||||
letter-spacing: 0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
background: linear-gradient(110deg, #c8b4f8 0%, var(--gold) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-decoration: none;
|
||||
}
|
||||
nav .brand svg { flex-shrink: 0; }
|
||||
nav a { color: var(--text-muted); font-size: 0.875rem; padding: 0 0.875rem; height: 48px; display: flex; align-items: center; border-bottom: 2px solid transparent; transition: color .15s, border-color .15s; }
|
||||
nav a:hover { color: var(--text); border-bottom-color: var(--border-mid); }
|
||||
nav a.nav-end { margin-left: auto; }
|
||||
|
||||
/* Layout */
|
||||
main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; width: 100%; box-sizing: border-box; position: relative; z-index: 1; }
|
||||
|
||||
/* Alerts */
|
||||
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
|
||||
.alert-error { background: var(--red-dim); border: 1px solid #6a1820; color: #ffb0b8; }
|
||||
.alert-success { background: var(--green-dim); border: 1px solid #1a5a28; color: #90f0b0; }
|
||||
|
||||
/* Cards */
|
||||
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
|
||||
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 1rem; }
|
||||
|
||||
/* Typography */
|
||||
.page-title { font-family: var(--font-serif); font-size: 1.6rem; font-weight: 600; color: #c8b4f8; margin-bottom: 1.5rem; letter-spacing: 0.01em; }
|
||||
.section-title { font-family: var(--font-serif); font-size: 0.82rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
h1, h2, h3 { font-family: var(--font-serif); }
|
||||
|
||||
/* Tables */
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th { text-align: left; padding: 0.6rem 1rem; color: var(--text-muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-mid); }
|
||||
.table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
|
||||
.table tbody tr:last-child td { border-bottom: none; }
|
||||
.table tbody tr:hover td { background: var(--bg-elevated); }
|
||||
.table .td-actions { text-align: right; white-space: nowrap; }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
label { display: block; margin-bottom: 0.3rem; font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-serif); }
|
||||
input[type=text], input[type=email], input[type=password], input[type=number], input[type=url], select, textarea {
|
||||
width: 100%; padding: 0.5rem 0.75rem; background: var(--bg); border: 1px solid var(--border-mid);
|
||||
border-radius: 5px; color: var(--text); font-size: 0.9rem; font-family: inherit;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
||||
select { cursor: pointer; }
|
||||
textarea { resize: vertical; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 1rem; background: var(--accent); color: #fff; border: none; border-radius: 5px; cursor: pointer; font-size: 0.875rem; font-family: inherit; font-weight: 500; transition: background .15s; text-decoration: none; }
|
||||
.btn:hover { background: var(--accent-hover); color: #fff; }
|
||||
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
|
||||
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-mid); }
|
||||
.btn-ghost:hover { background: var(--bg-elevated); color: var(--text); }
|
||||
.btn-danger { background: #7a1820; color: #ffb0b0; }
|
||||
.btn-danger:hover { background: #9a2030; }
|
||||
.btn-warn { background: var(--yellow-dim); color: var(--yellow); border: 1px solid #6a5000; }
|
||||
.btn-warn:hover { background: #4a4000; }
|
||||
|
||||
/* Badges / Status */
|
||||
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 4px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.03em; }
|
||||
.badge-green { background: var(--green-dim); color: var(--green); }
|
||||
.badge-red { background: var(--red-dim); color: #ff8090; }
|
||||
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
|
||||
.badge-orange { background: var(--orange-dim); color: var(--orange); }
|
||||
.badge-gold { background: var(--gold-dim); color: var(--gold); }
|
||||
.badge-dim { background: var(--bg-elevated); color: var(--text-muted); }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot-up { background: var(--green); box-shadow: 0 0 4px var(--green); }
|
||||
.dot-down { background: var(--red); }
|
||||
.dot-warn { background: var(--yellow); }
|
||||
.dot-dim { background: var(--text-dim); }
|
||||
|
||||
/* Empty state */
|
||||
.empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
|
||||
|
||||
/* Stat cards (dashboard) */
|
||||
.stat-val { font-size: 2rem; font-weight: 700; line-height: 1; }
|
||||
.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-left: 0.3rem; }
|
||||
|
||||
/* Ping pills */
|
||||
.ping-card { padding: 0.75rem 1.25rem; }
|
||||
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid var(--border); }
|
||||
.ping-row:last-child { border-bottom:none; }
|
||||
.ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
|
||||
.ping-name { font-size:0.875rem; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block; }
|
||||
.ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.ping-pills { display:flex; gap:2px; flex:1; min-width:0; overflow:hidden; align-items:center; }
|
||||
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; cursor:default; transition:opacity .1s; }
|
||||
.pill:hover { opacity:.75; }
|
||||
.pill-empty { background:var(--bg-elevated); }
|
||||
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
|
||||
.ping-cur-good { color:var(--green); }
|
||||
.ping-cur-warn { color:var(--yellow); }
|
||||
.ping-cur-bad { color:var(--orange); }
|
||||
.ping-cur-down { color:var(--red); font-weight:bold; }
|
||||
.ping-cur-nd { color:var(--text-dim); }
|
||||
|
||||
/* Widget headers */
|
||||
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
|
||||
.widget-title { font-family:var(--font-serif); font-size:0.8rem; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; }
|
||||
.widget-link { font-size:0.8rem; color:var(--text-muted); }
|
||||
.widget-link:hover { color:var(--text); }
|
||||
|
||||
/* Time range selector */
|
||||
.range-btn { background:none; border:1px solid var(--border-mid); border-radius:4px; color:var(--text-muted); padding:0.18rem 0.5rem; cursor:pointer; font-size:0.75rem; font-family:inherit; transition:background .1s,color .1s,border-color .1s; }
|
||||
.range-btn:hover { background:var(--bg-elevated); color:var(--text); border-color:var(--accent); }
|
||||
.range-btn.active { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{# Star field — orbits around top-left corner, populated by JS below #}
|
||||
<div id="star-field"></div>
|
||||
|
||||
{% if session.user_id is defined %}
|
||||
<nav>
|
||||
<a href="/" class="brand">
|
||||
{# Crystal ball logo #}
|
||||
<svg width="20" height="24" viewBox="0 0 20 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<radialGradient id="orb-grad" cx="38%" cy="32%" r="62%">
|
||||
<stop offset="0%" stop-color="#6040c8"/>
|
||||
<stop offset="55%" stop-color="#2a1468"/>
|
||||
<stop offset="100%" stop-color="#0e0830"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glow-grad" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#c9a84c" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="#c9a84c" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<!-- Ball body -->
|
||||
<circle cx="10" cy="10" r="9" fill="url(#orb-grad)" stroke="#7c5ef4" stroke-width="0.5"/>
|
||||
<!-- Inner gold glow -->
|
||||
<circle cx="10" cy="10" r="5" fill="url(#glow-grad)"/>
|
||||
<!-- Reflection highlight -->
|
||||
<ellipse cx="7.5" cy="7" rx="2.2" ry="1.1" fill="white" opacity="0.14" transform="rotate(-25 7.5 7)"/>
|
||||
<!-- Inner star -->
|
||||
<path d="M10 7.2 L10.45 8.85 L12.2 8.85 L10.88 9.9 L11.35 11.55 L10 10.55 L8.65 11.55 L9.12 9.9 L7.8 8.85 L9.55 8.85 Z"
|
||||
fill="#c9a84c" opacity="0.55"/>
|
||||
<!-- Stand neck -->
|
||||
<path d="M8.2 19 L8.9 15.8 L11.1 15.8 L11.8 19" fill="none" stroke="#5040a0" stroke-width="0.75" stroke-linejoin="round"/>
|
||||
<!-- Stand base -->
|
||||
<ellipse cx="10" cy="19.5" rx="4" ry="1" fill="none" stroke="#5040a0" stroke-width="0.75"/>
|
||||
</svg>
|
||||
Fabled Scryer
|
||||
</a>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/hosts/">Hosts</a>
|
||||
<a href="/ping/">Ping</a>
|
||||
<a href="/dns/">DNS</a>
|
||||
<a href="/alerts/">Alerts</a>
|
||||
<a href="/ansible/">Ansible</a>
|
||||
{% if session.user_role == 'admin' %}
|
||||
<a href="/settings/">Settings</a>
|
||||
{% endif %}
|
||||
<a href="/logout" class="nav-end">{{ session.username }}</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function setTimeRange(val) {
|
||||
var input = document.getElementById('time-range');
|
||||
if (input) input.value = val;
|
||||
document.querySelectorAll('.range-btn').forEach(function(b) {
|
||||
b.classList.toggle('active', b.dataset.range === val);
|
||||
});
|
||||
var url = new URL(window.location);
|
||||
url.searchParams.set('range', val);
|
||||
history.replaceState({}, '', url);
|
||||
document.body.dispatchEvent(new CustomEvent('rangeChange'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var field = document.getElementById('star-field');
|
||||
if (!field) return;
|
||||
|
||||
var W = window.innerWidth;
|
||||
var H = window.innerHeight;
|
||||
var maxR = Math.sqrt(W * W + H * H);
|
||||
|
||||
// Subtle purple glow anchoring the field to the top-left corner
|
||||
var glow = document.createElement('div');
|
||||
glow.style.cssText = 'position:absolute;width:500px;height:500px;left:-250px;top:-250px;border-radius:50%;background:radial-gradient(circle,rgba(124,94,244,0.06) 0%,transparent 65%);pointer-events:none;';
|
||||
field.appendChild(glow);
|
||||
|
||||
// Compute polygon points for a 5-pointed star inside a size×size box
|
||||
function starPoints(size) {
|
||||
var cx = size / 2, cy = size / 2;
|
||||
var R = size / 2 * 0.95; // outer radius
|
||||
var r = R * 0.382; // inner radius (golden ratio)
|
||||
var pts = [];
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var oa = (i * 72 - 90) * Math.PI / 180;
|
||||
var ia = (i * 72 - 90 + 36) * Math.PI / 180;
|
||||
pts.push((cx + R * Math.cos(oa)).toFixed(2) + ',' + (cy + R * Math.sin(oa)).toFixed(2));
|
||||
pts.push((cx + r * Math.cos(ia)).toFixed(2) + ',' + (cy + r * Math.sin(ia)).toFixed(2));
|
||||
}
|
||||
return pts.join(' ');
|
||||
}
|
||||
|
||||
// 22 wizard-hat stars: [size(px), opacity, isGold, orbitDuration(s), spinDuration(s)]
|
||||
var defs = [
|
||||
[230, 0.09, true, 200, 28],
|
||||
[144, 0.08, true, 260, 40],
|
||||
[ 92, 0.10, false, 150, 22],
|
||||
[190, 0.07, true, 310, 35],
|
||||
[116, 0.09, false, 175, 18],
|
||||
[260, 0.07, true, 280, 45],
|
||||
[ 80, 0.11, false, 135, 20],
|
||||
[164, 0.08, true, 230, 32],
|
||||
[104, 0.09, false, 165, 24],
|
||||
[200, 0.07, true, 295, 38],
|
||||
[ 70, 0.10, false, 120, 16],
|
||||
[150, 0.08, true, 245, 30],
|
||||
[ 88, 0.09, false, 140, 21],
|
||||
[210, 0.07, true, 320, 42],
|
||||
[128, 0.08, false, 180, 26],
|
||||
[ 76, 0.10, true, 160, 19],
|
||||
[175, 0.07, true, 290, 36],
|
||||
[ 96, 0.09, false, 155, 23],
|
||||
[220, 0.07, true, 305, 44],
|
||||
[110, 0.08, false, 170, 25],
|
||||
[ 84, 0.09, true, 235, 31],
|
||||
[140, 0.08, false, 185, 27],
|
||||
];
|
||||
|
||||
var n = defs.length;
|
||||
defs.forEach(function (def, i) {
|
||||
var size = def[0], opacity = def[1], isGold = def[2], orbitDur = def[3], spinDur = def[4];
|
||||
|
||||
// Spread evenly across a ~110° fan from top-left, small random jitter
|
||||
var angleDeg = 4 + (i / (n - 1)) * 108 + (Math.random() * 8 - 4);
|
||||
var angle = angleDeg * Math.PI / 180;
|
||||
var radius = (0.15 + (i / n) * 0.7 + Math.random() * 0.15) * maxR * 0.88 + 60;
|
||||
|
||||
// Star center position in parent coords
|
||||
var cx = Math.cos(angle) * radius;
|
||||
var cy = Math.sin(angle) * radius;
|
||||
var left = cx - size / 2;
|
||||
var top = cy - size / 2;
|
||||
|
||||
var orbitDelay = -(Math.random() * orbitDur);
|
||||
var spinDelay = -(Math.random() * spinDur);
|
||||
var color = isGold ? '#c9a84c' : '#c8b4f8';
|
||||
|
||||
// Outer wrapper handles orbit — transform-origin points back to (0,0) corner
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = [
|
||||
'position:absolute',
|
||||
'left:' + left + 'px',
|
||||
'top:' + top + 'px',
|
||||
'width:' + size + 'px',
|
||||
'height:'+ size + 'px',
|
||||
'opacity:' + opacity,
|
||||
'transform-origin:' + (-left) + 'px ' + (-top) + 'px',
|
||||
'animation:star-orbit ' + orbitDur + 's ' + orbitDelay + 's linear infinite',
|
||||
'pointer-events:none'
|
||||
].join(';');
|
||||
|
||||
// Inner SVG handles spin — transform-origin defaults to 50% 50% (star center)
|
||||
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', size);
|
||||
svg.setAttribute('height', size);
|
||||
svg.setAttribute('viewBox', '0 0 ' + size + ' ' + size);
|
||||
svg.style.cssText = 'display:block;animation:star-spin ' + spinDur + 's ' + spinDelay + 's linear infinite;';
|
||||
|
||||
var poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||||
poly.setAttribute('points', starPoints(size));
|
||||
poly.setAttribute('fill', color);
|
||||
svg.appendChild(poly);
|
||||
wrapper.appendChild(svg);
|
||||
field.appendChild(wrapper);
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
{# dashboard/_edit_panels.html — returned by all mutation routes and included by edit.html #}
|
||||
<div id="edit-panels" style="display:grid;grid-template-columns:1fr 320px;gap:1.5rem;align-items:start;">
|
||||
|
||||
{# ── Current layout ───────────────────────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Current Layout</div>
|
||||
{% if widgets %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for item in widgets %}
|
||||
<div class="card" style="margin-bottom:0;display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:2px;flex-shrink:0;">
|
||||
<button hx-post="/d/{{ dashboard.id }}/edit/move/{{ item.db.id }}/up"
|
||||
hx-target="#edit-panels" hx-swap="outerHTML"
|
||||
class="btn btn-ghost btn-sm"
|
||||
style="padding:0.1rem 0.4rem;font-size:0.7rem;line-height:1;"
|
||||
{% if loop.first %}disabled{% endif %}>▲</button>
|
||||
<button hx-post="/d/{{ dashboard.id }}/edit/move/{{ item.db.id }}/down"
|
||||
hx-target="#edit-panels" hx-swap="outerHTML"
|
||||
class="btn btn-ghost btn-sm"
|
||||
style="padding:0.1rem 0.4rem;font-size:0.7rem;line-height:1;"
|
||||
{% if loop.last %}disabled{% endif %}>▼</button>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:0.9rem;">{{ item.defn.label }}</div>
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.1rem;">{{ item.defn.description }}</div>
|
||||
</div>
|
||||
|
||||
<button hx-post="/d/{{ dashboard.id }}/edit/remove/{{ item.db.id }}"
|
||||
hx-target="#edit-panels" hx-swap="outerHTML"
|
||||
class="btn btn-danger btn-sm" style="flex-shrink:0;">Remove</button>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;font-size:0.9rem;">
|
||||
No widgets yet — add some from the panel on the right.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Available widgets ────────────────────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Available Widgets</div>
|
||||
{% if addable %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for w in addable %}
|
||||
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:0.875rem;">{{ w.label }}</div>
|
||||
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;">{{ w.description }}</div>
|
||||
</div>
|
||||
<button hx-post="/d/{{ dashboard.id }}/edit/add/{{ w.key }}"
|
||||
hx-target="#edit-panels" hx-swap="outerHTML"
|
||||
class="btn btn-sm" style="flex-shrink:0;">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:1rem 0;">
|
||||
All available widgets are on this dashboard.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit: {{ dashboard.name }} — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Edit: {{ dashboard.name }}</h1>
|
||||
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost btn-sm">Done</a>
|
||||
</div>
|
||||
{% include "dashboard/_edit_panels.html" %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ dashboard.name }} — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# ── Header ────────────────────────────────────────────────────────────────── #}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">{{ dashboard.name }}</h1>
|
||||
{% if dashboard.is_default %}
|
||||
<span class="badge badge-gold" style="font-size:0.7rem;">Default</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
{# Dashboard switcher — shown when more than one dashboard exists #}
|
||||
{% if all_dashboards | length > 1 %}
|
||||
<select onchange="window.location='/d/'+this.value"
|
||||
style="font-size:0.82rem;padding:0.3rem 0.6rem;background:var(--bg-card);border:1px solid var(--border-mid);border-radius:5px;color:var(--text);cursor:pointer;">
|
||||
{% for d in all_dashboards %}
|
||||
<option value="{{ d.id }}" {% if d.id == dashboard.id %}selected{% endif %}>{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
{% if can_edit %}
|
||||
<a href="/d/{{ dashboard.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
|
||||
<a href="/d/{{ dashboard.id }}/share" class="btn btn-ghost btn-sm">Share</a>
|
||||
{% endif %}
|
||||
<a href="/dashboards/" class="btn btn-ghost btn-sm">Dashboards</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Summary strip ─────────────────────────────────────────────────────────── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">Hosts</div>
|
||||
<span class="stat-val">{{ total_hosts }}</span>
|
||||
<span class="stat-label">total</span>
|
||||
<div style="margin-top:0.5rem;"><a href="/hosts/" style="font-size:0.8rem;">Manage →</a></div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">Ping</div>
|
||||
{% if ping_up + ping_down == 0 %}
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
|
||||
{% else %}
|
||||
<span class="stat-val" style="color:var(--green);">{{ ping_up }}</span><span class="stat-label">up</span>
|
||||
{% if ping_down %} <span class="stat-val" style="color:var(--red);">{{ ping_down }}</span><span class="stat-label">down</span>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">DNS</div>
|
||||
{% if dns_ok + dns_fail == 0 %}
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
|
||||
{% else %}
|
||||
<span class="stat-val" style="color:var(--green);">{{ dns_ok }}</span><span class="stat-label">ok</span>
|
||||
{% if dns_fail %} <span class="stat-val" style="color:var(--red);">{{ dns_fail }}</span><span class="stat-label">failed</span>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">Alerts</div>
|
||||
<a href="/alerts/" style="font-size:0.85rem;">View rules →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Widget grid ───────────────────────────────────────────────────────────── #}
|
||||
{% if widgets %}
|
||||
<div style="column-width:380px;column-count:3;column-gap:1rem;">
|
||||
{% for item in widgets %}
|
||||
<div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<div class="widget-header">
|
||||
<span class="widget-title">{{ item.defn.label }}</span>
|
||||
<a href="{{ item.defn.detail_url }}" class="widget-link">Details →</a>
|
||||
</div>
|
||||
<div id="widget-{{ item.db.id }}"
|
||||
hx-get="{{ item.defn.hx_url }}"
|
||||
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<div style="font-size:1.1rem;color:var(--text-muted);margin-bottom:1rem;">No widgets on this dashboard yet.</div>
|
||||
{% if can_edit %}
|
||||
<a href="/d/{{ dashboard.id }}/edit" class="btn">Add Widgets</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,132 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboards — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Dashboards</h1>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 300px;gap:1.5rem;align-items:start;">
|
||||
|
||||
{# ── Left: dashboard lists ─────────────────────────────────────────── #}
|
||||
<div style="display:grid;gap:1.5rem;">
|
||||
|
||||
{# My dashboards #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">My Dashboards</div>
|
||||
{% if my_dashboards %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for dash in my_dashboards %}
|
||||
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
|
||||
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
|
||||
{% if dash.is_default %}
|
||||
<span class="badge badge-gold" style="font-size:0.68rem;">Your Default</span>
|
||||
{% endif %}
|
||||
{% if dash.is_shared %}
|
||||
<span class="badge badge-dim" style="font-size:0.68rem;">Shared</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0;">
|
||||
<a href="/d/{{ dash.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
|
||||
|
||||
<details style="position:relative;">
|
||||
<summary class="btn btn-ghost btn-sm" style="cursor:pointer;list-style:none;">Rename</summary>
|
||||
<div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:0 4px 16px rgba(0,0,0,0.4);">
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/rename" style="display:flex;gap:0.4rem;">
|
||||
<input type="text" name="name" value="{{ dash.name }}"
|
||||
style="flex:1;font-size:0.85rem;padding:0.3rem 0.5rem;" required>
|
||||
<button type="submit" class="btn btn-sm">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/toggle-share" style="margin:0;" title="{{ 'Make private' if dash.is_shared else 'Share with all users' }}">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">{{ '🔒' if dash.is_shared else '🔗' }}</button>
|
||||
</form>
|
||||
|
||||
{% if not dash.is_default %}
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">★</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if my_dashboards | length > 1 %}
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/delete" style="margin:0;"
|
||||
onsubmit="return confirm('Delete \'{{ dash.name }}\'?')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
You don't have any personal dashboards yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Shared / system dashboards #}
|
||||
{% if shared_dashboards %}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Shared Dashboards</div>
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for dash in shared_dashboards %}
|
||||
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
|
||||
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
|
||||
{% if dash.owner_id is none %}
|
||||
<span class="badge badge-dim" style="font-size:0.68rem;">System</span>
|
||||
{% endif %}
|
||||
{% if dash.is_default %}
|
||||
<span class="badge badge-gold" style="font-size:0.68rem;">Global Default</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0;">
|
||||
<a href="/d/{{ dash.id }}" class="btn btn-ghost btn-sm">View</a>
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">★</button>
|
||||
</form>
|
||||
{# Admins can also edit system dashboards #}
|
||||
{% if user_role == 'admin' %}
|
||||
<a href="/d/{{ dash.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Right: create new dashboard ──────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">New Dashboard</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<form method="post" action="/dashboards/create">
|
||||
<div class="form-group" style="margin-bottom:0.75rem;">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" placeholder="e.g. Network Overview" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.75rem;line-height:1.5;">
|
||||
New dashboards are private by default.<br>
|
||||
Use 🔗 to share with all users.<br>
|
||||
Use ★ to set as your default view.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Share: {{ dashboard.name }} — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Share: {{ dashboard.name }}</h1>
|
||||
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost btn-sm">Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 320px;gap:1.5rem;align-items:start;max-width:900px;">
|
||||
|
||||
{# ── Active tokens ─────────────────────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Active Share Links</div>
|
||||
{% if tokens %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for token in tokens %}
|
||||
{% set expired = token.expires_at is not none and token.expires_at <= now %}
|
||||
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;{% if expired %}opacity:0.5;{% endif %}">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:0.875rem;">
|
||||
{{ token.label or "Unnamed link" }}
|
||||
{% if expired %}<span class="badge badge-red" style="font-size:0.68rem;">Expired</span>{% endif %}
|
||||
</div>
|
||||
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.15rem;">
|
||||
Created {{ token.created_at.strftime("%Y-%m-%d") }}
|
||||
{% if token.expires_at %}
|
||||
· Expires {{ token.expires_at.strftime("%Y-%m-%d") }}
|
||||
{% else %}
|
||||
· Never expires
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/d/{{ dashboard.id }}/share/revoke/{{ token.id }}"
|
||||
style="margin:0;" onsubmit="return confirm('Revoke this share link?')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Revoke</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
No active share links. Create one on the right.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Create new token ──────────────────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Create Share Link</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<form method="post" action="/d/{{ dashboard.id }}/share/create">
|
||||
<div class="form-group" style="margin-bottom:0.75rem;">
|
||||
<label>Label <span style="color:var(--text-muted);font-size:0.78rem;">(optional)</span></label>
|
||||
<input type="text" name="label" placeholder="e.g. Public display screen">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0.75rem;">
|
||||
<label>Expires after</label>
|
||||
<select name="expires_in">
|
||||
<option value="">Never</option>
|
||||
<option value="1">1 day</option>
|
||||
<option value="7">7 days</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn">Generate Link</button>
|
||||
</form>
|
||||
</div>
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.75rem;line-height:1.5;">
|
||||
The link is shown <strong>once</strong> after creation.<br>
|
||||
Revoke it at any time to cut off access immediately.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Share Link Created — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="max-width:640px;">
|
||||
<h1 class="page-title">Share Link Created</h1>
|
||||
|
||||
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;">
|
||||
<div style="font-size:0.8rem;font-weight:600;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
|
||||
Copy this link now — it cannot be shown again
|
||||
</div>
|
||||
|
||||
{% set share_url = request.host_url.rstrip('/') + '/share/' + raw_token %}
|
||||
<div style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<input type="text" id="share-url" value="{{ share_url }}" readonly
|
||||
style="flex:1;font-family:ui-monospace,monospace;font-size:0.8rem;background:var(--bg);color:var(--text);">
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('share-url').value).then(()=>this.textContent='Copied!')"
|
||||
class="btn btn-ghost btn-sm" style="white-space:nowrap;">Copy</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:0.75rem;font-size:0.82rem;color:var(--text-muted);">
|
||||
{% if label %}<strong>Label:</strong> {{ label }} · {% endif %}
|
||||
{% if expires_at %}<strong>Expires:</strong> {{ expires_at.strftime("%Y-%m-%d") }}
|
||||
{% else %}<strong>Expires:</strong> Never{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:0.75rem;">
|
||||
<a href="/d/{{ dashboard.id }}/share" class="btn btn-ghost">Manage Share Links</a>
|
||||
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost">Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ dashboard.name }} — Fabled Scryer</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libertinus+Serif:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #07071a; --bg-card: #0f0f24; --bg-elevated: #17172e;
|
||||
--border: #1e1e3e; --border-mid: #28284c;
|
||||
--text: #d2cef0; --text-muted: #6858a0; --text-dim: #282860;
|
||||
--accent: #7c5ef4; --gold: #c9a84c;
|
||||
--green: #26d96b; --green-dim: #0e3a1e;
|
||||
--yellow: #e6b818; --red: #e83848; --red-dim: #3a0e14;
|
||||
--orange: #e07828;
|
||||
--font-serif: 'Libertinus Serif', Georgia, serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
|
||||
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
|
||||
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 1rem; }
|
||||
.section-title { font-family: var(--font-serif); font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th { text-align: left; padding: 0.6rem 1rem; color: var(--text-muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-mid); }
|
||||
.table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
|
||||
.table tbody tr:last-child td { border-bottom: none; }
|
||||
.table tbody tr:hover td { background: var(--bg-elevated); }
|
||||
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 4px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.03em; }
|
||||
.badge-green { background: var(--green-dim); color: var(--green); }
|
||||
.badge-red { background: var(--red-dim); color: #ff8090; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot-up { background: var(--green); box-shadow: 0 0 4px var(--green); }
|
||||
.dot-down { background: var(--red); }
|
||||
.dot-warn { background: var(--yellow); }
|
||||
.dot-dim { background: var(--text-dim); }
|
||||
.ping-card { padding: 0.75rem 1.25rem; }
|
||||
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid var(--border); }
|
||||
.ping-row:last-child { border-bottom:none; }
|
||||
.ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
|
||||
.ping-name { font-size:0.875rem; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block; }
|
||||
.ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.ping-pills { display:flex; gap:2px; flex:1; min-width:0; overflow:hidden; align-items:center; }
|
||||
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; }
|
||||
.pill-empty { background:var(--bg-elevated); }
|
||||
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
|
||||
.ping-cur-good { color:var(--green); } .ping-cur-warn { color:var(--yellow); }
|
||||
.ping-cur-bad { color:var(--orange); } .ping-cur-down { color:var(--red); font-weight:bold; }
|
||||
.ping-cur-nd { color:var(--text-dim); }
|
||||
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
|
||||
.widget-title { font-family:var(--font-serif); font-size:0.8rem; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; }
|
||||
.stat-val { font-size: 2rem; font-weight: 700; line-height: 1; }
|
||||
.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-left: 0.3rem; }
|
||||
header { background: var(--bg-card); padding: 0 1.5rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border-mid); height: 48px; font-family: var(--font-serif); }
|
||||
main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<span style="font-weight:700;font-size:1rem;background:linear-gradient(110deg,#c8b4f8,#c9a84c);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;">
|
||||
{{ dashboard.name }}
|
||||
</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);">Fabled Scryer · read-only view</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% if widgets %}
|
||||
<div style="column-width:380px;column-count:3;column-gap:1rem;">
|
||||
{% for item in widgets %}
|
||||
<div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<div class="widget-header">
|
||||
<span class="widget-title">{{ item.defn.label }}</span>
|
||||
</div>
|
||||
{# Pass share token as ?s= param so require_role allows through #}
|
||||
<div id="widget-{{ item.db.id }}"
|
||||
hx-get="{{ item.defn.hx_url }}?s={{ raw_token }}"
|
||||
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);text-align:center;padding:4rem;font-size:0.9rem;">
|
||||
This dashboard has no widgets.
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}DNS Monitor — Fabled Scryer{% endblock %}
|
||||
{% 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;">DNS Monitor</h1>
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
|
||||
</div>
|
||||
<div class="card ping-card">
|
||||
<div id="dns-rows"
|
||||
hx-get="/dns/rows"
|
||||
hx-trigger="every {{ poll_interval }}s"
|
||||
hx-swap="innerHTML">
|
||||
{% include "dns/rows.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% if hosts %}
|
||||
{% for host in hosts %}
|
||||
{% set d = latest.get(host.id) %}
|
||||
<div class="ping-row">
|
||||
<div class="ping-meta">
|
||||
<span class="ping-name">{{ host.name }}</span>
|
||||
<span class="ping-addr">{{ host.address }}</span>
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
{% if d is none %}
|
||||
<span style="color:var(--text-dim);font-size:0.85rem;">—</span>
|
||||
{% elif d.status.value == 'resolved' %}
|
||||
<span class="dot dot-up" style="margin-right:0.5rem;"></span>
|
||||
<span style="font-size:0.85rem;color:var(--green);">{{ d.resolved_ip or 'resolved' }}</span>
|
||||
{% else %}
|
||||
<span class="dot dot-down" style="margin-right:0.5rem;"></span>
|
||||
<span style="font-size:0.85rem;color:var(--red);">Failed</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ping-cur">
|
||||
{% if d %}
|
||||
<span style="color:var(--text-muted);font-size:0.75rem;">{{ d.resolved_at.strftime('%H:%M:%S') }} UTC</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty" style="padding:0.5rem 0;">No DNS-enabled hosts. <a href="/hosts/">Enable DNS on a host →</a></p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if host %}Edit Host{% else %}New Host{% endif %} — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="max-width:560px;margin:2rem auto;">
|
||||
<h1 class="page-title">{% if host %}Edit Host{% else %}Add Host{% endif %}</h1>
|
||||
<div class="card">
|
||||
<form method="post" action="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}">
|
||||
<div class="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" name="name" value="{{ host.name if host else '' }}" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Address (hostname or IP)</label>
|
||||
<input type="text" name="address" value="{{ host.address if host else '' }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Probe Type</label>
|
||||
<select name="probe_type">
|
||||
{% for pt in probe_types %}
|
||||
<option value="{{ pt.value }}" {% if host and host.probe_type == pt %}selected{% endif %}>
|
||||
{{ pt.value | upper }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>TCP Port (used for TCP probe; ignored for ICMP)</label>
|
||||
<input type="number" name="probe_port" value="{{ host.probe_port if host else 80 }}" min="1" max="65535">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
|
||||
<input type="checkbox" name="ping_enabled" {% if not host or host.ping_enabled %}checked{% endif %}>
|
||||
Enable ping monitor
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
|
||||
<input type="checkbox" name="dns_enabled" {% if host and host.dns_enabled %}checked{% endif %}>
|
||||
Enable DNS monitor
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Expected IP (optional — leave blank to accept any)</label>
|
||||
<input type="text" name="dns_expected_ip" value="{{ host.dns_expected_ip if host and host.dns_expected_ip else '' }}">
|
||||
</div>
|
||||
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
|
||||
<button type="submit" class="btn">{% if host %}Save{% else %}Add Host{% endif %}</button>
|
||||
<a href="/hosts/" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Hosts — Fabled Scryer{% endblock %}
|
||||
{% 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;">Hosts</h1>
|
||||
<a class="btn" href="/hosts/new">Add Host</a>
|
||||
</div>
|
||||
{% if hosts %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Address</th>
|
||||
<th>Probe</th>
|
||||
<th style="text-align:center;">Ping</th>
|
||||
<th style="text-align:center;">Latency</th>
|
||||
<th style="text-align:center;">DNS</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for host in hosts %}
|
||||
{% set ping = latest_pings.get(host.id) %}
|
||||
{% set dns = latest_dns.get(host.id) %}
|
||||
<tr>
|
||||
<td>{{ host.name }}</td>
|
||||
<td style="color:var(--text-muted);">{{ host.address }}</td>
|
||||
<td style="color:var(--text-muted);font-size:0.85rem;">
|
||||
{{ host.probe_type.value | upper }}{% if host.probe_type.value == 'tcp' %}:{{ host.probe_port }}{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
{% if not host.ping_enabled %}
|
||||
<span style="color:var(--text-dim);font-size:0.8rem;">off</span>
|
||||
{% elif ping is none %}
|
||||
<span style="color:var(--text-muted);font-size:0.8rem;">—</span>
|
||||
{% elif ping.status.value == 'up' %}
|
||||
<span class="dot dot-up" title="Up"></span>
|
||||
{% else %}
|
||||
<span class="dot dot-down" title="Down"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;font-size:0.9rem;">
|
||||
{% if ping and ping.response_time_ms is not none %}
|
||||
{% if ping.response_time_ms < 10 %}
|
||||
<span style="color:var(--green);">{{ ping.response_time_ms | round(1) }} ms</span>
|
||||
{% elif ping.response_time_ms < 100 %}
|
||||
<span style="color:var(--yellow);">{{ ping.response_time_ms | round(1) }} ms</span>
|
||||
{% else %}
|
||||
<span style="color:var(--orange);">{{ ping.response_time_ms | round(1) }} ms</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span style="color:var(--text-dim);">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
{% if not host.dns_enabled %}
|
||||
<span style="color:var(--text-dim);font-size:0.8rem;">off</span>
|
||||
{% elif dns is none %}
|
||||
<span style="color:var(--text-muted);font-size:0.8rem;">—</span>
|
||||
{% elif dns.status.value == 'resolved' %}
|
||||
<span class="dot dot-up" title="{{ dns.resolved_ip }}"></span>
|
||||
{% else %}
|
||||
<span class="dot dot-down" title="Failed"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="td-actions">
|
||||
<a href="/hosts/{{ host.id }}/edit" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a>
|
||||
<form method="post" action="/hosts/{{ host.id }}/delete" style="display:inline;">
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete {{ host.name }}?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<p style="color:var(--text-muted);">No hosts configured yet. <a href="/hosts/new">Add one.</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ping Monitor — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.25rem;">
|
||||
<div style="display:flex;align-items:baseline;gap:1rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Ping Monitor</h1>
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
|
||||
</div>
|
||||
{% include "_time_range.html" %}
|
||||
</div>
|
||||
|
||||
{# ── Threshold settings ─────────────────────────────────────────────────── #}
|
||||
<div class="card" style="margin-bottom:1.25rem;">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Latency Thresholds</h2>
|
||||
<form method="post" action="/ping/settings"
|
||||
style="display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-end;">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#1a6632;vertical-align:middle;margin-right:4px;"></span>
|
||||
Good below (ms)
|
||||
</label>
|
||||
<input type="number" name="good_ms" value="{{ good_ms }}" min="1" max="9999" style="width:100px;">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#6b5c00;vertical-align:middle;margin-right:4px;"></span>
|
||||
Warn above (ms)
|
||||
</label>
|
||||
<input type="number" name="warn_ms" value="{{ warn_ms }}" min="1" max="9999" style="width:100px;">
|
||||
</div>
|
||||
<button type="submit" class="btn" style="padding:0.4rem 1rem;">Save</button>
|
||||
<span style="color:#505070;font-size:0.8rem;align-self:center;">
|
||||
Above warn = <span style="color:#c06020;">■</span> orange | No response = <span style="color:#c03030;">■</span> red
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# ── Live host rows ─────────────────────────────────────────────────────── #}
|
||||
<div class="card ping-card">
|
||||
<div id="ping-rows"
|
||||
hx-get="/ping/rows"
|
||||
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
|
||||
hx-include="#time-range"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{# HTMX fragment — included in both /ping/ page and dashboard widget #}
|
||||
{% macro pill_bg(p) %}
|
||||
{%- if p is none or p.status.value == 'down' or p.response_time_ms is none -%}
|
||||
#6a1515
|
||||
{%- elif p.response_time_ms <= good_ms -%}
|
||||
#1a6632
|
||||
{%- elif p.response_time_ms <= warn_ms -%}
|
||||
#6b5c00
|
||||
{%- else -%}
|
||||
#7a3800
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% macro pill_title(p) %}
|
||||
{%- if p is none -%}No data
|
||||
{%- elif p.status.value == 'down' -%}Down — {{ p.probed_at.strftime('%H:%M:%S') }} UTC
|
||||
{%- else -%}{{ "%.1f"|format(p.response_time_ms) }} ms — {{ p.probed_at.strftime('%H:%M:%S') }} UTC
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% if hosts %}
|
||||
{% for host in hosts %}
|
||||
{% set host_pings = pings_by_host.get(host.id, []) %}
|
||||
{% set last = host_pings[-1] if host_pings else none %}
|
||||
<div class="ping-row">
|
||||
<div class="ping-meta">
|
||||
<span class="ping-name">{{ host.name }}</span>
|
||||
<span class="ping-addr">{{ host.address }}</span>
|
||||
</div>
|
||||
<div class="ping-pills">
|
||||
{% for _ in range([30 - host_pings|length, 0]|max) %}
|
||||
<span class="pill pill-empty" title="No data"></span>
|
||||
{% endfor %}
|
||||
{% for p in host_pings %}
|
||||
<span class="pill" style="background:{{ pill_bg(p) }};" title="{{ pill_title(p) }}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="ping-cur">
|
||||
{% if last is none %}
|
||||
<span class="ping-cur-nd">—</span>
|
||||
{% elif last.status.value == 'down' or last.response_time_ms is none %}
|
||||
<span class="ping-cur-down">DOWN</span>
|
||||
{% elif last.response_time_ms <= good_ms %}
|
||||
<span class="ping-cur-good">{{ "%.1f"|format(last.response_time_ms) }} ms</span>
|
||||
{% elif last.response_time_ms <= warn_ms %}
|
||||
<span class="ping-cur-warn">{{ "%.1f"|format(last.response_time_ms) }} ms</span>
|
||||
{% else %}
|
||||
<span class="ping-cur-bad">{{ "%.1f"|format(last.response_time_ms) }} ms</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if uptime_pcts is defined %}
|
||||
{% set pct = uptime_pcts.get(host.id) %}
|
||||
<div style="min-width:4.5rem;text-align:right;font-size:0.78rem;font-variant-numeric:tabular-nums;color:
|
||||
{% if pct is none %}var(--text-dim)
|
||||
{% elif pct >= 99 %}var(--green)
|
||||
{% elif pct >= 95 %}var(--yellow)
|
||||
{% else %}var(--red){% endif %};">
|
||||
{% if pct is not none %}{{ "%.1f"|format(pct) }}%{% else %}—{% endif %}
|
||||
<span style="color:var(--text-dim);font-size:0.7rem;margin-left:0.15rem;">{{ range_key }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color:#505070;font-size:0.85rem;padding:0.5rem 0;">No ping-enabled hosts. <a href="/hosts/" style="color:#7070c0;">Add hosts →</a></p>
|
||||
{% endif %}
|
||||
@@ -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