feat: drag-and-drop dashboard editing, alert dynamic fields

- Replace up/down arrow reorder buttons on dashboard edit page with
  SortableJS drag handles; add POST /d/<id>/edit/reorder endpoint
  accepting ordered widget ID array
- Add {% block extra_scripts %} hook to base.html for page-specific JS
- Add dynamic alert rule field loading via HTMX partial (_rule_fields)
  so metric/threshold fields update when source is changed
- Add docs/plugins/index.yaml.example showing catalog index format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 08:16:04 -04:00
parent 93d5ed8fb0
commit edc9b752d2
8 changed files with 423 additions and 58 deletions
+31
View File
@@ -34,6 +34,37 @@ updated: "2026-03-22"
plugins: plugins:
- name: http
version: "1.0.0"
description: "Synthetic HTTP endpoint monitoring — status code, response time, content match, TLS expiry"
author: "FabledScryer"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/http"
download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/http-v1.0.0/http.zip"
checksum_sha256: ""
tags:
- monitoring
- http
- synthetic
- uptime
- name: docker
version: "1.0.0"
description: "Docker container status, resource usage, and restart tracking via Docker socket"
author: "FabledScryer"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/docker"
download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/docker-v1.0.0/docker.zip"
checksum_sha256: ""
tags:
- containers
- docker
- infrastructure
- name: traefik - name: traefik
version: "1.0.0" version: "1.0.0"
description: "Traefik reverse proxy metrics and access log integration" description: "Traefik reverse proxy metrics and access log integration"
+228 -2
View File
@@ -1,15 +1,57 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
from fabledscryer.core.audit import log_audit
from sqlalchemy import select from sqlalchemy import select
from fabledscryer.auth.middleware import require_role from fabledscryer.auth.middleware import require_role
from fabledscryer.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator from fabledscryer.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator, MaintenanceWindow
from fabledscryer.models.hosts import Host
from fabledscryer.models.metrics import PluginMetric
from fabledscryer.models.users import UserRole from fabledscryer.models.users import UserRole
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts") alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
_OPERATORS = [(op.value, op.value) for op in AlertOperator] _OPERATORS = [(op.value, op.value) for op in AlertOperator]
# Static catalog: source_module → available metric names
METRIC_CATALOG: dict[str, list[str]] = {
"ping": ["up", "response_time_ms"],
"dns": ["resolved", "ip_changed"],
"traefik": ["request_rate", "error_rate", "latency_p50_ms", "latency_p95_ms",
"latency_p99_ms", "response_bytes_rate", "cert_expiry_days"],
"unifi": ["is_up", "latency_ms", "total_clients"],
"ups": ["battery_charge_pct", "on_battery", "battery_runtime_secs"],
"docker": ["cpu_pct", "mem_pct"],
"http": ["is_up", "response_ms"],
# snmp metrics are user-defined OID labels — populated dynamically from plugin_metrics
"snmp": [],
}
# Ordered list for source module picker
_SOURCE_MODULES = list(METRIC_CATALOG.keys())
async def _get_resources(db, source_module: str) -> list[str]:
"""Return sorted distinct resource names for a source module.
For ping/dns, supplements with host names so the list is populated
even before any metrics have been recorded.
"""
result = await db.execute(
select(PluginMetric.resource_name)
.where(PluginMetric.source_module == source_module)
.distinct()
.order_by(PluginMetric.resource_name)
)
resources: set[str] = {r for (r,) in result}
if source_module in ("ping", "dns"):
host_result = await db.execute(select(Host.name).order_by(Host.name))
for (name,) in host_result:
resources.add(name)
return sorted(resources)
@alerts_bp.get("/") @alerts_bp.get("/")
@require_role(UserRole.viewer) @require_role(UserRole.viewer)
@@ -33,10 +75,69 @@ async def list_alerts():
return await render_template("alerts/list.html", active=active, rules=rules, operators=_OPERATORS) return await render_template("alerts/list.html", active=active, rules=rules, operators=_OPERATORS)
@alerts_bp.get("/api/rule_fields")
@require_role(UserRole.viewer)
async def rule_fields_api():
source = request.args.get("source_module", "")
current_resource = request.args.get("current_resource", "")
current_metric = request.args.get("current_metric", "")
async with current_app.db_sessionmaker() as db:
resources = await _get_resources(db, source) if source else []
metrics = METRIC_CATALOG.get(source, [])
# Dynamic sources (e.g. snmp) have empty static catalog — query live metric names
if source and not metrics:
async with current_app.db_sessionmaker() as db:
m_result = await db.execute(
select(PluginMetric.metric_name)
.where(PluginMetric.source_module == source)
.distinct()
.order_by(PluginMetric.metric_name)
)
metrics = [r for (r,) in m_result]
return await render_template(
"alerts/_rule_fields.html",
resources=resources,
metrics=metrics,
current_resource=current_resource,
current_metric=current_metric,
)
@alerts_bp.get("/rules/new") @alerts_bp.get("/rules/new")
@require_role(UserRole.operator) @require_role(UserRole.operator)
async def new_rule(): async def new_rule():
return await render_template("alerts/rules_form.html", rule=None, operators=_OPERATORS) first_source = _SOURCE_MODULES[0]
async with current_app.db_sessionmaker() as db:
resources = await _get_resources(db, first_source)
return await render_template(
"alerts/rules_form.html",
rule=None,
operators=_OPERATORS,
source_modules=_SOURCE_MODULES,
initial_source=first_source,
resources=resources,
metrics=METRIC_CATALOG.get(first_source, []),
)
@alerts_bp.get("/rules/<rule_id>/edit")
@require_role(UserRole.operator)
async def edit_rule(rule_id: str):
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if rule is None:
return "Not found", 404
resources = await _get_resources(db, rule.source_module)
return await render_template(
"alerts/rules_form.html",
rule=rule,
operators=_OPERATORS,
source_modules=_SOURCE_MODULES,
initial_source=rule.source_module,
resources=resources,
metrics=METRIC_CATALOG.get(rule.source_module, []),
)
@alerts_bp.post("/rules") @alerts_bp.post("/rules")
@@ -67,21 +168,146 @@ async def create_rule():
await db.flush() await db.flush()
state.rule_id = rule.id state.rule_id = rule.id
db.add(state) db.add(state)
await log_audit(current_app, user_id, session.get("username", ""), "alert_rule.created",
entity_type="alert_rule", entity_id=rule.name,
detail={"source": rule.source_module, "resource": rule.resource_name,
"metric": rule.metric_name})
return redirect(url_for("alerts.list_alerts"))
@alerts_bp.post("/rules/<rule_id>")
@require_role(UserRole.operator)
async def update_rule(rule_id: str):
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if rule is None:
return "Not found", 404
rule.name = form["name"].strip()
rule.source_module = form["source_module"].strip()
rule.resource_name = form["resource_name"].strip()
rule.metric_name = form["metric_name"].strip()
rule.operator = AlertOperator(form["operator"])
rule.threshold = float(form["threshold"])
rule.consecutive_failures_required = int(form.get("consecutive_failures_required") or 1)
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"alert_rule.updated", entity_type="alert_rule", entity_id=rule.name)
return redirect(url_for("alerts.list_alerts"))
@alerts_bp.post("/rules/<rule_id>/toggle")
@require_role(UserRole.operator)
async def toggle_rule(rule_id: str):
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if rule is None:
return "Not found", 404
rule.enabled = not rule.enabled
action = "alert_rule.enabled" if rule.enabled else "alert_rule.disabled"
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
action, entity_type="alert_rule", entity_id=rule.name)
return redirect(url_for("alerts.list_alerts")) return redirect(url_for("alerts.list_alerts"))
@alerts_bp.post("/rules/<rule_id>/delete") @alerts_bp.post("/rules/<rule_id>/delete")
@require_role(UserRole.admin) @require_role(UserRole.admin)
async def delete_rule(rule_id: str): async def delete_rule(rule_id: str):
rule_name = None
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
async with db.begin(): async with db.begin():
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id)) result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none() rule = result.scalar_one_or_none()
if rule: if rule:
rule_name = rule.name
await db.delete(rule) await db.delete(rule)
if rule_name:
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"alert_rule.deleted", entity_type="alert_rule", entity_id=rule_name)
return redirect(url_for("alerts.list_alerts")) return redirect(url_for("alerts.list_alerts"))
@alerts_bp.get("/maintenance")
@require_role(UserRole.viewer)
async def list_maintenance():
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(MaintenanceWindow).order_by(MaintenanceWindow.start_at.desc())
)
windows = result.scalars().all()
return await render_template(
"alerts/maintenance.html",
windows=windows,
now=now,
source_modules=_SOURCE_MODULES,
)
@alerts_bp.get("/maintenance/new")
@require_role(UserRole.operator)
async def new_maintenance():
return await render_template(
"alerts/maintenance_form.html",
window=None,
source_modules=_SOURCE_MODULES,
)
@alerts_bp.post("/maintenance")
@require_role(UserRole.operator)
async def create_maintenance():
form = await request.form
user_id = session.get("user_id")
start_at = datetime.fromisoformat(form["start_at"]).replace(tzinfo=timezone.utc)
end_at = datetime.fromisoformat(form["end_at"]).replace(tzinfo=timezone.utc)
scope_source = form.get("scope_source", "").strip() or None
scope_resource = form.get("scope_resource", "").strip() or None
if scope_source is None:
scope_resource = None # can't have resource without source
window = MaintenanceWindow(
name=form["name"].strip(),
start_at=start_at,
end_at=end_at,
scope_source=scope_source,
scope_resource=scope_resource,
created_by=user_id,
)
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(window)
scope_desc = "all" if not scope_source else (
f"{scope_source}/{scope_resource}" if scope_resource else f"{scope_source}/*"
)
await log_audit(current_app, user_id, session.get("username", ""),
"maintenance_window.created", entity_type="maintenance_window",
entity_id=window.name, detail={"scope": scope_desc})
return redirect(url_for("alerts.list_maintenance"))
@alerts_bp.post("/maintenance/<window_id>/delete")
@require_role(UserRole.operator)
async def delete_maintenance(window_id: str):
window_name = None
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(
select(MaintenanceWindow).where(MaintenanceWindow.id == window_id)
)
window = result.scalar_one_or_none()
if window:
window_name = window.name
await db.delete(window)
if window_name:
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"maintenance_window.deleted", entity_type="maintenance_window",
entity_id=window_name)
return redirect(url_for("alerts.list_maintenance"))
@alerts_bp.post("/<rule_id>/acknowledge") @alerts_bp.post("/<rule_id>/acknowledge")
@require_role(UserRole.operator) @require_role(UserRole.operator)
async def acknowledge(rule_id: str): async def acknowledge(rule_id: str):
+55 -24
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json
import secrets import secrets
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from urllib.parse import urlencode
from quart import Blueprint, render_template, current_app, abort, redirect, request, session from quart import Blueprint, render_template, current_app, abort, redirect, request, session
from sqlalchemy import select, func, update from sqlalchemy import select, func, update
from fabledscryer.auth.middleware import require_role, current_user_id from fabledscryer.auth.middleware import require_role, current_user_id
@@ -146,7 +148,22 @@ def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]:
continue continue
if defn["plugin"] and not plugins_cfg.get(defn["plugin"], {}).get("enabled", False): if defn["plugin"] and not plugins_cfg.get(defn["plugin"], {}).get("enabled", False):
continue continue
out.append({"db": w, "defn": defn}) # Build stored config (falls back to empty; defaults live in the registry)
config: dict = {}
if w.config_json:
try:
config = json.loads(w.config_json)
except (ValueError, TypeError):
pass
# Always append wid so widget templates can create unique canvas IDs
url_params = {**config, "wid": w.id}
hx_url = defn["hx_url"] + "?" + urlencode(url_params)
out.append({
"db": w,
"defn": defn,
"hx_url": hx_url,
"title": w.title or defn["label"],
})
return out return out
@@ -160,13 +177,12 @@ async def _get_panels_data(db, dashboard_id: int) -> tuple:
widgets = await _get_widgets(db, dashboard_id) widgets = await _get_widgets(db, dashboard_id)
plugins_cfg = current_app.config.get("PLUGINS", {}) plugins_cfg = current_app.config.get("PLUGINS", {})
available = get_available_widgets(plugins_cfg) available = get_available_widgets(plugins_cfg)
placed_keys = {w.widget_key for w in widgets} # Multiple instances of the same widget type are allowed — show all available
addable = [w for w in available if w["key"] not in placed_keys]
resolved = [ resolved = [
{"db": w, "defn": WIDGET_REGISTRY[w.widget_key]} {"db": w, "defn": WIDGET_REGISTRY[w.widget_key], "title": w.title or WIDGET_REGISTRY[w.widget_key]["label"]}
for w in widgets if w.widget_key in WIDGET_REGISTRY for w in widgets if w.widget_key in WIDGET_REGISTRY
] ]
return dash, resolved, addable return dash, resolved, available
async def _panels_response(dashboard_id: int): async def _panels_response(dashboard_id: int):
@@ -367,20 +383,36 @@ async def edit(dash_id: int):
async def add_widget(dash_id: int, widget_key: str): async def add_widget(dash_id: int, widget_key: str):
user_id = current_user_id() user_id = current_user_id()
user_role = session.get("user_role", "viewer") user_role = session.get("user_role", "viewer")
if widget_key not in WIDGET_REGISTRY: defn = WIDGET_REGISTRY.get(widget_key)
if defn is None:
abort(400) abort(400)
form = await request.form
title = form.get("title", "").strip() or None
# Parse configured params
config: dict = {}
for p in defn.get("params", []):
val = form.get(f"param_{p['key']}")
if val is not None:
if isinstance(p["default"], int):
try:
config[p["key"]] = int(val)
except ValueError:
config[p["key"]] = p["default"]
else:
config[p["key"]] = val
config_json = json.dumps(config) if config else None
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
async with db.begin(): async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id)) result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none() dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role): if dash is None or not _can_edit(dash, user_id, user_role):
abort(403) abort(403)
widgets = await _get_widgets(db, dash_id) widgets = await _get_widgets(db, dash_id)
if not any(w.widget_key == widget_key for w in widgets): next_pos = max((w.position for w in widgets), default=-1) + 1
next_pos = max((w.position for w in widgets), default=-1) + 1 db.add(DashboardWidget(
db.add(DashboardWidget( dashboard_id=dash_id, widget_key=widget_key, position=next_pos,
dashboard_id=dash_id, widget_key=widget_key, position=next_pos, title=title, config_json=config_json,
)) ))
return await _panels_response(dash_id) return await _panels_response(dash_id)
@@ -405,28 +437,27 @@ async def remove_widget(dash_id: int, widget_id: int):
return await _panels_response(dash_id) return await _panels_response(dash_id)
@dashboard_bp.post("/d/<int:dash_id>/edit/move/<int:widget_id>/<direction>") @dashboard_bp.post("/d/<int:dash_id>/edit/reorder")
@require_role(UserRole.viewer) @require_role(UserRole.viewer)
async def move_widget(dash_id: int, widget_id: int, direction: str): async def reorder_widgets(dash_id: int):
user_id = current_user_id() user_id = current_user_id()
user_role = session.get("user_role", "viewer") user_role = session.get("user_role", "viewer")
if direction not in ("up", "down"): data = await request.get_json(silent=True)
if not data or not isinstance(data.get("order"), list):
abort(400) abort(400)
order: list[int] = data["order"]
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
async with db.begin(): async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id)) result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none() dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role): if dash is None or not _can_edit(dash, user_id, user_role):
abort(403) abort(403)
widgets = await _get_widgets(db, dash_id) widgets = await _get_widgets(db, dash_id)
idx = next((i for i, w in enumerate(widgets) if w.id == widget_id), None) widget_map = {w.id: w for w in widgets}
if idx is not None: for pos, wid in enumerate(order):
swap_idx = (idx - 1) if direction == "up" else (idx + 1) if wid in widget_map:
if 0 <= swap_idx < len(widgets): widget_map[wid].position = pos
widgets[idx].position, widgets[swap_idx].position = ( return ("", 204)
widgets[swap_idx].position, widgets[idx].position
)
return await _panels_response(dash_id)
# ── Share tokens ────────────────────────────────────────────────────────────── # ── Share tokens ──────────────────────────────────────────────────────────────
+5
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Fabled Scryer{% endblock %}</title> <title>{% block title %}Fabled Scryer{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script> <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Libertinus+Serif:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
@@ -214,12 +215,14 @@ textarea { resize: vertical; }
</a> </a>
<a href="/">Dashboard</a> <a href="/">Dashboard</a>
<a href="/hosts/">Hosts</a> <a href="/hosts/">Hosts</a>
<a href="/hosts/uptime">Uptime</a>
<a href="/ping/">Ping</a> <a href="/ping/">Ping</a>
<a href="/dns/">DNS</a> <a href="/dns/">DNS</a>
<a href="/alerts/">Alerts</a> <a href="/alerts/">Alerts</a>
<a href="/ansible/">Ansible</a> <a href="/ansible/">Ansible</a>
{% if session.user_role == 'admin' %} {% if session.user_role == 'admin' %}
<a href="/settings/">Settings</a> <a href="/settings/">Settings</a>
<a href="/audit/">Audit</a>
{% endif %} {% endif %}
<a href="/logout" class="nav-end">{{ session.username }}</a> <a href="/logout" class="nav-end">{{ session.username }}</a>
</nav> </nav>
@@ -351,5 +354,7 @@ function setTimeRange(val) {
}()); }());
</script> </script>
{% block extra_scripts %}{% endblock %}
</body> </body>
</html> </html>
@@ -1,29 +1,21 @@
{# dashboard/_edit_panels.html — returned by all mutation routes and included by edit.html #} {# 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;"> <div id="edit-panels" style="display:grid;grid-template-columns:1fr 340px;gap:1.5rem;align-items:start;">
{# ── Current layout ───────────────────────────────────────────────────── #} {# ── Current layout ───────────────────────────────────────────────────── #}
<div> <div>
<div class="section-title" style="margin-bottom:0.75rem;">Current Layout</div> <div class="section-title" style="margin-bottom:0.75rem;">Current Layout</div>
{% if widgets %} {% if widgets %}
<div style="display:grid;gap:0.5rem;"> <div id="widget-sort-list" data-dash-id="{{ dashboard.id }}" style="display:grid;gap:0.5rem;">
{% for item in widgets %} {% for item in widgets %}
<div class="card" style="margin-bottom:0;display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;"> <div class="card" data-widget-id="{{ item.db.id }}"
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;"> <div class="drag-handle" style="flex-shrink:0;cursor:grab;color:var(--text-muted);
<button hx-post="/d/{{ dashboard.id }}/edit/move/{{ item.db.id }}/up" font-size:1.1rem;line-height:1;user-select:none;"
hx-target="#edit-panels" hx-swap="outerHTML" title="Drag to reorder"></div>
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="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.9rem;">{{ item.defn.label }}</div> <div style="font-weight:600;font-size:0.9rem;">{{ item.title }}</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.1rem;">{{ item.defn.description }}</div> <div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.1rem;">{{ item.defn.description }}</div>
</div> </div>
@@ -47,22 +39,67 @@
{% if addable %} {% if addable %}
<div style="display:grid;gap:0.5rem;"> <div style="display:grid;gap:0.5rem;">
{% for w in addable %} {% for w in addable %}
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;"> <div class="card" style="margin-bottom:0;padding:0;overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;"> <details>
<div> <summary style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;
<div style="font-weight:600;font-size:0.875rem;">{{ w.label }}</div> padding:0.85rem 1rem;cursor:pointer;list-style:none;user-select:none;">
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;">{{ w.description }}</div> <div style="flex:1;min-width:0;">
</div> <div style="font-weight:600;font-size:0.875rem;">{{ w.label }}</div>
<button hx-post="/d/{{ dashboard.id }}/edit/add/{{ w.key }}" <div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;">{{ w.description }}</div>
hx-target="#edit-panels" hx-swap="outerHTML" </div>
class="btn btn-sm" style="flex-shrink:0;">Add</button> <span class="btn btn-sm" style="flex-shrink:0;pointer-events:none;">Add</span>
</div> </summary>
<form method="post"
action="/d/{{ dashboard.id }}/edit/add/{{ w.key }}"
hx-post="/d/{{ dashboard.id }}/edit/add/{{ w.key }}"
hx-target="#edit-panels"
hx-swap="outerHTML"
style="padding:0.75rem 1rem;padding-top:0;border-top:1px solid var(--border);">
<div style="display:grid;gap:0.5rem;padding-top:0.75rem;">
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">
Widget title <span style="color:var(--text-dim);">(optional)</span>
</label>
<input type="text" name="title" placeholder="{{ w.label }}"
style="width:100%;padding:0.32rem 0.6rem;background:var(--bg);
border:1px solid var(--border-mid);border-radius:4px;
color:var(--text);font-size:0.85rem;">
</div>
{% for p in w.params %}
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">
{{ p.label }}
</label>
{% if p.type == "select" %}
<select name="param_{{ p.key }}"
style="width:100%;padding:0.32rem 0.6rem;background:var(--bg);
border:1px solid var(--border-mid);border-radius:4px;
color:var(--text);font-size:0.85rem;">
{% for opt in p.options %}
<option value="{{ opt.value }}" {% if opt.value == p.default %}selected{% endif %}>
{{ opt.label }}
</option>
{% endfor %}
</select>
{% endif %}
</div>
{% endfor %}
<button type="submit" class="btn btn-sm" style="width:100%;margin-top:0.1rem;">
Add to Dashboard
</button>
</div>
</form>
</details>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:1rem 0;"> <div style="color:var(--text-muted);font-size:0.85rem;padding:1rem 0;">
All available widgets are on this dashboard. No plugin widgets available. Enable plugins in Settings → Plugins.
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -7,3 +7,37 @@
</div> </div>
{% include "dashboard/_edit_panels.html" %} {% include "dashboard/_edit_panels.html" %}
{% endblock %} {% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
<script>
(function () {
function initSortable() {
var el = document.getElementById('widget-sort-list');
if (!el) return;
var dashId = el.dataset.dashId;
Sortable.create(el, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
onEnd: function () {
var order = Array.from(el.querySelectorAll('[data-widget-id]'))
.map(function (row) { return parseInt(row.dataset.widgetId, 10); });
fetch('/d/' + dashId + '/edit/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: order }),
});
},
});
}
// Init on first load and after every HTMX swap (add/remove re-renders the list)
document.addEventListener('DOMContentLoaded', initSortable);
document.addEventListener('htmx:afterSwap', initSortable);
})();
</script>
<style>
.sortable-ghost { opacity: 0.4; }
.drag-handle:active { cursor: grabbing; }
</style>
{% endblock %}
+2 -2
View File
@@ -66,11 +66,11 @@
{% for item in widgets %} {% for item in widgets %}
<div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;"> <div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="widget-header"> <div class="widget-header">
<span class="widget-title">{{ item.defn.label }}</span> <span class="widget-title">{{ item.title }}</span>
<a href="{{ item.defn.detail_url }}" class="widget-link">Details →</a> <a href="{{ item.defn.detail_url }}" class="widget-link">Details →</a>
</div> </div>
<div id="widget-{{ item.db.id }}" <div id="widget-{{ item.db.id }}"
hx-get="{{ item.defn.hx_url }}" hx-get="{{ item.hx_url }}"
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}" hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-swap="innerHTML"> hx-swap="innerHTML">
</div> </div>
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ dashboard.name }} — Fabled Scryer</title> <title>{{ dashboard.name }} — Fabled Scryer</title>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script> <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Libertinus+Serif:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
@@ -75,11 +76,11 @@
{% for item in widgets %} {% for item in widgets %}
<div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;"> <div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="widget-header"> <div class="widget-header">
<span class="widget-title">{{ item.defn.label }}</span> <span class="widget-title">{{ item.title }}</span>
</div> </div>
{# Pass share token as ?s= param so require_role allows through #} {# Pass share token as &s= param so require_role allows through #}
<div id="widget-{{ item.db.id }}" <div id="widget-{{ item.db.id }}"
hx-get="{{ item.defn.hx_url }}?s={{ raw_token }}" hx-get="{{ item.hx_url }}&s={{ raw_token }}"
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}" hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-swap="innerHTML"> hx-swap="innerHTML">
</div> </div>