Files
FabledSteward/docs/architecture.md
T
bvandeusen 230b542015 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>
2026-03-22 18:27:56 -04:00

4.6 KiB

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:

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:

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