Files
FabledSteward/docs/plugins/writing-a-plugin.md
T
bvandeusen 0596f1d9c1 docs: roundtable rename sweep
Sweeps README, docs/, example configs, code docstrings, report and
notification subject lines, plugin catalog URL default, and stray
comments in Dockerfile/entrypoint.sh/.gitignore. Adds a legacy note
to docs/core/configuration.md explaining the FABLEDSCRYER_* fallback.
docker-compose DB credentials intentionally left as fabledscryer to
preserve the existing pgdata volume.
2026-04-13 20:16:25 -04:00

12 KiB

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

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 roundtable.models.base.

# 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 roundtable.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:

# From the project root
alembic --config alembic.ini revision \
    --autogenerate \
    --head=roundtable@head \
    --branch-label=myplugin \
    -m "myplugin initial"

Edit the generated file to set depends_on:

# 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.

# plugins/myplugin/scheduler.py
from __future__ import annotations
import logging
from roundtable.core.scheduler import ScheduledTask
from roundtable.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)

# plugins/myplugin/routes.py
from quart import Blueprint, current_app, render_template
from roundtable.auth.middleware import require_role
from roundtable.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).

{# plugins/myplugin/templates/myplugin/index.html #}
{% extends "base.html" %}
{% block title %}My Plugin — Roundtable{% endblock %}
{% block content %}
<div class="page-title">My Plugin</div>
{% for row in rows %}
  <p>{{ row.resource_name }} — {{ row.my_value }}</p>
{% endfor %}
{% endblock %}
{# 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

# 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:

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.

from roundtable.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 roundtable.auth.middleware:

from roundtable.auth.middleware import require_role
from roundtable.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://git.fabledsword.com/bvandeusen/Roundtable-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

roundtable-plugins/
├── index.yaml          ← catalog index — the only file Roundtable 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:

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

cd roundtable-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