230b542015
- 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>
106 lines
4.6 KiB
Markdown
106 lines
4.6 KiB
Markdown
# 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.
|