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,80 @@
|
||||
# fabledscryer-plugins / index.yaml
|
||||
#
|
||||
# This file is the catalog index for the fabledscryer plugin repository.
|
||||
# It is fetched by the app's Settings → Plugins → Plugin Catalog UI.
|
||||
#
|
||||
# Fabled Scryer reads this file from:
|
||||
# https://raw.githubusercontent.com/bvandeusen/fabledscryer-plugins/main/index.yaml
|
||||
#
|
||||
# After adding or updating a plugin entry, commit and push — the change is
|
||||
# live immediately for anyone whose app fetches the catalog (cache TTL: 5 min).
|
||||
#
|
||||
# REQUIRED fields for each plugin entry:
|
||||
# name - must match the plugin's directory name exactly
|
||||
# version - semver string
|
||||
# download_url - direct URL to a zip file containing the plugin
|
||||
# checksum_sha256 - SHA-256 hex digest of the zip (leave empty to skip verification)
|
||||
#
|
||||
# OPTIONAL fields:
|
||||
# description, author, license, min_app_version,
|
||||
# repository_url, homepage, tags
|
||||
#
|
||||
# Generating a checksum:
|
||||
# sha256sum traefik.zip
|
||||
# shasum -a 256 traefik.zip (macOS)
|
||||
#
|
||||
# Download URL conventions:
|
||||
# GitHub release assets (recommended):
|
||||
# https://github.com/bvandeusen/fabledscryer-plugins/releases/download/traefik-v1.0.0/traefik.zip
|
||||
# GitHub source archive of a subdirectory tag is not directly supported;
|
||||
# use release assets created by the publish workflow (see .github/workflows/publish.yml).
|
||||
|
||||
version: 1
|
||||
updated: "2026-03-22"
|
||||
|
||||
plugins:
|
||||
|
||||
- name: traefik
|
||||
version: "1.0.0"
|
||||
description: "Traefik reverse proxy metrics and access log integration"
|
||||
author: "FabledScryer"
|
||||
license: "MIT"
|
||||
min_app_version: "0.1.0"
|
||||
repository_url: "https://github.com/bvandeusen/fabledscryer-plugins"
|
||||
homepage: "https://github.com/bvandeusen/fabledscryer-plugins/tree/main/traefik"
|
||||
download_url: "https://github.com/bvandeusen/fabledscryer-plugins/releases/download/traefik-v1.0.0/traefik.zip"
|
||||
checksum_sha256: "" # fill in after running: sha256sum traefik.zip
|
||||
tags:
|
||||
- proxy
|
||||
- metrics
|
||||
- access-log
|
||||
|
||||
- name: unifi
|
||||
version: "1.0.0"
|
||||
description: "UniFi Network controller integration — WAN health, devices, clients, DPI"
|
||||
author: "FabledScryer"
|
||||
license: "MIT"
|
||||
min_app_version: "0.1.0"
|
||||
repository_url: "https://github.com/bvandeusen/fabledscryer-plugins"
|
||||
homepage: "https://github.com/bvandeusen/fabledscryer-plugins/tree/main/unifi"
|
||||
download_url: "https://github.com/bvandeusen/fabledscryer-plugins/releases/download/unifi-v1.0.0/unifi.zip"
|
||||
checksum_sha256: ""
|
||||
tags:
|
||||
- network
|
||||
- unifi
|
||||
- ubiquiti
|
||||
|
||||
- name: ups
|
||||
version: "1.0.0"
|
||||
description: "UPS monitoring via NUT (Network UPS Tools) with Ansible shutdown automation"
|
||||
author: "FabledScryer"
|
||||
license: "MIT"
|
||||
min_app_version: "0.1.0"
|
||||
repository_url: "https://github.com/bvandeusen/fabledscryer-plugins"
|
||||
homepage: "https://github.com/bvandeusen/fabledscryer-plugins/tree/main/ups"
|
||||
download_url: "https://github.com/bvandeusen/fabledscryer-plugins/releases/download/ups-v1.0.0/ups.zip"
|
||||
checksum_sha256: ""
|
||||
tags:
|
||||
- ups
|
||||
- power
|
||||
- nut
|
||||
@@ -0,0 +1,173 @@
|
||||
# Plugin System Overview
|
||||
|
||||
Plugins extend Fabled Scryer with new data sources, UI pages, scheduled tasks, and dashboard widgets. Only enabled plugins are imported — disabled or unlisted plugins have zero runtime overhead.
|
||||
|
||||
---
|
||||
|
||||
## How Plugins Are Loaded
|
||||
|
||||
Plugin loading happens in step 9 of `create_app()`, after all core blueprints and tasks are registered. The entrypoint is `load_plugins(app)` in `fabledscryer/core/plugin_manager.py`.
|
||||
|
||||
For each plugin listed as `enabled: true` in the `PLUGINS` config:
|
||||
|
||||
1. **Directory check** — `plugins/<name>/` must exist
|
||||
2. **`plugin.yaml` check** — file must exist and be valid YAML
|
||||
3. **Name validation** — `plugin.yaml` `name` field must match the directory name exactly
|
||||
4. **Version check** — if `min_app_version` is set, the running app version must be >= that value (uses SemVer comparison)
|
||||
5. **Config merge** — `plugin.yaml` `config` defaults are merged with user overrides from app settings; result stored in `app.config["PLUGINS"][name]`
|
||||
6. **Import** — `importlib.import_module(name)` imports the plugin package (the plugins directory is prepended to `sys.path`)
|
||||
7. **Export validation** — plugin must export `setup` and `get_scheduled_tasks`
|
||||
8. **`setup(app)`** called
|
||||
9. **Blueprint registration** — if plugin exports `get_blueprint()`, the returned blueprint is registered at `/plugins/<name>/`
|
||||
10. **Task registration** — `get_scheduled_tasks()` results are appended to `app._task_registry`
|
||||
|
||||
If any step fails, the plugin is skipped with an error log and startup continues.
|
||||
|
||||
---
|
||||
|
||||
## Plugin Directory Structure
|
||||
|
||||
```
|
||||
plugins/
|
||||
└── myplugin/
|
||||
├── __init__.py # Required: setup(), get_scheduled_tasks(), optionally get_blueprint()
|
||||
├── plugin.yaml # Required: name, version, description
|
||||
├── models.py # SQLAlchemy models (optional if plugin has no DB tables)
|
||||
├── routes.py # Quart Blueprint (optional if plugin has no UI)
|
||||
├── scheduler.py # Task logic (optional if plugin has no background tasks)
|
||||
├── migrations/ # Alembic migrations for plugin DB tables
|
||||
│ ├── env.py
|
||||
│ ├── script.py.mako
|
||||
│ └── versions/
|
||||
└── templates/
|
||||
└── myplugin/ # Jinja2 templates (namespaced under plugin name)
|
||||
├── index.html
|
||||
└── widget.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## plugin.yaml Schema
|
||||
|
||||
```yaml
|
||||
# Required
|
||||
name: myplugin # Must match directory name exactly
|
||||
version: "1.0.0" # SemVer
|
||||
description: "Does a thing"
|
||||
|
||||
# Optional
|
||||
author: "Your Name"
|
||||
min_app_version: "0.1.0" # Minimum Fabled Scryer version required
|
||||
|
||||
# Default config — merged with user overrides at runtime
|
||||
# Access at runtime via: app.config["PLUGINS"]["myplugin"]["my_setting"]
|
||||
config:
|
||||
my_setting: "default_value"
|
||||
poll_interval_seconds: 60
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Required Python Exports
|
||||
|
||||
Every plugin's `__init__.py` must export:
|
||||
|
||||
### `setup(app: Quart) -> None`
|
||||
|
||||
Called once during app startup. Use it to store the `app` reference for use in scheduled tasks, and to import models so they register with SQLAlchemy metadata.
|
||||
|
||||
```python
|
||||
_app = None
|
||||
|
||||
def setup(app):
|
||||
global _app
|
||||
_app = app
|
||||
from .models import MyModel # noqa: registers model with Base.metadata
|
||||
```
|
||||
|
||||
### `get_scheduled_tasks() -> list[ScheduledTask]`
|
||||
|
||||
Returns a list of `ScheduledTask` objects. Return `[]` if the plugin has no background tasks. Called after `setup()`, so any app references set in `setup()` are available.
|
||||
|
||||
```python
|
||||
from fabledscryer.core.scheduler import ScheduledTask
|
||||
|
||||
def get_scheduled_tasks():
|
||||
app = _app
|
||||
|
||||
async def my_task():
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
# do work
|
||||
pass
|
||||
|
||||
return [
|
||||
ScheduledTask(
|
||||
name="myplugin_task",
|
||||
coro_factory=my_task,
|
||||
interval_seconds=app.config["PLUGINS"]["myplugin"]["poll_interval_seconds"],
|
||||
run_on_startup=True,
|
||||
)
|
||||
]
|
||||
```
|
||||
|
||||
### `get_blueprint() -> Blueprint` (optional)
|
||||
|
||||
Return a Quart `Blueprint`. The plugin manager mounts it automatically at `/plugins/<name>/`. Do not set `url_prefix` on the blueprint itself.
|
||||
|
||||
```python
|
||||
def get_blueprint():
|
||||
from .routes import my_bp
|
||||
return my_bp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Migrations
|
||||
|
||||
Plugins manage their own Alembic migrations in `migrations/versions/`. Plugin migrations run after core migrations, so the core schema (including `app_settings`) is always available.
|
||||
|
||||
Each plugin's initial revision declares a dependency on the core migration head using `depends_on` (not `down_revision`), keeping the plugin on a separate migration branch:
|
||||
|
||||
```python
|
||||
# In the plugin's first migration file:
|
||||
depends_on = ("0004_core_head_revision_id",)
|
||||
down_revision = None
|
||||
branch_labels = ("myplugin",)
|
||||
```
|
||||
|
||||
Revision IDs should be prefixed with the plugin name to avoid collisions:
|
||||
|
||||
```
|
||||
myplugin_001_initial.py
|
||||
myplugin_002_add_column.py
|
||||
```
|
||||
|
||||
If a plugin is disabled after its migrations have been applied, its tables remain in the database. Re-enabling it skips already-applied migrations and resumes normally.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Widgets
|
||||
|
||||
A plugin can contribute a dashboard widget by:
|
||||
|
||||
1. Adding a `GET /widget` route to its blueprint that returns an HTMX HTML fragment
|
||||
2. The dashboard template polling that endpoint with HTMX
|
||||
|
||||
The dashboard (`fabledscryer/templates/dashboard/index.html`) currently includes the Traefik widget conditionally based on `traefik_enabled`. To add a new plugin widget, the dashboard route and template both need to be updated to detect and render the new widget. See the Traefik widget implementation as a reference.
|
||||
|
||||
---
|
||||
|
||||
## Available Context in Plugins
|
||||
|
||||
Inside scheduled tasks and request handlers, the following is available via `app` or `current_app`:
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|---|---|---|
|
||||
| `app.config["PLUGINS"]["myplugin"]` | dict | Merged plugin config (defaults + user overrides) |
|
||||
| `app.db_sessionmaker` | async_sessionmaker | Open DB sessions |
|
||||
| `app.logger` | Logger | Standard Python logger |
|
||||
| `app.config["SMTP"]` | dict | Email config |
|
||||
| `app.config["WEBHOOK"]` | dict | Webhook config |
|
||||
|
||||
During `setup(app)`, do not open DB sessions — the event loop is not yet running. Store the `app` reference and open sessions inside coroutines only.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Traefik Plugin
|
||||
|
||||
The Traefik plugin scrapes the Traefik reverse proxy's Prometheus `/metrics` endpoint on a configurable interval, stores per-router metrics, and surfaces them on the dashboard and a dedicated detail page.
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
On each scrape:
|
||||
1. Fetches raw Prometheus text from the configured `metrics_url`
|
||||
2. Parses histogram and counter data to compute per-router rates and latency approximations
|
||||
3. Writes computed metrics to the `traefik_metrics` history table
|
||||
4. Calls `record_metric()` for each metric, making them available to alert rules
|
||||
|
||||
Request rates and error rates are computed as deltas between the current and previous scrape, divided by elapsed time. Latency percentiles (p50, p95, p99) are approximated via linear interpolation over histogram buckets — these are estimates, not exact percentiles.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `metrics_url` | `http://localhost:8080/metrics` | Traefik Prometheus endpoint |
|
||||
| `scrape_interval_seconds` | `60` | How often to scrape |
|
||||
|
||||
Enable the plugin by setting `enabled: true` in the app settings (Settings UI or directly in `app_settings` DB table under key `plugin.traefik`).
|
||||
|
||||
Traefik must have metrics enabled. In Traefik config:
|
||||
|
||||
```yaml
|
||||
# traefik.yml
|
||||
metrics:
|
||||
prometheus: {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Metrics Collected
|
||||
|
||||
Per Traefik router, per scrape:
|
||||
|
||||
| Metric name | Description |
|
||||
|---|---|
|
||||
| `request_rate` | Requests per second (delta from previous scrape) |
|
||||
| `error_rate_4xx_pct` | 4xx responses as % of total requests |
|
||||
| `error_rate_5xx_pct` | 5xx responses as % of total requests |
|
||||
| `latency_p50_ms` | Approximate p50 latency (ms) |
|
||||
| `latency_p95_ms` | Approximate p95 latency (ms) |
|
||||
| `latency_p99_ms` | Approximate p99 latency (ms) |
|
||||
|
||||
All metrics are available for alert rules with `source_module = "traefik"` and `resource_name = <router name>`.
|
||||
|
||||
---
|
||||
|
||||
## Alert Rule Examples
|
||||
|
||||
| Rule | source_module | resource_name | metric_name | operator | threshold |
|
||||
|---|---|---|---|---|---|
|
||||
| High 5xx rate on API router | `traefik` | `api@docker` | `error_rate_5xx_pct` | `>` | `1.0` |
|
||||
| Slow API (p95 > 500ms) | `traefik` | `api@docker` | `latency_p95_ms` | `>` | `500` |
|
||||
| Traffic spike | `traefik` | `web@docker` | `request_rate` | `>` | `1000` |
|
||||
|
||||
Router names are the Traefik router labels as reported in the Prometheus metrics (e.g. `api@docker`, `dashboard@internal`). Check the raw metrics at your `metrics_url` to see the exact names in use.
|
||||
|
||||
---
|
||||
|
||||
## UI
|
||||
|
||||
### Dashboard Widget
|
||||
|
||||
The widget appears on the dashboard when the Traefik plugin is enabled. It shows, per router:
|
||||
|
||||
- Router name
|
||||
- Current req/s
|
||||
- p95 latency (color-coded: green < 200ms, yellow < 500ms, red ≥ 500ms)
|
||||
- 5xx error rate (shown only if > 0)
|
||||
|
||||
The widget auto-refreshes via HTMX polling every `poll_interval` seconds. Fragment endpoint: `GET /plugins/traefik/widget`.
|
||||
|
||||
### Detail Page
|
||||
|
||||
The full Traefik page at `/plugins/traefik/` shows all routers with sparkline history charts (last 20 data points) for request rate, p95 latency, and 5xx error rate.
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `plugins/traefik/__init__.py` | Plugin entry point: `setup()`, `get_scheduled_tasks()`, `get_blueprint()` |
|
||||
| `plugins/traefik/plugin.yaml` | Plugin metadata and default config |
|
||||
| `plugins/traefik/models.py` | `TraefikMetric` SQLAlchemy model |
|
||||
| `plugins/traefik/scheduler.py` | `make_scrape_task()` and scrape logic |
|
||||
| `plugins/traefik/scraper.py` | Prometheus text parsing and metric computation |
|
||||
| `plugins/traefik/routes.py` | `GET /` (detail page) and `GET /widget` (HTMX fragment) |
|
||||
| `plugins/traefik/migrations/` | Alembic migration for `traefik_metrics` table |
|
||||
| `plugins/traefik/templates/traefik/` | `index.html` (detail) and `widget.html` (dashboard fragment) |
|
||||
|
||||
---
|
||||
|
||||
## Database Table
|
||||
|
||||
`traefik_metrics` (defined in `plugins/traefik/models.py`):
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | UUID | Primary key |
|
||||
| `router_name` | str | Traefik router name |
|
||||
| `scraped_at` | timestamp UTC | When this row was written |
|
||||
| `request_rate` | float | req/s |
|
||||
| `error_rate_4xx_pct` | float | % 4xx |
|
||||
| `error_rate_5xx_pct` | float | % 5xx |
|
||||
| `latency_p50_ms` | float | Approx p50 (ms) |
|
||||
| `latency_p95_ms` | float | Approx p95 (ms) |
|
||||
| `latency_p99_ms` | float | Approx p99 (ms) |
|
||||
@@ -0,0 +1,418 @@
|
||||
# Writing a Plugin
|
||||
|
||||
This guide walks through building a complete plugin from scratch. The Traefik plugin (`plugins/traefik/`) is the reference implementation — read it alongside this guide.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create the Directory
|
||||
|
||||
```
|
||||
plugins/
|
||||
└── myplugin/
|
||||
└── __init__.py ← start here
|
||||
```
|
||||
|
||||
The directory name is the plugin's identity. It must match the `name` field in `plugin.yaml` and is used as the URL prefix (`/plugins/myplugin/`) and the Python import name.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Write plugin.yaml
|
||||
|
||||
```yaml
|
||||
name: myplugin
|
||||
version: "1.0.0"
|
||||
description: "A short description of what this plugin monitors"
|
||||
author: "Your Name or GitHub username"
|
||||
license: "MIT" # any SPDX identifier, e.g. MIT, Apache-2.0, GPL-3.0
|
||||
|
||||
# Optional: prevents loading on older app versions
|
||||
min_app_version: "0.1.0"
|
||||
|
||||
# Optional: shown in the catalog UI
|
||||
repository_url: "https://github.com/yourname/yourrepo"
|
||||
homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin"
|
||||
tags:
|
||||
- monitoring
|
||||
- http
|
||||
|
||||
# Default config values — users override these via the Settings UI
|
||||
# or by writing to the app_settings DB table under "plugin.myplugin"
|
||||
config:
|
||||
target_url: "http://localhost:9090/metrics"
|
||||
scrape_interval_seconds: 60
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Define Models (if needed)
|
||||
|
||||
If your plugin stores data, define SQLAlchemy models using the shared `Base` from `fabledscryer.models.base`.
|
||||
|
||||
```python
|
||||
# plugins/myplugin/models.py
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Float, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from fabledscryer.models.base import Base
|
||||
|
||||
|
||||
class MyPluginMetric(Base):
|
||||
__tablename__ = "myplugin_metrics"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
resource_name: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
my_value: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Write Migrations
|
||||
|
||||
Create `plugins/myplugin/migrations/` with the standard Alembic layout. Copy `env.py` and `script.py.mako` from `plugins/traefik/migrations/` as a starting point — the `env.py` is boilerplate and rarely needs changes.
|
||||
|
||||
Generate the initial migration:
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
alembic --config alembic.ini revision \
|
||||
--autogenerate \
|
||||
--head=fabledscryer@head \
|
||||
--branch-label=myplugin \
|
||||
-m "myplugin initial"
|
||||
```
|
||||
|
||||
Edit the generated file to set `depends_on`:
|
||||
|
||||
```python
|
||||
# In the generated revision file:
|
||||
depends_on = ("0004_core_head_id",) # the core migration head ID
|
||||
down_revision = None
|
||||
branch_labels = ("myplugin",)
|
||||
|
||||
# Prefix the revision ID with the plugin name:
|
||||
revision = "myplugin_001_initial"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Write Scheduled Task Logic
|
||||
|
||||
Keep task logic in a separate file so `__init__.py` stays clean.
|
||||
|
||||
```python
|
||||
# plugins/myplugin/scheduler.py
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from fabledscryer.core.scheduler import ScheduledTask
|
||||
from fabledscryer.core.alerts import record_metric
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_task(app) -> ScheduledTask:
|
||||
interval = int(app.config["PLUGINS"]["myplugin"]["scrape_interval_seconds"])
|
||||
|
||||
async def scrape():
|
||||
await _do_scrape(app)
|
||||
|
||||
return ScheduledTask(
|
||||
name="myplugin_scrape",
|
||||
coro_factory=scrape,
|
||||
interval_seconds=interval,
|
||||
run_on_startup=True,
|
||||
)
|
||||
|
||||
|
||||
async def _do_scrape(app) -> None:
|
||||
from .models import MyPluginMetric
|
||||
from datetime import datetime, timezone
|
||||
|
||||
url = app.config["PLUGINS"]["myplugin"]["target_url"]
|
||||
|
||||
try:
|
||||
value = await _fetch_value(url)
|
||||
except Exception:
|
||||
logger.exception("myplugin scrape failed (url=%s)", url)
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
# Write to plugin's own history table
|
||||
session.add(MyPluginMetric(
|
||||
resource_name="my-resource",
|
||||
scraped_at=now,
|
||||
my_value=value,
|
||||
))
|
||||
|
||||
# Emit to plugin_metrics so alert rules can fire
|
||||
await record_metric(
|
||||
session=session,
|
||||
source_module="myplugin",
|
||||
resource_name="my-resource",
|
||||
metric_name="my_value",
|
||||
value=value,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_value(url: str) -> float:
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
return float(resp.text.strip())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Write Routes (if needed)
|
||||
|
||||
```python
|
||||
# plugins/myplugin/routes.py
|
||||
from quart import Blueprint, current_app, render_template
|
||||
from fabledscryer.auth.middleware import require_role
|
||||
from fabledscryer.models.users import UserRole
|
||||
from .models import MyPluginMetric
|
||||
|
||||
myplugin_bp = Blueprint("myplugin", __name__, template_folder="templates")
|
||||
|
||||
|
||||
@myplugin_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def index():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
from sqlalchemy import select
|
||||
result = await db.execute(
|
||||
select(MyPluginMetric).order_by(MyPluginMetric.scraped_at.desc()).limit(50)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
return await render_template("myplugin/index.html", rows=rows)
|
||||
|
||||
|
||||
@myplugin_bp.get("/widget")
|
||||
@require_role(UserRole.viewer)
|
||||
async def widget():
|
||||
"""HTMX fragment for the dashboard widget."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
from sqlalchemy import select
|
||||
result = await db.execute(
|
||||
select(MyPluginMetric).order_by(MyPluginMetric.scraped_at.desc()).limit(1)
|
||||
)
|
||||
latest = result.scalar_one_or_none()
|
||||
return await render_template("myplugin/widget.html", latest=latest)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Write Templates
|
||||
|
||||
Templates live in `plugins/myplugin/templates/myplugin/` (the extra nesting avoids naming collisions with core templates).
|
||||
|
||||
```html
|
||||
{# plugins/myplugin/templates/myplugin/index.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}My Plugin — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-title">My Plugin</div>
|
||||
{% for row in rows %}
|
||||
<p>{{ row.resource_name }} — {{ row.my_value }}</p>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
```html
|
||||
{# plugins/myplugin/templates/myplugin/widget.html — HTMX fragment, no extends #}
|
||||
{% if latest %}
|
||||
<div class="ping-row">
|
||||
<span>{{ latest.resource_name }}</span>
|
||||
<span>{{ latest.my_value }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">No data yet.</p>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Wire Up __init__.py
|
||||
|
||||
```python
|
||||
# plugins/myplugin/__init__.py
|
||||
from __future__ import annotations
|
||||
|
||||
_app = None
|
||||
|
||||
|
||||
def setup(app) -> None:
|
||||
global _app
|
||||
_app = app
|
||||
from .models import MyPluginMetric # noqa: registers model with Base.metadata
|
||||
|
||||
|
||||
def get_scheduled_tasks() -> list:
|
||||
from .scheduler import make_task
|
||||
return [make_task(_app)]
|
||||
|
||||
|
||||
def get_blueprint():
|
||||
from .routes import myplugin_bp
|
||||
return myplugin_bp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 9: Enable the Plugin
|
||||
|
||||
Plugin config is stored in the `app_settings` DB table. The easiest way to enable a plugin is via the Settings UI, or by inserting directly:
|
||||
|
||||
```sql
|
||||
INSERT INTO app_settings (key, value_json, updated_at)
|
||||
VALUES ('plugin.myplugin', '{"enabled": true, "target_url": "http://localhost:9090/metrics"}', now());
|
||||
```
|
||||
|
||||
On next startup, the plugin will be loaded, its migrations applied, and its blueprint and tasks registered.
|
||||
|
||||
---
|
||||
|
||||
## Using record_metric()
|
||||
|
||||
`record_metric()` is how plugins feed data into the alert pipeline. Any metric you write here can be the target of an alert rule created in the UI.
|
||||
|
||||
```python
|
||||
from fabledscryer.core.alerts import record_metric
|
||||
|
||||
# Must be inside an active transaction
|
||||
async with session.begin():
|
||||
await record_metric(
|
||||
session=session,
|
||||
source_module="myplugin", # matches alert rule source_module
|
||||
resource_name="my-server", # matches alert rule resource_name
|
||||
metric_name="response_time_ms", # matches alert rule metric_name
|
||||
value=42.3,
|
||||
)
|
||||
```
|
||||
|
||||
`record_metric()` writes to `plugin_metrics` and evaluates all matching alert rules inline. Notifications are deferred outside the transaction. It propagates `SQLAlchemyError` on DB failure — don't swallow it.
|
||||
|
||||
---
|
||||
|
||||
## Auth in Routes
|
||||
|
||||
Use the `@require_role` decorator from `fabledscryer.auth.middleware`:
|
||||
|
||||
```python
|
||||
from fabledscryer.auth.middleware import require_role
|
||||
from fabledscryer.models.users import UserRole
|
||||
|
||||
@myplugin_bp.get("/admin-only")
|
||||
@require_role(UserRole.admin)
|
||||
async def admin_page():
|
||||
...
|
||||
|
||||
@myplugin_bp.get("/read-only")
|
||||
@require_role(UserRole.viewer) # viewer, operator, and admin can all access
|
||||
async def read_page():
|
||||
...
|
||||
```
|
||||
|
||||
Role hierarchy: `admin > operator > viewer`. Requiring `viewer` grants access to all three roles.
|
||||
|
||||
---
|
||||
|
||||
## Publishing to the Catalog
|
||||
|
||||
The official plugin catalog is hosted at `https://github.com/bvandeusen/fabledscryer-plugins`. Anyone can submit a plugin by opening a pull request — first-party and third-party plugins are treated identically by the catalog system.
|
||||
|
||||
### Repo layout
|
||||
|
||||
```
|
||||
fabledscryer-plugins/
|
||||
├── index.yaml ← catalog index — the only file Fabled Scryer fetches
|
||||
├── myplugin/
|
||||
│ ├── plugin.yaml
|
||||
│ ├── __init__.py
|
||||
│ └── ...
|
||||
└── .github/
|
||||
└── workflows/
|
||||
└── publish.yml ← packages each plugin dir into a zip on release
|
||||
```
|
||||
|
||||
### index.yaml entry
|
||||
|
||||
Each plugin needs a corresponding entry in `index.yaml`. See `docs/plugins/index.yaml.example` for the full schema. The minimum required fields are:
|
||||
|
||||
```yaml
|
||||
- name: myplugin
|
||||
version: "1.0.0"
|
||||
description: "What this plugin does"
|
||||
author: "Your Name"
|
||||
license: "MIT"
|
||||
min_app_version: "0.1.0"
|
||||
repository_url: "https://github.com/yourname/yourrepo"
|
||||
homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin"
|
||||
download_url: "https://github.com/yourname/yourrepo/releases/download/myplugin-v1.0.0/myplugin.zip"
|
||||
checksum_sha256: "" # fill in after zipping — see below
|
||||
tags:
|
||||
- monitoring
|
||||
```
|
||||
|
||||
### Creating a release zip
|
||||
|
||||
The zip must contain the plugin files at the top level OR inside a single directory named after the plugin. Both layouts work:
|
||||
|
||||
```
|
||||
# Layout A — flat (preferred)
|
||||
myplugin.zip
|
||||
├── plugin.yaml
|
||||
├── __init__.py
|
||||
└── ...
|
||||
|
||||
# Layout B — single top-level directory (also accepted, GitHub archive default)
|
||||
myplugin.zip
|
||||
└── myplugin/
|
||||
├── plugin.yaml
|
||||
└── ...
|
||||
```
|
||||
|
||||
Generate the zip and its checksum:
|
||||
|
||||
```bash
|
||||
cd fabledscryer-plugins
|
||||
zip -r myplugin.zip myplugin/
|
||||
sha256sum myplugin.zip # paste this into index.yaml checksum_sha256
|
||||
```
|
||||
|
||||
Upload `myplugin.zip` as a GitHub release asset, then update `index.yaml` with the release download URL and checksum.
|
||||
|
||||
### How install works
|
||||
|
||||
When a user clicks **Install** in Settings → Plugins:
|
||||
|
||||
1. The app downloads the zip from `download_url`
|
||||
2. Verifies the SHA-256 checksum (if provided)
|
||||
3. Extracts the plugin into its `PLUGIN_DIR`
|
||||
4. Runs any pending Alembic migrations for the plugin
|
||||
5. Attempts a **hot-reload** — registers the blueprint and scheduled tasks without restarting
|
||||
6. If the plugin was previously loaded (blueprint already mounted), a **restart** is required to pick up the new code
|
||||
|
||||
Hot-reload works reliably for brand-new plugin installs. Updates to already-active plugins require a restart, which can be triggered from the same settings page.
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `plugins/myplugin/` directory created
|
||||
- [ ] `plugin.yaml` with correct `name` (matches directory), `author`, `license`, `tags`
|
||||
- [ ] `__init__.py` exports `setup()` and `get_scheduled_tasks()`
|
||||
- [ ] Models import inside `setup()` to register with metadata
|
||||
- [ ] Migrations use `depends_on` pointing to core head, not `down_revision`
|
||||
- [ ] Revision IDs prefixed with plugin name
|
||||
- [ ] `record_metric()` called inside `session.begin()`
|
||||
- [ ] Routes use `@require_role` decorator
|
||||
- [ ] Templates namespaced under `templates/myplugin/`
|
||||
- [ ] Plugin enabled in app settings
|
||||
- [ ] `index.yaml` entry added with `download_url` and `checksum_sha256`
|
||||
Reference in New Issue
Block a user