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
+105
View File
@@ -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.
+138
View File
@@ -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
+143
View File
@@ -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 |
+103
View File
@@ -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 |
+104
View File
@@ -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 |
+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`
+138
View File
@@ -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 |