diff --git a/docs/plugins/index.yaml.example b/docs/plugins/index.yaml.example index ec0c6d2..62e6783 100644 --- a/docs/plugins/index.yaml.example +++ b/docs/plugins/index.yaml.example @@ -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" diff --git a/fabledscryer/alerts/routes.py b/fabledscryer/alerts/routes.py index 368338e..db025c7 100644 --- a/fabledscryer/alerts/routes.py +++ b/fabledscryer/alerts/routes.py @@ -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//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/") +@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//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//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//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("//acknowledge") @require_role(UserRole.operator) async def acknowledge(rule_id: str): diff --git a/fabledscryer/dashboard/routes.py b/fabledscryer/dashboard/routes.py index 071fd7c..faeba86 100644 --- a/fabledscryer/dashboard/routes.py +++ b/fabledscryer/dashboard/routes.py @@ -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//edit/move//") +@dashboard_bp.post("/d//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 ────────────────────────────────────────────────────────────── diff --git a/fabledscryer/templates/base.html b/fabledscryer/templates/base.html index 585852f..d03d7eb 100644 --- a/fabledscryer/templates/base.html +++ b/fabledscryer/templates/base.html @@ -5,6 +5,7 @@ {% block title %}Fabled Scryer{% endblock %} + @@ -214,12 +215,14 @@ textarea { resize: vertical; } Dashboard Hosts + Uptime Ping DNS Alerts Ansible {% if session.user_role == 'admin' %} Settings + Audit {% endif %} {{ session.username }} @@ -351,5 +354,7 @@ function setTimeRange(val) { }()); +{% block extra_scripts %}{% endblock %} + diff --git a/fabledscryer/templates/dashboard/_edit_panels.html b/fabledscryer/templates/dashboard/_edit_panels.html index 2800361..ddc48fd 100644 --- a/fabledscryer/templates/dashboard/_edit_panels.html +++ b/fabledscryer/templates/dashboard/_edit_panels.html @@ -1,29 +1,21 @@ {# dashboard/_edit_panels.html — returned by all mutation routes and included by edit.html #} -
+
{# ── Current layout ───────────────────────────────────────────────────── #}
Current Layout
{% if widgets %} -
+
{% for item in widgets %} -
+
-
- - -
+
-
{{ item.defn.label }}
+
{{ item.title }}
{{ item.defn.description }}
@@ -47,22 +39,67 @@ {% if addable %}
{% for w in addable %} -
-
-
-
{{ w.label }}
-
{{ w.description }}
-
- -
+
+
+ +
+
{{ w.label }}
+
{{ w.description }}
+
+ Add ▾ +
+ +
+
+ +
+ + +
+ + {% for p in w.params %} +
+ + {% if p.type == "select" %} + + {% endif %} +
+ {% endfor %} + + +
+
+
{% endfor %}
{% else %}
- All available widgets are on this dashboard. + No plugin widgets available. Enable plugins in Settings → Plugins.
{% endif %}
diff --git a/fabledscryer/templates/dashboard/edit.html b/fabledscryer/templates/dashboard/edit.html index 3140102..1ef9d61 100644 --- a/fabledscryer/templates/dashboard/edit.html +++ b/fabledscryer/templates/dashboard/edit.html @@ -7,3 +7,37 @@
{% include "dashboard/_edit_panels.html" %} {% endblock %} +{% block extra_scripts %} + + + +{% endblock %} diff --git a/fabledscryer/templates/dashboard/index.html b/fabledscryer/templates/dashboard/index.html index 7247466..598f9d4 100644 --- a/fabledscryer/templates/dashboard/index.html +++ b/fabledscryer/templates/dashboard/index.html @@ -66,11 +66,11 @@ {% for item in widgets %}
- {{ item.defn.label }} + {{ item.title }} Details →
diff --git a/fabledscryer/templates/dashboard/share_view.html b/fabledscryer/templates/dashboard/share_view.html index 9bc3cb9..198cf6a 100644 --- a/fabledscryer/templates/dashboard/share_view.html +++ b/fabledscryer/templates/dashboard/share_view.html @@ -5,6 +5,7 @@ {{ dashboard.name }} — Fabled Scryer + @@ -75,11 +76,11 @@ {% for item in widgets %}
- {{ item.defn.label }} + {{ item.title }}
- {# Pass share token as ?s= param so require_role allows through #} + {# Pass share token as &s= param so require_role allows through #}