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,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