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:
@@ -34,6 +34,37 @@ updated: "2026-03-22"
|
||||
|
||||
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
|
||||
version: "1.0.0"
|
||||
description: "Traefik reverse proxy metrics and access log integration"
|
||||
|
||||
@@ -1,15 +1,57 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
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 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
|
||||
|
||||
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
|
||||
|
||||
_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("/")
|
||||
@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)
|
||||
|
||||
|
||||
@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")
|
||||
@require_role(UserRole.operator)
|
||||
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")
|
||||
@@ -67,21 +168,146 @@ async def create_rule():
|
||||
await db.flush()
|
||||
state.rule_id = rule.id
|
||||
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"))
|
||||
|
||||
|
||||
@alerts_bp.post("/rules/<rule_id>/delete")
|
||||
@require_role(UserRole.admin)
|
||||
async def delete_rule(rule_id: str):
|
||||
rule_name = None
|
||||
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:
|
||||
rule_name = rule.name
|
||||
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"))
|
||||
|
||||
|
||||
@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")
|
||||
@require_role(UserRole.operator)
|
||||
async def acknowledge(rule_id: str):
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from urllib.parse import urlencode
|
||||
from quart import Blueprint, render_template, current_app, abort, redirect, request, session
|
||||
from sqlalchemy import select, func, update
|
||||
from fabledscryer.auth.middleware import require_role, current_user_id
|
||||
@@ -146,7 +148,22 @@ def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]:
|
||||
continue
|
||||
if defn["plugin"] and not plugins_cfg.get(defn["plugin"], {}).get("enabled", False):
|
||||
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
|
||||
|
||||
|
||||
@@ -160,13 +177,12 @@ async def _get_panels_data(db, dashboard_id: int) -> tuple:
|
||||
widgets = await _get_widgets(db, dashboard_id)
|
||||
plugins_cfg = current_app.config.get("PLUGINS", {})
|
||||
available = get_available_widgets(plugins_cfg)
|
||||
placed_keys = {w.widget_key for w in widgets}
|
||||
addable = [w for w in available if w["key"] not in placed_keys]
|
||||
# Multiple instances of the same widget type are allowed — show all available
|
||||
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
|
||||
]
|
||||
return dash, resolved, addable
|
||||
return dash, resolved, available
|
||||
|
||||
|
||||
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):
|
||||
user_id = current_user_id()
|
||||
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)
|
||||
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 db.begin():
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None or not _can_edit(dash, user_id, user_role):
|
||||
abort(403)
|
||||
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
|
||||
db.add(DashboardWidget(
|
||||
dashboard_id=dash_id, widget_key=widget_key, position=next_pos,
|
||||
))
|
||||
widgets = await _get_widgets(db, dash_id)
|
||||
next_pos = max((w.position for w in widgets), default=-1) + 1
|
||||
db.add(DashboardWidget(
|
||||
dashboard_id=dash_id, widget_key=widget_key, position=next_pos,
|
||||
title=title, config_json=config_json,
|
||||
))
|
||||
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)
|
||||
|
||||
|
||||
@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)
|
||||
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_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)
|
||||
order: list[int] = data["order"]
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||
dash = result.scalar_one_or_none()
|
||||
if dash is None or not _can_edit(dash, user_id, user_role):
|
||||
abort(403)
|
||||
widgets = await _get_widgets(db, dash_id)
|
||||
idx = next((i for i, w in enumerate(widgets) if w.id == widget_id), None)
|
||||
if idx is not None:
|
||||
swap_idx = (idx - 1) if direction == "up" else (idx + 1)
|
||||
if 0 <= swap_idx < len(widgets):
|
||||
widgets[idx].position, widgets[swap_idx].position = (
|
||||
widgets[swap_idx].position, widgets[idx].position
|
||||
)
|
||||
return await _panels_response(dash_id)
|
||||
widgets = await _get_widgets(db, dash_id)
|
||||
widget_map = {w.id: w for w in widgets}
|
||||
for pos, wid in enumerate(order):
|
||||
if wid in widget_map:
|
||||
widget_map[wid].position = pos
|
||||
return ("", 204)
|
||||
|
||||
|
||||
# ── Share tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<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>
|
||||
<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.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">
|
||||
@@ -214,12 +215,14 @@ textarea { resize: vertical; }
|
||||
</a>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/hosts/">Hosts</a>
|
||||
<a href="/hosts/uptime">Uptime</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>
|
||||
<a href="/audit/">Audit</a>
|
||||
{% endif %}
|
||||
<a href="/logout" class="nav-end">{{ session.username }}</a>
|
||||
</nav>
|
||||
@@ -351,5 +354,7 @@ function setTimeRange(val) {
|
||||
}());
|
||||
</script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
{# 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 ───────────────────────────────────────────────────── #}
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Current Layout</div>
|
||||
{% 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 %}
|
||||
<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;">
|
||||
<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 class="drag-handle" style="flex-shrink:0;cursor:grab;color:var(--text-muted);
|
||||
font-size:1.1rem;line-height:1;user-select:none;"
|
||||
title="Drag to reorder">⠿</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -47,22 +39,67 @@
|
||||
{% 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 class="card" style="margin-bottom:0;padding:0;overflow:hidden;">
|
||||
<details>
|
||||
<summary style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;
|
||||
padding:0.85rem 1rem;cursor:pointer;list-style:none;user-select:none;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<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>
|
||||
<span class="btn btn-sm" style="flex-shrink:0;pointer-events:none;">Add ▾</span>
|
||||
</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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -7,3 +7,37 @@
|
||||
</div>
|
||||
{% include "dashboard/_edit_panels.html" %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -66,11 +66,11 @@
|
||||
{% 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>
|
||||
<span class="widget-title">{{ item.title }}</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-get="{{ item.hx_url }}"
|
||||
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<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>
|
||||
<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.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">
|
||||
@@ -75,11 +76,11 @@
|
||||
{% 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>
|
||||
<span class="widget-title">{{ item.title }}</span>
|
||||
</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 }}"
|
||||
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-swap="innerHTML">
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user