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,105 @@
|
||||
# Architecture
|
||||
|
||||
Fabled Scryer is a single Quart (async Python) process. There are no separate worker processes, no message brokers, and no build pipeline for the frontend. All monitoring, scheduling, and request handling runs on a single asyncio event loop.
|
||||
|
||||
---
|
||||
|
||||
## Startup Sequence
|
||||
|
||||
`create_app()` in `fabledscryer/app.py` runs these steps synchronously before the event loop starts:
|
||||
|
||||
| Step | What happens |
|
||||
|---|---|
|
||||
| 1 | Bootstrap config loaded (`database_url`, `secret_key`, `plugin_dir`) |
|
||||
| 2 | SQLAlchemy async engine and session factory attached to `app` |
|
||||
| 3 | Core Alembic migrations applied (creates `app_settings` table among others) |
|
||||
| 4 | All settings loaded from `app_settings` DB table into `app.config` |
|
||||
| 5 | Each enabled plugin's Alembic migrations applied |
|
||||
| 6 | Alert pipeline initialised (`init_alerts(app)` stores app ref for deferred notifications) |
|
||||
| 7 | Core blueprints registered (auth, dashboard, hosts, ping, dns, alerts, ansible, settings) |
|
||||
| 8 | Core scheduled tasks registered into `app._task_registry` |
|
||||
| 9 | Plugins loaded via `load_plugins(app)` — blueprints and tasks appended |
|
||||
| 10 | `/health` endpoint registered |
|
||||
| 11 | `before_serving` hook starts the scheduler as an `asyncio.create_task()` |
|
||||
|
||||
The two-phase migration approach (step 3 core → step 4 load settings → step 5 plugins) exists because plugin enabling is stored in the settings DB, which requires the core schema to exist first.
|
||||
|
||||
---
|
||||
|
||||
## Request Routing
|
||||
|
||||
All routes are Quart Blueprints registered in `app.py`:
|
||||
|
||||
| Prefix | Blueprint | Module |
|
||||
|---|---|---|
|
||||
| `/auth/` | `auth_bp` | `fabledscryer/auth/routes.py` |
|
||||
| `/` | `dashboard_bp` | `fabledscryer/dashboard/routes.py` |
|
||||
| `/hosts/` | `hosts_bp` | `fabledscryer/hosts/routes.py` |
|
||||
| `/ping/` | `ping_bp` | `fabledscryer/ping/routes.py` |
|
||||
| `/dns/` | `dns_bp` | `fabledscryer/dns/routes.py` |
|
||||
| `/alerts/` | `alerts_bp` | `fabledscryer/alerts/routes.py` |
|
||||
| `/ansible/` | `ansible_bp` | `fabledscryer/ansible/routes.py` |
|
||||
| `/settings/` | `settings_bp` | `fabledscryer/settings/routes.py` |
|
||||
| `/plugins/<name>/` | plugin blueprint | `plugins/<name>/routes.py` |
|
||||
|
||||
Plugin blueprints are mounted automatically by `load_plugins()` using the plugin directory name as the URL prefix.
|
||||
|
||||
---
|
||||
|
||||
## Scheduler
|
||||
|
||||
`fabledscryer/core/scheduler.py` exports two things:
|
||||
|
||||
- `ScheduledTask` dataclass — holds a `name`, `coro_factory` (zero-argument callable returning a coroutine), `interval_seconds`, and optional `run_on_startup` flag
|
||||
- `start_scheduler(tasks)` — async function that loops every second, calling `asyncio.create_task()` for each task whose interval has elapsed
|
||||
|
||||
Tasks are never awaited serially — each runs as a fully independent asyncio task. Exceptions inside tasks are caught and logged; they do not crash other tasks or the app.
|
||||
|
||||
Core tasks registered in `app._task_registry`:
|
||||
- `ping_monitor` — runs every `monitors.poll_interval_seconds` (default 60s), `run_on_startup=True`
|
||||
- `dns_monitor` — same interval, `run_on_startup=True`
|
||||
- `data_cleanup` — runs hourly, `run_on_startup=False`
|
||||
- `ansible_git_pull_<name>` — one per configured Git source, interval from source config
|
||||
|
||||
---
|
||||
|
||||
## Database Session Pattern
|
||||
|
||||
`app.db_sessionmaker` is an SQLAlchemy `async_sessionmaker`. All DB access follows this pattern:
|
||||
|
||||
```python
|
||||
async with current_app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
# queries and writes here
|
||||
# transaction commits on exit, rolls back on exception
|
||||
```
|
||||
|
||||
Sessions are never shared across request boundaries or between tasks.
|
||||
|
||||
---
|
||||
|
||||
## Config Two-Layer Design
|
||||
|
||||
There are exactly two config layers:
|
||||
|
||||
1. **Bootstrap** (`config.yaml` + env vars) — `database_url`, `secret_key`, `plugin_dir` only. Read once at startup before the event loop.
|
||||
2. **App settings** (`app_settings` DB table) — everything else: SMTP, webhooks, Ansible sources, monitor intervals, ping thresholds, plugin config. Read at startup via `load_settings_sync()` and written via the Settings UI at runtime.
|
||||
|
||||
This means the only file you must touch to get the app running is the database URL. Everything else can be configured through the web UI.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Approach
|
||||
|
||||
No JavaScript framework, no build step. The frontend is:
|
||||
|
||||
- **Jinja2 templates** rendered server-side (`fabledscryer/templates/`)
|
||||
- **HTMX** for live-updating fragments (dashboard widgets, ping pills, DNS status)
|
||||
- A single CSS design system in `fabledscryer/templates/base.html` using CSS custom properties
|
||||
|
||||
Live-updating widgets use HTMX polling:
|
||||
```html
|
||||
<div hx-get="/ping/rows" hx-trigger="load, every 30s" hx-swap="innerHTML">
|
||||
```
|
||||
|
||||
Fragment endpoints (`/ping/rows`, `/dns/rows`, `/plugins/traefik/widget`) return partial HTML that HTMX swaps in without a full page reload.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Alerting
|
||||
|
||||
Alert rules evaluate every metric that flows through `record_metric()`. There is no separate polling process — evaluation is inline with every metric write.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When any monitor or plugin calls `record_metric()`:
|
||||
|
||||
1. The metric value is written to the `plugin_metrics` table
|
||||
2. All enabled `AlertRule` rows matching `(source_module, resource_name, metric_name)` are loaded
|
||||
3. Each matching rule is evaluated against the new value
|
||||
4. If a state transition occurs, an `AlertEvent` row is written
|
||||
5. Notification I/O is deferred outside the transaction via `asyncio.create_task()`
|
||||
|
||||
**Key function:** `fabledscryer/core/alerts.py` → `record_metric(session, source_module, resource_name, metric_name, value)`
|
||||
|
||||
`record_metric()` must always be called inside an active transaction:
|
||||
|
||||
```python
|
||||
async with session.begin():
|
||||
await record_metric(session, "ping", "my-server", "response_time_ms", 42.3)
|
||||
```
|
||||
|
||||
Notifications are sent via `asyncio.create_task()` after the function returns, so no network I/O blocks the DB transaction.
|
||||
|
||||
---
|
||||
|
||||
## Alert State Machine
|
||||
|
||||
Each `AlertRule` has one associated `AlertState` row. The state transitions are:
|
||||
|
||||
```
|
||||
inactive ──(breached)──► pending ──(consecutive count met)──► FIRING
|
||||
│ │
|
||||
└──(recovered)──► inactive (no notification) │
|
||||
│
|
||||
FIRING ──(recovered)──► RESOLVED ──► inactive
|
||||
FIRING ──(acknowledged)──► ACKNOWLEDGED
|
||||
ACKNOWLEDGED ──(recovered)──► RESOLVED ──► inactive
|
||||
ACKNOWLEDGED ──(re-breached)──► FIRING
|
||||
```
|
||||
|
||||
`RESOLVED` is transient — the evaluator writes the event and immediately sets state back to `inactive` within the same transaction. `RESOLVED` never persists as a final state in the DB.
|
||||
|
||||
### State Definitions
|
||||
|
||||
| State | Meaning |
|
||||
|---|---|
|
||||
| `inactive` | Threshold not breached |
|
||||
| `pending` | Threshold breached but consecutive count not yet met; no notification sent |
|
||||
| `firing` | Consecutive count met; FIRING notification sent |
|
||||
| `acknowledged` | Operator acknowledged; suppresses repeat notifications; auto-clears on recovery |
|
||||
| `resolved` | Transient — notification sent, immediately transitions to `inactive` |
|
||||
|
||||
---
|
||||
|
||||
## Creating Alert Rules
|
||||
|
||||
Alert rules are created in the UI at `/alerts/`. Each rule requires:
|
||||
|
||||
- **Name** — human-readable label
|
||||
- **Source module** — `ping`, `dns`, `traefik`, or any plugin name
|
||||
- **Resource name** — host name, router name, etc. (must match exactly what the monitor writes)
|
||||
- **Metric name** — the metric key (e.g. `response_time_ms`, `up`, `error_rate_5xx_pct`)
|
||||
- **Operator** — `>`, `<`, `>=`, `<=`, `==`, `!=`
|
||||
- **Threshold** — numeric value
|
||||
- **Consecutive failures required** — how many consecutive breaches before FIRING (default 1)
|
||||
|
||||
### Available Metrics by Module
|
||||
|
||||
| `source_module` | `metric_name` | Description |
|
||||
|---|---|---|
|
||||
| `ping` | `response_time_ms` | Probe latency (0.0 if down) |
|
||||
| `ping` | `up` | 1.0 = up, 0.0 = down |
|
||||
| `dns` | `resolved` | 1.0 = resolved, 0.0 = failed |
|
||||
| `dns` | `ip_changed` | 1.0 = IP changed from previous result |
|
||||
| `traefik` | `request_rate` | Requests per second |
|
||||
| `traefik` | `error_rate_4xx_pct` | 4xx errors as % of requests |
|
||||
| `traefik` | `error_rate_5xx_pct` | 5xx errors as % of requests |
|
||||
| `traefik` | `latency_p50_ms` | Approximate p50 latency (ms) |
|
||||
| `traefik` | `latency_p95_ms` | Approximate p95 latency (ms) |
|
||||
| `traefik` | `latency_p99_ms` | Approximate p99 latency (ms) |
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
Notifications fire on `FIRING` and `RESOLVED` transitions. All configured channels receive every notification; there is no per-rule channel routing.
|
||||
|
||||
### Email
|
||||
|
||||
Configured at `/settings/` under SMTP. Settings:
|
||||
|
||||
| Setting key | Description |
|
||||
|---|---|
|
||||
| `smtp.host` | SMTP server |
|
||||
| `smtp.port` | Port (default 587) |
|
||||
| `smtp.tls` | STARTTLS (default true) |
|
||||
| `smtp.username` | Login |
|
||||
| `smtp.password` | Password |
|
||||
| `smtp.recipients` | List of email addresses |
|
||||
|
||||
Email is skipped if `smtp.host` is empty.
|
||||
|
||||
### Webhook
|
||||
|
||||
Configured at `/settings/` under Webhook. The body is a Jinja2 template rendered to JSON. Content-Type is always `application/json`. If the rendered template is not valid JSON, the delivery is logged as failed and no request is sent.
|
||||
|
||||
| Template variable | Type | Description |
|
||||
|---|---|---|
|
||||
| `alert.rule_name` | str | Alert rule name |
|
||||
| `alert.state` | str | `FIRING` or `RESOLVED` |
|
||||
| `alert.metric` | str | Metric name |
|
||||
| `alert.value` | float | Current value |
|
||||
| `alert.threshold` | float | Configured threshold |
|
||||
| `alert.resource` | str | Resource name |
|
||||
| `alert.source_module` | str | `ping`, `dns`, `traefik`, etc. |
|
||||
| `alert.timestamp` | str | ISO 8601 UTC |
|
||||
|
||||
Default template (Discord-compatible):
|
||||
```json
|
||||
{"content": "**{{ alert.state }}** — {{ alert.resource }} — {{ alert.rule_name }} ({{ alert.metric }} = {{ alert.value }})"}
|
||||
```
|
||||
|
||||
Webhook is skipped if `webhook.url` is empty.
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
Defined in `fabledscryer/models/alerts.py`:
|
||||
|
||||
- **`alert_rules`** — one row per configured rule
|
||||
- **`alert_states`** — one row per rule, tracks current state and consecutive failure count
|
||||
- **`alert_events`** — append-only log of all state transitions and notification outcomes
|
||||
- **`plugin_metrics`** — all metric values written by any monitor or plugin
|
||||
@@ -0,0 +1,143 @@
|
||||
# Ansible Integration
|
||||
|
||||
Fabled Scryer can browse, trigger, and stream output from Ansible playbooks directly from the web UI. Runs execute as asyncio tasks inside the same process — no Celery, no external workers.
|
||||
|
||||
---
|
||||
|
||||
## Playbook Sources
|
||||
|
||||
Sources are configured at `/settings/` under Ansible. Two source types are supported:
|
||||
|
||||
### Local Filesystem
|
||||
|
||||
Points to a directory on the host that already contains playbooks.
|
||||
|
||||
```yaml
|
||||
# In app_settings (configured via UI)
|
||||
ansible.sources:
|
||||
- name: "homelab"
|
||||
type: local
|
||||
path: "/opt/playbooks"
|
||||
```
|
||||
|
||||
### Git Repository
|
||||
|
||||
The app clones or pulls the repo into a local cache directory on a configurable schedule. All execution uses the local cache.
|
||||
|
||||
```yaml
|
||||
ansible.sources:
|
||||
- name: "infra-repo"
|
||||
type: git
|
||||
url: "https://github.com/example/infra.git"
|
||||
branch: "main"
|
||||
pull_interval_seconds: 300
|
||||
cache_path: "/data/playbook_cache/infra-repo"
|
||||
```
|
||||
|
||||
Git sources register a `ScheduledTask` named `ansible_git_pull_<name>` that runs `git pull` on the configured interval.
|
||||
|
||||
---
|
||||
|
||||
## Inventory Discovery
|
||||
|
||||
The app discovers inventory files within the root of the playbook source directory (non-recursive). It looks for files named:
|
||||
|
||||
- `hosts`
|
||||
- `inventory`
|
||||
- `inventory.yml`
|
||||
- `inventory.ini`
|
||||
|
||||
A manual relative path can also be entered in the UI (e.g. `inventories/production/hosts`) for inventories in subdirectories.
|
||||
|
||||
---
|
||||
|
||||
## Triggering a Run
|
||||
|
||||
From the UI at `/ansible/`, you can:
|
||||
|
||||
1. Browse available playbooks across all sources
|
||||
2. View playbook contents before running
|
||||
3. Select an inventory file
|
||||
4. Trigger a run (requires `operator` or `viewer` role — execution is restricted to `operator`/`admin`)
|
||||
|
||||
The run flow:
|
||||
|
||||
1. UI submits `POST /ansible/runs` with `playbook_path`, `source_name`, and `inventory_path`
|
||||
2. An `AnsibleRun` row is created with `status = running`
|
||||
3. Playbook execution starts as `asyncio.create_task()`
|
||||
4. The response returns the `run_id` and an HTMX partial that wires the SSE subscription
|
||||
5. The browser connects to `GET /ansible/runs/<run_id>/stream` to receive live output
|
||||
|
||||
---
|
||||
|
||||
## SSE Streaming
|
||||
|
||||
Run output is streamed to the browser via Server-Sent Events (SSE) at:
|
||||
|
||||
```
|
||||
GET /ansible/runs/<run_id>/stream
|
||||
```
|
||||
|
||||
Each output line is sent as:
|
||||
|
||||
```
|
||||
event: output
|
||||
data: <line of stdout/stderr>
|
||||
```
|
||||
|
||||
Run completion:
|
||||
|
||||
```
|
||||
event: done
|
||||
data: success|failed|interrupted
|
||||
```
|
||||
|
||||
Output is flushed to the `ansible_runs.output` DB column every 50 lines or every 5 seconds (whichever comes first) and always on completion. This means partial output survives a process crash.
|
||||
|
||||
If a client connects after the run has already completed, the endpoint immediately sends `event: done` with the final status and closes the stream. To view stored output, use the run history view at `GET /ansible/runs/<run_id>`.
|
||||
|
||||
---
|
||||
|
||||
## Run Lifecycle
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `running` | Execution in progress |
|
||||
| `success` | Playbook exited 0 |
|
||||
| `failed` | Playbook exited non-zero |
|
||||
| `interrupted` | App restarted while run was in progress |
|
||||
|
||||
On startup, the app marks any runs still in `running` state as `interrupted` (see `_mark_interrupted_runs()` in `app.py`).
|
||||
|
||||
Output stored in the DB is capped at 1 MB. If truncated, `[output truncated]` is appended to the DB column, but the live SSE stream continues unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
`ansible_runs` table (defined in `fabledscryer/models/ansible.py`):
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | UUID | Primary key |
|
||||
| `playbook_path` | str | Relative path to playbook |
|
||||
| `inventory_path` | str | Inventory path used |
|
||||
| `source_name` | str | Name of the playbook source |
|
||||
| `triggered_by` | FK → users | |
|
||||
| `status` | enum | `running`, `success`, `failed`, `interrupted` |
|
||||
| `started_at` | timestamp UTC | |
|
||||
| `finished_at` | timestamp UTC | Null if still running |
|
||||
| `output` | text | Captured stdout/stderr (capped at 1 MB) |
|
||||
|
||||
Runs older than `data.retention_days` (default 90) are pruned by the `data_cleanup` scheduled task.
|
||||
|
||||
---
|
||||
|
||||
## Source Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `fabledscryer/ansible/sources.py` | Source discovery, git pull logic |
|
||||
| `fabledscryer/ansible/executor.py` | Subprocess execution and output streaming |
|
||||
| `fabledscryer/ansible/routes.py` | HTTP routes (browse, trigger, stream, history) |
|
||||
| `fabledscryer/models/ansible.py` | `AnsibleRun` model |
|
||||
@@ -0,0 +1,103 @@
|
||||
# Configuration
|
||||
|
||||
Fabled Scryer uses a two-layer configuration system. Only the bare minimum needed to boot lives in files or environment variables. Everything else is stored in the database and managed through the Settings UI.
|
||||
|
||||
---
|
||||
|
||||
## Bootstrap Config (File / Env Vars)
|
||||
|
||||
These three values are read at startup from `config.yaml` and/or environment variables:
|
||||
|
||||
| Key | Env var | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `database.url` | `FABLEDSCRYER_DATABASE_URL` | — | PostgreSQL async URL. **Required.** |
|
||||
| `secret_key` | `FABLEDSCRYER_SECRET_KEY` | auto-generated | Flask/Quart session signing key. Auto-generated and saved to `/data/secret.key` if not set. |
|
||||
| `plugin_dir` | `FABLEDSCRYER_PLUGIN_DIR` | `plugins` | Path to the plugins directory. |
|
||||
|
||||
**Resolution order for `database_url`:** env var `FABLEDSCRYER_DATABASE_URL` → env var `FABLEDSCRYER_DATABASE__URL` (legacy double-underscore) → `database.url` in `config.yaml`.
|
||||
|
||||
**Resolution order for `secret_key`:** env var `FABLEDSCRYER_SECRET_KEY` → `secret_key` in `config.yaml` → `/data/secret.key` file → auto-generate and write to `/data/secret.key`.
|
||||
|
||||
### Minimal config.yaml
|
||||
|
||||
```yaml
|
||||
database:
|
||||
url: "postgresql+asyncpg://user:password@localhost/fabledscryer"
|
||||
```
|
||||
|
||||
### Minimal env-only setup (Docker)
|
||||
|
||||
```bash
|
||||
FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://user:password@db/fabledscryer
|
||||
```
|
||||
|
||||
A `.env` file is loaded automatically if present.
|
||||
|
||||
---
|
||||
|
||||
## App Settings (Database-backed)
|
||||
|
||||
All runtime settings are stored in the `app_settings` table and editable through the web UI at `/settings/`. They are loaded into `app.config` at startup.
|
||||
|
||||
### All Settings and Defaults
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `session.lifetime_hours` | `8` | How long a login session lasts |
|
||||
| `data.retention_days` | `90` | How many days of ping/DNS/metric/ansible history to keep |
|
||||
| `monitors.poll_interval_seconds` | `60` | How often ping and DNS checks run |
|
||||
| `smtp.host` | `""` | SMTP server hostname |
|
||||
| `smtp.port` | `587` | SMTP server port |
|
||||
| `smtp.tls` | `true` | Use STARTTLS |
|
||||
| `smtp.username` | `""` | SMTP login username |
|
||||
| `smtp.password` | `""` | SMTP login password |
|
||||
| `smtp.recipients` | `[]` | List of email addresses to notify |
|
||||
| `webhook.url` | `""` | Webhook POST destination URL |
|
||||
| `webhook.template` | see below | Jinja2 JSON template for webhook body |
|
||||
| `ansible.sources` | `[]` | List of playbook source definitions |
|
||||
| `ping.threshold.good_ms` | `50` | Latency below this is shown green in the ping UI |
|
||||
| `ping.threshold.warn_ms` | `200` | Latency below this is shown yellow; above is orange |
|
||||
|
||||
Default webhook template:
|
||||
```
|
||||
{"content": "**{{ alert.state }}** — {{ alert.resource }} — {{ alert.rule_name }} ({{ alert.metric }} = {{ alert.value }})"}
|
||||
```
|
||||
|
||||
### Reading and Writing Settings in Code
|
||||
|
||||
```python
|
||||
from fabledscryer.core.settings import get_setting, set_setting
|
||||
|
||||
# Read
|
||||
async with current_app.db_sessionmaker() as session:
|
||||
host = await get_setting(session, "smtp.host")
|
||||
|
||||
# Write (must be inside a transaction)
|
||||
async with current_app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
await set_setting(session, "smtp.host", "mail.example.com")
|
||||
```
|
||||
|
||||
`get_setting()` returns the stored value or the default from `DEFAULTS` if the key has never been set.
|
||||
|
||||
### The DEFAULTS Dict
|
||||
|
||||
`fabledscryer/core/settings.py` contains the `DEFAULTS` dict — the canonical list of all recognised settings and their default values. Add new settings here to make them recognised by `get_all_settings()` and the settings UI.
|
||||
|
||||
---
|
||||
|
||||
## app.config Keys
|
||||
|
||||
After startup, `app.config` contains these additional keys (in addition to standard Quart keys):
|
||||
|
||||
| Key | Type | Source |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | str | Bootstrap |
|
||||
| `PLUGIN_DIR` | str | Bootstrap |
|
||||
| `SESSION_LIFETIME_HOURS` | int | DB settings |
|
||||
| `DATA_RETENTION_DAYS` | int | DB settings |
|
||||
| `MONITORS_POLL_INTERVAL` | int | DB settings |
|
||||
| `SMTP` | dict | DB settings, via `to_smtp_cfg()` |
|
||||
| `WEBHOOK` | dict | DB settings, via `to_webhook_cfg()` |
|
||||
| `ANSIBLE` | dict | DB settings, via `to_ansible_cfg()` |
|
||||
| `PLUGINS` | dict | DB settings + plugin.yaml defaults, keyed by plugin name |
|
||||
@@ -0,0 +1,104 @@
|
||||
# Core Monitors
|
||||
|
||||
Fabled Scryer ships two built-in monitors: Ping and DNS. Both run as asyncio scheduled tasks on the same event loop as the web server, with no separate processes.
|
||||
|
||||
---
|
||||
|
||||
## Ping Monitor
|
||||
|
||||
**Source:** `fabledscryer/monitors/ping.py`
|
||||
**Scheduler task:** `ping_monitor` in `fabledscryer/app.py`
|
||||
**Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup
|
||||
|
||||
### How It Works
|
||||
|
||||
On each tick, the scheduler fetches all hosts with `ping_enabled = true` and calls `ping_check(host, session)` for each.
|
||||
|
||||
`ping_check()` probes the host using:
|
||||
- **ICMP** if `host.probe_type == "icmp"` — uses the system `ping` binary (`/bin/ping` or equivalent). Requires `iputils-ping` in Docker.
|
||||
- **TCP** if `host.probe_type == "tcp"` (default) — attempts an async TCP connection to `host.address:host.probe_port` (default port 80).
|
||||
|
||||
Each probe writes a `PingResult` row and calls `record_metric()`:
|
||||
|
||||
| `source_module` | `resource_name` | `metric_name` | `value` |
|
||||
|---|---|---|---|
|
||||
| `ping` | `host.name` | `response_time_ms` | measured latency, or `0.0` if down |
|
||||
| `ping` | `host.name` | `up` | `1.0` if up, `0.0` if down |
|
||||
|
||||
**Alert rule note:** Because `response_time_ms` is recorded as `0.0` when a host is down, a latency rule (e.g. `response_time_ms > 500`) will not fire on complete outages. Use a separate rule on `up == 0.0` to detect host down events.
|
||||
|
||||
### Data Model
|
||||
|
||||
`ping_results` table (defined in `fabledscryer/models/monitors.py`):
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | UUID | Primary key |
|
||||
| `host_id` | FK → hosts | |
|
||||
| `probed_at` | timestamp UTC | When the probe ran |
|
||||
| `status` | enum `up`/`down` | Result |
|
||||
| `response_time_ms` | float | Null if down |
|
||||
|
||||
Old rows are pruned by the `data_cleanup` task (default: 90 days).
|
||||
|
||||
### UI
|
||||
|
||||
- **Dashboard widget** — live-updating via HTMX polling (`/ping/rows`)
|
||||
- **`/ping/` page** — full page with 30-pill history per host and threshold settings form
|
||||
- **Hosts list** — shows latest ping status dot and latency
|
||||
|
||||
---
|
||||
|
||||
## DNS Monitor
|
||||
|
||||
**Source:** `fabledscryer/monitors/dns.py`
|
||||
**Scheduler task:** `dns_monitor` in `fabledscryer/app.py`
|
||||
**Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup
|
||||
|
||||
### How It Works
|
||||
|
||||
On each tick, the scheduler fetches all hosts with `dns_enabled = true` and calls `dns_check(host, session)` for each.
|
||||
|
||||
`dns_check()` resolves `host.address` using the system resolver. If `host.dns_expected_ip` is set, the check passes only if at least one returned A/AAAA record exactly matches that string. If `dns_expected_ip` is null, any successful resolution counts as a pass.
|
||||
|
||||
Each check writes a `DnsResult` row and calls `record_metric()`:
|
||||
|
||||
| `source_module` | `resource_name` | `metric_name` | `value` |
|
||||
|---|---|---|---|
|
||||
| `dns` | `host.name` | `resolved` | `1.0` if resolved, `0.0` if failed |
|
||||
| `dns` | `host.name` | `ip_changed` | `1.0` if IP changed from last successful result, `0.0` otherwise |
|
||||
|
||||
### Data Model
|
||||
|
||||
`dns_results` table (defined in `fabledscryer/models/monitors.py`):
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | UUID | Primary key |
|
||||
| `host_id` | FK → hosts | |
|
||||
| `resolved_at` | timestamp UTC | When the check ran |
|
||||
| `status` | enum `resolved`/`failed` | Result |
|
||||
| `resolved_ip` | str | First returned A/AAAA record; null if failed |
|
||||
|
||||
`ip_changed` is computed by comparing `resolved_ip` of the current result against the most recent prior `resolved` result for the same host.
|
||||
|
||||
### UI
|
||||
|
||||
- **Dashboard widget** — live-updating via HTMX polling (`/dns/rows`)
|
||||
- **`/dns/` page** — full page showing all DNS-enabled hosts with status, resolved IP, and timestamp
|
||||
- **Hosts list** — shows latest DNS status dot
|
||||
|
||||
---
|
||||
|
||||
## Host Configuration
|
||||
|
||||
Hosts are managed at `/hosts/`. Both monitors are configured per-host:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `ping_enabled` | Enable ping probing for this host |
|
||||
| `probe_type` | `tcp` (default) or `icmp` |
|
||||
| `probe_port` | TCP port to connect to (default 80; ignored for ICMP) |
|
||||
| `dns_enabled` | Enable DNS resolution checks |
|
||||
| `dns_expected_ip` | If set, the resolved IP must match this string exactly |
|
||||
| `poll_interval_seconds` | Per-host override for the global poll interval; null uses global |
|
||||
@@ -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`
|
||||
@@ -0,0 +1,138 @@
|
||||
# Code Map
|
||||
|
||||
Quick reference for where key functions, models, and entry points live in the codebase.
|
||||
|
||||
---
|
||||
|
||||
## Application Bootstrap
|
||||
|
||||
| What | File | Function / Class |
|
||||
|---|---|---|
|
||||
| App factory | `fabledscryer/app.py` | `create_app()` |
|
||||
| CLI entry point | `fabledscryer/cli.py` | `main()` |
|
||||
| Bootstrap config loading | `fabledscryer/config.py` | `load_bootstrap()` |
|
||||
| Secret key resolution | `fabledscryer/config.py` | `_resolve_secret_key()` |
|
||||
| DB engine init | `fabledscryer/database.py` | `init_db()` |
|
||||
| Core migrations | `fabledscryer/core/migration_runner.py` | `run_core_migrations()` |
|
||||
| Plugin migrations | `fabledscryer/core/migration_runner.py` | `run_plugin_migrations()` |
|
||||
| Core task registration | `fabledscryer/app.py` | `_register_core_tasks()` |
|
||||
| Scheduler loop | `fabledscryer/core/scheduler.py` | `start_scheduler()` |
|
||||
|
||||
---
|
||||
|
||||
## Settings System
|
||||
|
||||
| What | File | Function |
|
||||
|---|---|---|
|
||||
| All defaults | `fabledscryer/core/settings.py` | `DEFAULTS` dict |
|
||||
| Read a setting | `fabledscryer/core/settings.py` | `get_setting(session, key)` |
|
||||
| Write a setting | `fabledscryer/core/settings.py` | `set_setting(session, key, value)` |
|
||||
| Read all settings | `fabledscryer/core/settings.py` | `get_all_settings(session)` |
|
||||
| Sync load at startup | `fabledscryer/core/settings.py` | `load_settings_sync(db_url)` |
|
||||
| Extract SMTP dict | `fabledscryer/core/settings.py` | `to_smtp_cfg(settings)` |
|
||||
| Extract webhook dict | `fabledscryer/core/settings.py` | `to_webhook_cfg(settings)` |
|
||||
| Extract Ansible dict | `fabledscryer/core/settings.py` | `to_ansible_cfg(settings)` |
|
||||
| Extract plugins dict | `fabledscryer/core/settings.py` | `to_plugins_cfg(settings)` |
|
||||
|
||||
---
|
||||
|
||||
## Plugin System
|
||||
|
||||
| What | File | Function |
|
||||
|---|---|---|
|
||||
| Plugin loading | `fabledscryer/core/plugin_manager.py` | `load_plugins(app)` |
|
||||
| ScheduledTask dataclass | `fabledscryer/core/scheduler.py` | `ScheduledTask` |
|
||||
| Task runner | `fabledscryer/core/scheduler.py` | `start_scheduler(tasks)` |
|
||||
|
||||
---
|
||||
|
||||
## Alert Pipeline
|
||||
|
||||
| What | File | Function |
|
||||
|---|---|---|
|
||||
| Write metric + evaluate alerts | `fabledscryer/core/alerts.py` | `record_metric(session, source_module, resource_name, metric_name, value)` |
|
||||
| Init alert pipeline | `fabledscryer/core/alerts.py` | `init_alerts(app)` |
|
||||
| Rule evaluation | `fabledscryer/core/alerts.py` | `_evaluate_rule()` (internal) |
|
||||
| Notification dispatch | `fabledscryer/core/alerts.py` | `_dispatch_notification()` (internal) |
|
||||
| Email + webhook send | `fabledscryer/core/notifications.py` | `dispatch_notifications()` |
|
||||
|
||||
---
|
||||
|
||||
## Monitors
|
||||
|
||||
| What | File | Function |
|
||||
|---|---|---|
|
||||
| Ping a host | `fabledscryer/monitors/ping.py` | `ping_check(host, session)` |
|
||||
| DNS check a host | `fabledscryer/monitors/dns.py` | `dns_check(host, session)` |
|
||||
| Data cleanup | `fabledscryer/core/cleanup.py` | `run_cleanup(app)` |
|
||||
|
||||
---
|
||||
|
||||
## Auth
|
||||
|
||||
| What | File | Function / Class |
|
||||
|---|---|---|
|
||||
| Role-based access decorator | `fabledscryer/auth/middleware.py` | `@require_role(UserRole.X)` |
|
||||
| Login / session handling | `fabledscryer/auth/middleware.py` | `login_user()`, `logout_user()` |
|
||||
| User count (for first-run) | `fabledscryer/auth/middleware.py` | `get_user_count(app)` |
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
| Model | File | Table |
|
||||
|---|---|---|
|
||||
| `Host` | `fabledscryer/models/hosts.py` | `hosts` |
|
||||
| `PingResult` | `fabledscryer/models/monitors.py` | `ping_results` |
|
||||
| `DnsResult` | `fabledscryer/models/monitors.py` | `dns_results` |
|
||||
| `AlertRule` | `fabledscryer/models/alerts.py` | `alert_rules` |
|
||||
| `AlertState` | `fabledscryer/models/alerts.py` | `alert_states` |
|
||||
| `AlertEvent` | `fabledscryer/models/alerts.py` | `alert_events` |
|
||||
| `PluginMetric` | `fabledscryer/models/metrics.py` | `plugin_metrics` |
|
||||
| `AnsibleRun` | `fabledscryer/models/ansible.py` | `ansible_runs` |
|
||||
| `User` | `fabledscryer/models/users.py` | `users` |
|
||||
| `AppSetting` | `fabledscryer/models/settings.py` | `app_settings` |
|
||||
| `TraefikMetric` | `plugins/traefik/models.py` | `traefik_metrics` |
|
||||
| SQLAlchemy `Base` | `fabledscryer/models/base.py` | (shared declarative base) |
|
||||
|
||||
---
|
||||
|
||||
## HTTP Routes
|
||||
|
||||
| URL pattern | Blueprint | File |
|
||||
|---|---|---|
|
||||
| `/` (dashboard) | `dashboard_bp` | `fabledscryer/dashboard/routes.py` |
|
||||
| `/auth/login`, `/auth/logout` | `auth_bp` | `fabledscryer/auth/routes.py` |
|
||||
| `/hosts/` | `hosts_bp` | `fabledscryer/hosts/routes.py` |
|
||||
| `/ping/`, `/ping/rows`, `/ping/settings` | `ping_bp` | `fabledscryer/ping/routes.py` |
|
||||
| `/dns/`, `/dns/rows` | `dns_bp` | `fabledscryer/dns/routes.py` |
|
||||
| `/alerts/` | `alerts_bp` | `fabledscryer/alerts/routes.py` |
|
||||
| `/ansible/` | `ansible_bp` | `fabledscryer/ansible/routes.py` |
|
||||
| `/settings/` | `settings_bp` | `fabledscryer/settings/routes.py` |
|
||||
| `/plugins/traefik/`, `/plugins/traefik/widget` | `traefik_bp` | `plugins/traefik/routes.py` |
|
||||
| `/health` | (inline) | `fabledscryer/app.py` |
|
||||
|
||||
---
|
||||
|
||||
## Templates
|
||||
|
||||
| Template | Purpose |
|
||||
|---|---|
|
||||
| `fabledscryer/templates/base.html` | Layout, navigation, full CSS design system |
|
||||
| `fabledscryer/templates/dashboard/index.html` | Dashboard with stat strip and widget grid |
|
||||
| `fabledscryer/templates/ping/rows.html` | HTMX fragment: ping pill rows (shared by dashboard and /ping/) |
|
||||
| `fabledscryer/templates/ping/index.html` | Full /ping/ page |
|
||||
| `fabledscryer/templates/dns/rows.html` | HTMX fragment: DNS status rows |
|
||||
| `fabledscryer/templates/dns/index.html` | Full /dns/ page |
|
||||
| `plugins/traefik/templates/traefik/widget.html` | HTMX fragment: Traefik dashboard widget |
|
||||
| `plugins/traefik/templates/traefik/index.html` | Full Traefik detail page |
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
| Location | Covers |
|
||||
|---|---|
|
||||
| `fabledscryer/migrations/versions/` | Core schema (hosts, users, monitors, alerts, app_settings) |
|
||||
| `plugins/traefik/migrations/versions/` | `traefik_metrics` table |
|
||||
| `alembic.ini` | Alembic config; `version_locations` lists all migration directories |
|
||||
Reference in New Issue
Block a user