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