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:
2026-03-22 18:27:56 -04:00
parent 165a202ba4
commit 230b542015
121 changed files with 4820 additions and 715 deletions
+80
View File
@@ -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
+173
View File
@@ -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.
+115
View File
@@ -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) |
+418
View File
@@ -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`