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
+3 -3
View File
@@ -1,6 +1,6 @@
# Copy to .env for Docker / local development secrets
# These override values in config.yaml
FABLEDNETMON_SECRET_KEY=change-me
FABLEDNETMON_DATABASE__URL=postgresql+asyncpg://fablednetmon:password@localhost/fablednetmon
FABLEDNETMON_SMTP__PASSWORD=
FABLEDSCRYER_SECRET_KEY=change-me
FABLEDSCRYER_DATABASE__URL=postgresql+asyncpg://fabledscryer:password@localhost/fabledscryer
FABLEDSCRYER_SMTP__PASSWORD=
+3 -3
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim
FROM python:3.13-slim
RUN DEBIAN_FRONTEND=noninteractive apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iputils-ping \
@@ -10,7 +10,7 @@ RUN pip install --upgrade pip
WORKDIR /app
COPY pyproject.toml .
COPY fablednetmon/ fablednetmon/
COPY fabledscryer/ fabledscryer/
RUN pip install --no-cache-dir .
COPY alembic.ini .
@@ -24,4 +24,4 @@ USER app
EXPOSE 5000
CMD ["fablednetmon", "--host", "0.0.0.0", "--port", "5000"]
CMD ["fabledscryer", "--host", "0.0.0.0", "--port", "5000"]
+89
View File
@@ -0,0 +1,89 @@
# Fabled Scryer
A self-hosted network monitoring and infrastructure management hub for home servers. Fabled Scryer gives you a single pane of glass over your hosts, services, and automation — with live-updating dashboards, alerting, and Ansible integration.
---
## What It Does
- **Ping monitoring** — TCP/ICMP probes with live latency history and configurable thresholds
- **DNS monitoring** — resolution checks with optional expected-IP validation
- **Ansible** — browse playbooks, trigger runs, stream output live
- **Alerting** — threshold-based rules against any monitored metric, with email and webhook (Discord-compatible) notifications
- **Plugins** — extend with additional data sources; Traefik metrics included out of the box
- **Dashboard widgets** — all monitors and plugins contribute live-updating widgets
No JavaScript framework. No build step. No external workers or message brokers.
---
## Quick Start — Docker
```bash
cp .env.example .env
# Set FABLEDSCRYER_DATABASE_URL in .env
docker compose up -d
```
Open `http://localhost:5000`. On first run you'll be prompted to create the admin account.
---
## Quick Start — Bare Metal
Requires Python 3.11+ and PostgreSQL.
```bash
python -m venv .venv && source .venv/bin/activate
pip install -e .
cp config.example.yaml config.yaml
# Edit config.yaml — set database.url at minimum
fabledscryer --host 0.0.0.0 --port 5000
```
ICMP ping requires `CAP_NET_RAW` or the `setuid` ping binary. TCP mode (the default) needs no elevated privileges.
---
## Configuration
Only two things must be set to run the app:
| What | How |
|---|---|
| Database URL | `FABLEDSCRYER_DATABASE_URL` env var or `database.url` in `config.yaml` |
| Secret key | Auto-generated on first run and saved to `/data/secret.key` |
Everything else — SMTP, webhooks, monitor intervals, Ansible sources, plugin settings — is configured through the web UI at `/settings/`.
---
## Plugins
Drop a directory into `plugins/` and enable it via the Settings UI. The Traefik plugin is included:
```
plugins/
└── traefik/ ← included; enable in Settings
```
See `docs/plugins/` for a full plugin development guide.
---
## Documentation
| Document | Contents |
|---|---|
| `docs/architecture.md` | How the app works: startup sequence, routing, scheduler, DB pattern |
| `docs/core/configuration.md` | Bootstrap config, DB-backed settings, all setting keys and defaults |
| `docs/core/monitors.md` | Ping and DNS monitors: probe logic, metrics emitted, data models |
| `docs/core/alerting.md` | Alert rules, state machine, notification channels, template variables |
| `docs/core/ansible.md` | Playbook sources, run lifecycle, SSE streaming |
| `docs/plugins/overview.md` | Plugin system: how loading works, plugin.yaml schema, required exports |
| `docs/plugins/writing-a-plugin.md` | Step-by-step plugin development guide |
| `docs/plugins/traefik.md` | Traefik plugin: config, metrics, alert rule examples |
| `docs/reference/code-map.md` | Where every key function, model, and route lives in the codebase |
+1 -1
View File
@@ -1,5 +1,5 @@
[alembic]
script_location = fablednetmon/migrations
script_location = fabledscryer/migrations
prepend_sys_path = .
version_path_separator = os
+3 -3
View File
@@ -1,9 +1,9 @@
# FabledNetMon — Bootstrap Configuration Example
# Fabled Scryer — Bootstrap Configuration Example
#
# The only REQUIRED setting is the database URL.
# Set it via env var (recommended for Docker):
#
# FABLEDNETMON_DATABASE_URL=postgresql+asyncpg://user:pass@host/db
# FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://user:pass@host/db
#
# All other settings (SMTP, webhook, ansible, plugins, retention)
# are stored in the database and managed via the Settings UI at /settings/.
@@ -11,7 +11,7 @@
# config.yaml is optional. Use it only if you prefer file-based bootstrap.
database:
url: "postgresql+asyncpg://fablednetmon:password@localhost/fablednetmon"
url: "postgresql+asyncpg://fabledscryer:password@localhost/fabledscryer"
# Optional: override the auto-generated secret key.
# If not set, a key is auto-generated on first run and saved to /data/secret.key.
+8 -5
View File
@@ -5,8 +5,11 @@ services:
- "5000:5000"
volumes:
- app_data:/data
- ./plugins:/app/plugins:ro
# Uncomment to enable Traefik access log ingestion (set access_log.enabled: true in plugin config):
- /mnt/Data/traefik/log:/var/log/traefik:ro
environment:
- FABLEDNETMON_DATABASE_URL=postgresql+asyncpg://fablednetmon:fablednetmon@db/fablednetmon
- FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@db/fabledscryer
depends_on:
db:
condition: service_healthy
@@ -15,13 +18,13 @@ services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: fablednetmon
POSTGRES_PASSWORD: fablednetmon
POSTGRES_DB: fablednetmon
POSTGRES_USER: fabledscryer
POSTGRES_PASSWORD: fabledscryer
POSTGRES_DB: fabledscryer
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fablednetmon"]
test: ["CMD-SHELL", "pg_isready -U fabledscryer"]
interval: 5s
timeout: 5s
retries: 5
+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 |
-129
View File
@@ -1,129 +0,0 @@
# fablednetmon/core/plugin_manager.py
from __future__ import annotations
import importlib
import logging
import sys
from pathlib import Path
from typing import TYPE_CHECKING
import yaml
from packaging.version import Version
if TYPE_CHECKING:
from quart import Quart
logger = logging.getLogger(__name__)
def load_plugins(app: "Quart") -> None:
"""Import all enabled plugins and register their blueprints and scheduled tasks.
For each plugin listed as enabled in config.yaml under 'plugins':
1. Locate the plugin directory under PLUGIN_DIR
2. Load and validate plugin.yaml (name must match dir, min_app_version check)
3. Merge plugin.yaml config defaults with user overrides in app.config["PLUGINS"]
4. Import the plugin Python package
5. Validate required exports: setup() and get_scheduled_tasks()
6. Call setup(app)
7. Register blueprint at /plugins/<name>/ if get_blueprint() exists
8. Append get_scheduled_tasks() results to app._task_registry
"""
import fablednetmon
plugin_dir = Path(app.config["PLUGIN_DIR"])
plugins_cfg: dict = app.config["PLUGINS"]
# Ensure plugin_dir is on sys.path so plugins are importable by name
plugin_dir_str = str(plugin_dir.resolve())
if plugin_dir_str not in sys.path:
sys.path.insert(0, plugin_dir_str)
for name, cfg in list(plugins_cfg.items()):
if not cfg.get("enabled", False):
continue
plugin_path = plugin_dir / name
if not plugin_path.exists():
logger.error("Plugin %r: directory %s not found, skipping", name, plugin_path)
continue
# Load and validate plugin.yaml
yaml_path = plugin_path / "plugin.yaml"
if not yaml_path.exists():
logger.error("Plugin %r: plugin.yaml not found, skipping", name)
continue
try:
with yaml_path.open() as f:
meta = yaml.safe_load(f) or {}
except Exception:
logger.exception("Plugin %r: failed to read plugin.yaml, skipping", name)
continue
if meta.get("name") != name:
logger.error(
"Plugin %r: plugin.yaml name=%r does not match directory name, skipping",
name, meta.get("name"),
)
continue
min_ver = meta.get("min_app_version")
if min_ver:
try:
if Version(fablednetmon.__version__) < Version(min_ver):
logger.error(
"Plugin %r: requires fablednetmon>=%s but running %s, skipping",
name, min_ver, fablednetmon.__version__,
)
continue
except Exception:
logger.exception("Plugin %r: invalid min_app_version %r, skipping", name, min_ver)
continue
# Merge plugin.yaml config defaults with user overrides (non-"enabled" keys)
defaults = meta.get("config", {})
user_overrides = {k: v for k, v in cfg.items() if k != "enabled"}
plugins_cfg[name] = {"enabled": True, **defaults, **user_overrides}
# Import the plugin package
try:
module = importlib.import_module(name)
except ImportError:
logger.exception("Plugin %r: import failed, skipping", name)
continue
# Validate required exports
if not hasattr(module, "setup"):
logger.error("Plugin %r: missing required export 'setup', skipping", name)
continue
if not hasattr(module, "get_scheduled_tasks"):
logger.error(
"Plugin %r: missing required export 'get_scheduled_tasks', skipping", name
)
continue
# Call setup
try:
module.setup(app)
except Exception:
logger.exception("Plugin %r: setup() raised, skipping", name)
continue
# Register blueprint (optional)
if hasattr(module, "get_blueprint"):
try:
bp = module.get_blueprint()
app.register_blueprint(bp, url_prefix=f"/plugins/{name}")
logger.info("Plugin %r: blueprint registered at /plugins/%s/", name, name)
except Exception:
logger.exception("Plugin %r: get_blueprint() raised", name)
# Register scheduled tasks
try:
tasks = module.get_scheduled_tasks()
app._task_registry.extend(tasks)
logger.info("Plugin %r: registered %d scheduled task(s)", name, len(tasks))
except Exception:
logger.exception("Plugin %r: get_scheduled_tasks() raised", name)
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
-61
View File
@@ -1,61 +0,0 @@
from __future__ import annotations
from quart import Blueprint, render_template, current_app
from sqlalchemy import select, func
from fablednetmon.auth.middleware import require_role
from fablednetmon.models.users import UserRole
from fablednetmon.models.hosts import Host
from fablednetmon.models.monitors import PingResult, DnsResult
dashboard_bp = Blueprint("dashboard", __name__)
@dashboard_bp.get("/")
@require_role(UserRole.viewer)
async def index():
async with current_app.db_sessionmaker() as db:
# Latest ping per host
latest_ping_subq = (
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
.group_by(PingResult.host_id).subquery()
)
pr = await db.execute(
select(PingResult).join(
latest_ping_subq,
(PingResult.host_id == latest_ping_subq.c.host_id)
& (PingResult.probed_at == latest_ping_subq.c.max_at),
)
)
latest_pings = list(pr.scalars())
ping_up = sum(1 for p in latest_pings if p.status.value == "up")
ping_down = sum(1 for p in latest_pings if p.status.value == "down")
# Latest DNS per host
latest_dns_subq = (
select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at"))
.group_by(DnsResult.host_id).subquery()
)
dr = await db.execute(
select(DnsResult).join(
latest_dns_subq,
(DnsResult.host_id == latest_dns_subq.c.host_id)
& (DnsResult.resolved_at == latest_dns_subq.c.max_at),
)
)
latest_dns = list(dr.scalars())
dns_ok = sum(1 for d in latest_dns if d.status.value == "resolved")
dns_fail = sum(1 for d in latest_dns if d.status.value != "resolved")
total_hosts = (await db.execute(select(func.count()).select_from(Host))).scalar()
plugins = current_app.config.get("PLUGINS", {})
traefik_enabled = plugins.get("traefik", {}).get("enabled", False)
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
return await render_template(
"dashboard/index.html",
ping_up=ping_up, ping_down=ping_down,
dns_ok=dns_ok, dns_fail=dns_fail,
total_hosts=total_hosts,
traefik_enabled=traefik_enabled,
poll_interval=poll_interval,
)
-1
View File
@@ -1 +0,0 @@
# fablednetmon/settings/__init__.py
-142
View File
@@ -1,142 +0,0 @@
# fablednetmon/settings/routes.py
from __future__ import annotations
import json
import logging
from pathlib import Path
from quart import Blueprint, current_app, render_template, request, redirect, url_for
from fablednetmon.auth.middleware import require_role
from fablednetmon.models.users import UserRole
from fablednetmon.core.settings import (
DEFAULTS, get_all_settings, set_setting,
to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg,
)
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
logger = logging.getLogger(__name__)
def _discover_plugins() -> list[dict]:
"""Scan PLUGIN_DIR for plugin.yaml files. Returns list of plugin metadata dicts."""
import yaml
plugin_dir = Path(current_app.config.get("PLUGIN_DIR", "plugins")).resolve()
plugins = []
if not plugin_dir.exists():
return plugins
for entry in sorted(plugin_dir.iterdir()):
yaml_path = entry / "plugin.yaml"
if entry.is_dir() and yaml_path.exists():
try:
with yaml_path.open() as f:
meta = yaml.safe_load(f) or {}
meta["_dir"] = entry.name
plugins.append(meta)
except Exception:
logger.warning("Could not read %s", yaml_path)
return plugins
@settings_bp.get("/")
@require_role(UserRole.admin)
async def index():
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
discovered_plugins = _discover_plugins()
# Merge current plugin settings from DB into discovered plugin list
plugins_cfg = to_plugins_cfg(settings)
for plugin in discovered_plugins:
name = plugin["_dir"]
stored = plugins_cfg.get(name, {})
plugin["_enabled"] = stored.get("enabled", False)
# Merge config defaults with stored overrides for display
defaults = plugin.get("config", {})
plugin["_config"] = {**defaults, **{k: v for k, v in stored.items() if k != "enabled"}}
return await render_template(
"settings/index.html",
settings=settings,
discovered_plugins=discovered_plugins,
)
@settings_bp.post("/")
@require_role(UserRole.admin)
async def save():
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
# General settings
await set_setting(db, "session.lifetime_hours",
int(form.get("session.lifetime_hours", 8)))
await set_setting(db, "data.retention_days",
int(form.get("data.retention_days", 90)))
await set_setting(db, "monitors.poll_interval_seconds",
int(form.get("monitors.poll_interval_seconds", 60)))
# SMTP
recipients_raw = form.get("smtp.recipients", "")
recipients = [r.strip() for r in recipients_raw.split(",") if r.strip()]
await set_setting(db, "smtp.host", form.get("smtp.host", ""))
await set_setting(db, "smtp.port", int(form.get("smtp.port", 587)))
await set_setting(db, "smtp.tls", "smtp.tls" in form)
await set_setting(db, "smtp.username", form.get("smtp.username", ""))
if form.get("smtp.password"):
await set_setting(db, "smtp.password", form.get("smtp.password"))
await set_setting(db, "smtp.recipients", recipients)
# Webhook
await set_setting(db, "webhook.url", form.get("webhook.url", ""))
await set_setting(db, "webhook.template", form.get("webhook.template", ""))
# Ansible sources (JSON textarea)
sources_raw = form.get("ansible.sources", "[]")
try:
sources = json.loads(sources_raw)
if not isinstance(sources, list):
sources = []
except json.JSONDecodeError:
sources = []
await set_setting(db, "ansible.sources", sources)
# Build per-plugin config dicts from form fields
discovered = _discover_plugins()
for plugin in discovered:
name = plugin["_dir"]
enabled = f"plugin.{name}.enabled" in form
plugin_cfg: dict = {"enabled": enabled}
for cfg_key in plugin.get("config", {}).keys():
field_name = f"plugin.{name}.{cfg_key}"
if field_name in form:
raw_val = form[field_name]
# Coerce to the default type
default_val = plugin["config"][cfg_key]
if isinstance(default_val, bool):
plugin_cfg[cfg_key] = raw_val.lower() in ("1", "true", "yes", "on")
elif isinstance(default_val, int):
try:
plugin_cfg[cfg_key] = int(raw_val)
except ValueError:
plugin_cfg[cfg_key] = default_val
else:
plugin_cfg[cfg_key] = raw_val
await set_setting(db, f"plugin.{name}", plugin_cfg)
# Live-update app.config so settings take effect without restart
from fablednetmon.core.settings import get_all_settings as _get_all
async with current_app.db_sessionmaker() as db:
fresh = await _get_all(db)
current_app.config.update(
SESSION_LIFETIME_HOURS=fresh.get("session.lifetime_hours", 8),
DATA_RETENTION_DAYS=fresh.get("data.retention_days", 90),
MONITORS_POLL_INTERVAL=fresh.get("monitors.poll_interval_seconds", 60),
SMTP=to_smtp_cfg(fresh),
WEBHOOK=to_webhook_cfg(fresh),
ANSIBLE=to_ansible_cfg(fresh),
PLUGINS=to_plugins_cfg(fresh),
)
return redirect(url_for("settings.index"))
-158
View File
@@ -1,158 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}FabledNetMon{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<style>
:root {
--bg: #09091a;
--bg-card: #111126;
--bg-elevated: #17173a;
--border: #21213d;
--border-mid: #2a2a4a;
--text: #d4d8f0;
--text-muted: #6870a0;
--text-dim: #30305a;
--accent: #5b5ef4;
--accent-hover: #7274f8;
--green: #26d96b;
--green-dim: #0e3a1e;
--yellow: #e6b818;
--yellow-dim: #3a3000;
--orange: #e07828;
--orange-dim: #3a1e00;
--red: #e83848;
--red-dim: #3a0e14;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
/* Nav */
nav { background: var(--bg-card); padding: 0 1.5rem; display: flex; align-items: center; gap: 0; border-bottom: 1px solid var(--border-mid); height: 48px; }
nav .brand { font-weight: 700; color: #a0a8f8; font-size: 1rem; margin-right: 2rem; letter-spacing: -0.02em; }
nav a { color: var(--text-muted); font-size: 0.875rem; padding: 0 0.875rem; height: 48px; display: flex; align-items: center; border-bottom: 2px solid transparent; transition: color .15s, border-color .15s; }
nav a:hover { color: var(--text); border-bottom-color: var(--border-mid); }
nav a.nav-end { margin-left: auto; }
/* Layout */
main { padding: 1.5rem; max-width: 1280px; margin: 0 auto; }
/* Alerts */
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
.alert-error { background: var(--red-dim); border: 1px solid #6a1820; color: #ffb0b8; }
.alert-success { background: var(--green-dim); border: 1px solid #1a5a28; color: #90f0b0; }
/* Cards */
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 1rem; }
/* Typography */
.page-title { font-size: 1.5rem; font-weight: 600; color: #b0b8f8; margin-bottom: 1.5rem; letter-spacing: -0.02em; }
.section-title { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
/* Tables */
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 0.6rem 1rem; color: var(--text-muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-mid); }
.table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
.table tbody tr:last-child td { border-bottom: none; }
.table tbody tr:hover td { background: var(--bg-elevated); }
.table .td-actions { text-align: right; white-space: nowrap; }
/* Forms */
.form-group { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.3rem; font-size: 0.85rem; color: var(--text-muted); }
input[type=text], input[type=email], input[type=password], input[type=number], input[type=url], select, textarea {
width: 100%; padding: 0.5rem 0.75rem; background: var(--bg); border: 1px solid var(--border-mid);
border-radius: 5px; color: var(--text); font-size: 0.9rem; font-family: inherit;
transition: border-color .15s;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
select { cursor: pointer; }
textarea { resize: vertical; }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 1rem; background: var(--accent); color: #fff; border: none; border-radius: 5px; cursor: pointer; font-size: 0.875rem; font-family: inherit; font-weight: 500; transition: background .15s; text-decoration: none; }
.btn:hover { background: var(--accent-hover); color: #fff; }
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-mid); }
.btn-ghost:hover { background: var(--bg-elevated); color: var(--text); }
.btn-danger { background: #7a1820; color: #ffb0b0; }
.btn-danger:hover { background: #9a2030; }
.btn-warn { background: var(--yellow-dim); color: var(--yellow); border: 1px solid #6a5000; }
.btn-warn:hover { background: #4a4000; }
/* Badges / Status */
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 4px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.03em; }
.badge-green { background: var(--green-dim); color: var(--green); }
.badge-red { background: var(--red-dim); color: #ff8090; }
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
.badge-orange { background: var(--orange-dim); color: var(--orange); }
.badge-dim { background: var(--bg-elevated); color: var(--text-muted); }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-up { background: var(--green); box-shadow: 0 0 4px var(--green); }
.dot-down { background: var(--red); }
.dot-warn { background: var(--yellow); }
.dot-dim { background: var(--text-dim); }
/* Empty state */
.empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
/* Stat cards (dashboard) */
.stat-val { font-size: 2rem; font-weight: 700; line-height: 1; }
.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-left: 0.3rem; }
/* Ping pills */
.ping-card { padding: 0.75rem 1.25rem; }
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid var(--border); }
.ping-row:last-child { border-bottom:none; }
.ping-meta { min-width:160px; flex-shrink:0; }
.ping-name { font-size:0.875rem; font-weight: 500; }
.ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; }
.ping-pills { display:flex; gap:2px; flex:1; align-items:center; }
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; cursor:default; transition:opacity .1s; }
.pill:hover { opacity:.75; }
.pill-empty { background:var(--bg-elevated); }
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
.ping-cur-good { color:var(--green); }
.ping-cur-warn { color:var(--yellow); }
.ping-cur-bad { color:var(--orange); }
.ping-cur-down { color:var(--red); font-weight:bold; }
.ping-cur-nd { color:var(--text-dim); }
/* Widget headers */
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
.widget-title { font-size:0.8rem; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; }
.widget-link { font-size:0.8rem; color:var(--text-muted); }
.widget-link:hover { color:var(--text); }
</style>
{% block head %}{% endblock %}
</head>
<body>
{% if session.user_id is defined %}
<nav>
<span class="brand">FabledNetMon</span>
<a href="/">Dashboard</a>
<a href="/hosts/">Hosts</a>
<a href="/ping/">Ping</a>
<a href="/dns/">DNS</a>
<a href="/alerts/">Alerts</a>
<a href="/ansible/">Ansible</a>
{% if session.user_role == 'admin' %}
<a href="/settings/">Settings</a>
{% endif %}
<a href="/logout" class="nav-end">{{ session.username }}</a>
</nav>
{% endif %}
<main>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -1,83 +0,0 @@
{% extends "base.html" %}
{% block title %}Dashboard — FabledNetMon{% endblock %}
{% block content %}
<h1 class="page-title">Dashboard</h1>
{# ── Summary strip ───────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Hosts</div>
<span class="stat-val">{{ total_hosts }}</span>
<span class="stat-label">total</span>
<div style="margin-top:0.5rem;"><a href="/hosts/" style="font-size:0.8rem;">Manage →</a></div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Ping</div>
{% if ping_up + ping_down == 0 %}
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
{% else %}
<span class="stat-val" style="color:var(--green);">{{ ping_up }}</span><span class="stat-label">up</span>
{% if ping_down %}&nbsp;<span class="stat-val" style="color:var(--red);">{{ ping_down }}</span><span class="stat-label">down</span>{% endif %}
{% endif %}
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">DNS</div>
{% if dns_ok + dns_fail == 0 %}
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
{% else %}
<span class="stat-val" style="color:var(--green);">{{ dns_ok }}</span><span class="stat-label">ok</span>
{% if dns_fail %}&nbsp;<span class="stat-val" style="color:var(--red);">{{ dns_fail }}</span><span class="stat-label">failed</span>{% endif %}
{% endif %}
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Alerts</div>
<a href="/alerts/" style="font-size:0.85rem;">View rules →</a>
</div>
</div>
{# ── Widget grid ─────────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(400px,1fr));gap:1rem;">
{# Ping widget #}
<div class="card ping-card" style="margin-bottom:0;">
<div class="widget-header">
<span class="widget-title">Ping — last 30 probes</span>
<a href="/ping/" class="widget-link">Details &amp; settings →</a>
</div>
<div id="ping-widget"
hx-get="/ping/rows"
hx-trigger="load, every {{ poll_interval }}s"
hx-swap="innerHTML">
</div>
</div>
{# DNS widget #}
<div class="card ping-card" style="margin-bottom:0;">
<div class="widget-header">
<span class="widget-title">DNS</span>
<a href="/dns/" class="widget-link">Details →</a>
</div>
<div id="dns-widget"
hx-get="/dns/rows"
hx-trigger="load, every {{ poll_interval }}s"
hx-swap="innerHTML">
</div>
</div>
{# Traefik widget — only if plugin enabled #}
{% if traefik_enabled %}
<div class="card ping-card" style="margin-bottom:0;">
<div class="widget-header">
<span class="widget-title">Traefik</span>
<a href="/plugins/traefik/" class="widget-link">Details →</a>
</div>
<div id="traefik-widget"
hx-get="/plugins/traefik/widget"
hx-trigger="load, every {{ poll_interval }}s"
hx-swap="innerHTML">
</div>
</div>
{% endif %}
</div>
{% endblock %}
@@ -2,9 +2,9 @@ from __future__ import annotations
from datetime import datetime, timezone
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
from sqlalchemy import select
from fablednetmon.auth.middleware import require_role
from fablednetmon.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator
from fablednetmon.models.users import UserRole
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator
from fabledscryer.models.users import UserRole
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
@@ -1,4 +1,4 @@
# fablednetmon/ansible/executor.py
# fabledscryer/ansible/executor.py
from __future__ import annotations
import asyncio
import logging
@@ -77,7 +77,7 @@ async def start_run(
async def _flush(final_status: str | None = None) -> None:
nonlocal lines_since_flush, last_flush_at
from sqlalchemy import update
from fablednetmon.models.ansible import AnsibleRun, AnsibleRunStatus
from fabledscryer.models.ansible import AnsibleRun, AnsibleRunStatus
values: dict = {"output": db_output}
if final_status is not None:
@@ -1,4 +1,4 @@
# fablednetmon/ansible/routes.py
# fabledscryer/ansible/routes.py
from __future__ import annotations
import asyncio
import uuid
@@ -11,10 +11,10 @@ from quart import (
)
from sqlalchemy import select, update
from fablednetmon.ansible import executor, sources as src_module
from fablednetmon.auth.middleware import require_role
from fablednetmon.models.ansible import AnsibleRun, AnsibleRunStatus
from fablednetmon.models.users import UserRole
from fabledscryer.ansible import executor, sources as src_module
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.ansible import AnsibleRun, AnsibleRunStatus
from fabledscryer.models.users import UserRole
ansible_bp = Blueprint("ansible", __name__, url_prefix="/ansible")
@@ -17,7 +17,7 @@ def get_sources(ansible_cfg: dict) -> list[dict]:
Config structure in config.yaml::
ansible:
cache_dir: /var/cache/fablednetmon/ansible
cache_dir: /var/cache/fabledscryer/ansible
sources:
- name: my-playbooks
type: local
@@ -29,7 +29,7 @@ def get_sources(ansible_cfg: dict) -> list[dict]:
pull_interval_seconds: 3600
"""
sources = ansible_cfg.get("sources", [])
cache_dir = ansible_cfg.get("cache_dir", "/var/cache/fablednetmon/ansible")
cache_dir = ansible_cfg.get("cache_dir", "/var/cache/fabledscryer/ansible")
result = []
for src in sources:
src_type = src.get("type", "local")
+41 -9
View File
@@ -1,4 +1,4 @@
# fablednetmon/app.py
# fabledscryer/app.py
from __future__ import annotations
import asyncio
from pathlib import Path
@@ -40,7 +40,10 @@ def create_app(
# ── 3. Core migrations only (creates app_settings table) ──────────────────
if not testing:
from .core.migration_runner import run_core_migrations
run_core_migrations(app.config["DATABASE_URL"])
run_core_migrations(
app.config["DATABASE_URL"],
plugin_dir=Path(app.config["PLUGIN_DIR"]).resolve(),
)
# ── 4. Load all settings from DB → populate app.config ────────────────────
if not testing:
@@ -69,14 +72,15 @@ def create_app(
PLUGINS={},
)
# ── 5. Plugin migrations (depends on knowing which plugins are enabled) ────
# ── 5. Plugin migrations ───────────────────────────────────────────────────
# Step 3 already applied all plugin migrations via discover_all_plugin_migration_dirs,
# so this is a no-op on normal startup. We still run it with all discovered dirs so
# Alembic can resolve the full revision graph regardless of which plugins are enabled.
if not testing:
from .core.migration_runner import run_plugin_migrations, get_plugin_migration_dirs
from .core.migration_runner import run_plugin_migrations, discover_all_plugin_migration_dirs
_plugin_dir = Path(app.config["PLUGIN_DIR"]).resolve()
_plugin_migration_dirs = get_plugin_migration_dirs(
_plugin_dir, app.config["PLUGINS"]
)
run_plugin_migrations(app.config["DATABASE_URL"], _plugin_migration_dirs)
_all_plugin_dirs = discover_all_plugin_migration_dirs(_plugin_dir)
run_plugin_migrations(app.config["DATABASE_URL"], _all_plugin_dirs)
# ── 6. Alert pipeline ──────────────────────────────────────────────────────
from .core.alerts import init_alerts
@@ -112,7 +116,35 @@ def create_app(
from .core.plugin_manager import load_plugins
load_plugins(app)
# ── 10. Health check ───────────────────────────────────────────────────────
# ── 10. Share-token middleware ─────────────────────────────────────────────
@app.before_request
async def _validate_share_token():
"""If ?s=<token> is present, validate it and set g.share_viewer."""
from quart import request, g
raw = request.args.get("s")
if not raw:
return
try:
import hashlib
from datetime import datetime, timezone
from sqlalchemy import select
from .models.dashboard import DashboardShareToken
token_hash = hashlib.sha256(raw.encode()).hexdigest()
async with app.db_sessionmaker() as db:
result = await db.execute(
select(DashboardShareToken)
.where(DashboardShareToken.token_hash == token_hash)
)
share = result.scalar_one_or_none()
if share:
now = datetime.now(timezone.utc)
if share.expires_at is None or share.expires_at > now:
g.share_viewer = True
g.share_dashboard_id = share.dashboard_id
except Exception:
pass # never block a request due to token validation failure
# ── 11. Health check ───────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@@ -1,16 +1,21 @@
from __future__ import annotations
import functools
from quart import session, redirect, url_for, abort
from fablednetmon.models.users import UserRole
from quart import session, redirect, url_for, abort, g
from fabledscryer.models.users import UserRole
_ROLE_ORDER = [UserRole.viewer, UserRole.operator, UserRole.admin]
def require_role(minimum_role: UserRole):
"""Decorator: requires authenticated user with at least minimum_role."""
"""Decorator: requires authenticated user with at least minimum_role.
Also allows access for validated share-token requests (viewer level only).
"""
def decorator(f):
@functools.wraps(f)
async def wrapper(*args, **kwargs):
# Share-token requests bypass session auth for viewer-level endpoints
if getattr(g, "share_viewer", False) and minimum_role == UserRole.viewer:
return await f(*args, **kwargs)
user_id = session.get("user_id")
if not user_id:
return redirect(url_for("auth.login"))
@@ -2,8 +2,8 @@ from __future__ import annotations
import bcrypt
from quart import Blueprint, render_template, request, redirect, url_for, session
from sqlalchemy import select, func
from fablednetmon.auth.middleware import login_user, logout_user
from fablednetmon.models.users import User, UserRole
from fabledscryer.auth.middleware import login_user, logout_user
from fabledscryer.models.users import User, UserRole
auth_bp = Blueprint("auth", __name__)
+1 -1
View File
@@ -9,6 +9,6 @@ from .app import create_app
@click.option("--port", default=5000, type=int)
@click.option("--debug", is_flag=True, default=False)
def main(config: str, host: str, port: int, debug: bool) -> None:
"""Start the FabledNetMon server."""
"""Start the Fabled Scryer server."""
app = create_app(config_path=Path(config))
app.run(host=host, port=port, debug=debug)
@@ -1,4 +1,4 @@
# fablednetmon/config.py
# fabledscryer/config.py
from __future__ import annotations
import logging
import os
@@ -33,19 +33,19 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, str]:
raw = yaml.safe_load(f) or {}
database_url = (
os.environ.get("FABLEDNETMON_DATABASE_URL")
or os.environ.get("FABLEDNETMON_DATABASE__URL")
os.environ.get("FABLEDSCRYER_DATABASE_URL")
or os.environ.get("FABLEDSCRYER_DATABASE__URL")
or raw.get("database", {}).get("url")
)
if not database_url:
raise ValueError(
"Database URL is required. Set FABLEDNETMON_DATABASE_URL env var "
"Database URL is required. Set FABLEDSCRYER_DATABASE_URL env var "
"or add 'database.url' to config.yaml."
)
secret_key = _resolve_secret_key(raw)
plugin_dir = (
os.environ.get("FABLEDNETMON_PLUGIN_DIR")
os.environ.get("FABLEDSCRYER_PLUGIN_DIR")
or raw.get("plugin_dir", "plugins")
)
@@ -58,7 +58,7 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, str]:
def _resolve_secret_key(raw: dict) -> str:
"""Resolve secret_key: env var → file → auto-generate."""
from_env = os.environ.get("FABLEDNETMON_SECRET_KEY") or raw.get("secret_key")
from_env = os.environ.get("FABLEDSCRYER_SECRET_KEY") or raw.get("secret_key")
if from_env:
return from_env
@@ -8,14 +8,14 @@ from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fablednetmon.models.alerts import (
from fabledscryer.models.alerts import (
AlertEvent,
AlertOperator,
AlertRule,
AlertState,
AlertStateEnum,
)
from fablednetmon.models.metrics import PluginMetric
from fabledscryer.models.metrics import PluginMetric
if TYPE_CHECKING:
from quart import Quart
@@ -205,7 +205,7 @@ async def _dispatch_notification(
event_id: str,
) -> None:
"""Run after the transaction commits. Sends notifications and updates the event row."""
from fablednetmon.core.notifications import dispatch_notifications
from fabledscryer.core.notifications import dispatch_notifications
alert_data = {
"rule_name": rule.name,
@@ -5,9 +5,9 @@ from typing import TYPE_CHECKING
from sqlalchemy import delete
from fablednetmon.models.monitors import DnsResult, PingResult
from fablednetmon.models.metrics import PluginMetric
from fablednetmon.models.ansible import AnsibleRun
from fabledscryer.models.monitors import DnsResult, PingResult
from fabledscryer.models.metrics import PluginMetric
from fabledscryer.models.ansible import AnsibleRun
if TYPE_CHECKING:
from quart import Quart
@@ -1,4 +1,4 @@
# fablednetmon/core/migration_runner.py
# fabledscryer/core/migration_runner.py
from __future__ import annotations
import logging
from pathlib import Path
@@ -6,12 +6,33 @@ from pathlib import Path
logger = logging.getLogger(__name__)
def run_core_migrations(db_url: str) -> None:
"""Run only core Alembic migrations (no plugin dirs).
def discover_all_plugin_migration_dirs(plugin_dir: Path) -> list[Path]:
"""Scan plugin_dir for any migrations/ subdirs, regardless of enabled status.
Used by run_core_migrations so Alembic can resolve the full revision graph
even when a plugin migration is already stamped in alembic_version.
"""
dirs: list[Path] = []
if not plugin_dir.exists():
return dirs
for entry in sorted(plugin_dir.iterdir()):
if not entry.is_dir():
continue
mdir = entry / "migrations"
if mdir.exists():
dirs.append(mdir)
return dirs
def run_core_migrations(db_url: str, plugin_dir: Path | None = None) -> None:
"""Run core Alembic migrations.
Includes all discovered plugin migration dirs (if plugin_dir given) so
Alembic can resolve any previously-applied plugin revisions in the graph.
Called first so the app_settings table exists before loading settings.
"""
_run(db_url, plugin_migration_dirs=[])
dirs = discover_all_plugin_migration_dirs(plugin_dir) if plugin_dir else []
_run(db_url, plugin_migration_dirs=dirs)
def run_plugin_migrations(db_url: str, plugin_migration_dirs: list[Path]) -> None:
@@ -33,8 +33,8 @@ async def dispatch_notifications(
async def _send_email(cfg: dict, alert: dict) -> dict:
try:
msg = EmailMessage()
msg["Subject"] = f"[FabledNetMon] {alert['state']}{alert['rule_name']}"
msg["From"] = cfg.get("username", "fablednetmon@localhost")
msg["Subject"] = f"[Fabled Scryer] {alert['state']}{alert['rule_name']}"
msg["From"] = cfg.get("username", "fabledscryer@localhost")
msg["To"] = ", ".join(cfg["recipients"])
msg.set_content(
f"Alert: {alert['state']}\n"
+77
View File
@@ -0,0 +1,77 @@
# fabledscryer/core/plugin_index.py
"""Remote plugin catalog: fetch, parse, and cache the plugin index.yaml."""
from __future__ import annotations
import logging
import time
from typing import Any
logger = logging.getLogger(__name__)
_CACHE_TTL = 300 # seconds
_cache: tuple[dict, float] | None = None # (raw_data, fetched_at monotonic)
class CatalogPlugin:
"""One entry from the remote plugin index."""
__slots__ = (
"name", "version", "description", "author",
"min_app_version", "download_url", "checksum_sha256",
"repository_url", "homepage", "license", "tags",
)
def __init__(self, data: dict[str, Any]) -> None:
self.name: str = data.get("name", "")
self.version: str = data.get("version", "0.0.0")
self.description: str = data.get("description", "")
self.author: str = data.get("author", "")
self.min_app_version: str | None = data.get("min_app_version")
self.download_url: str = data.get("download_url", "")
self.checksum_sha256: str = data.get("checksum_sha256", "")
self.repository_url: str = data.get("repository_url", "")
self.homepage: str = data.get("homepage", "")
self.license: str = data.get("license", "")
self.tags: list[str] = data.get("tags", [])
async def fetch_catalog(index_url: str, force: bool = False) -> list[CatalogPlugin]:
"""Fetch and parse the remote plugin catalog.
Results are cached in-process for _CACHE_TTL seconds. Pass force=True to
bypass the cache. On network failure, stale cached data is returned if
available; otherwise an empty list is returned.
"""
global _cache
import httpx
import yaml
now = time.monotonic()
if not force and _cache is not None:
raw, fetched_at = _cache
if now - fetched_at < _CACHE_TTL:
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(index_url)
resp.raise_for_status()
raw = yaml.safe_load(resp.text) or {}
_cache = (raw, now)
logger.info(
"Plugin catalog fetched from %s: %d plugin(s)",
index_url, len(raw.get("plugins", [])),
)
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
except Exception:
logger.exception("Failed to fetch plugin catalog from %s", index_url)
if _cache is not None:
raw, _ = _cache
logger.warning("Returning stale cached catalog")
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
return []
def clear_catalog_cache() -> None:
"""Invalidate the in-process catalog cache."""
global _cache
_cache = None
+333
View File
@@ -0,0 +1,333 @@
# fabledscryer/core/plugin_manager.py
from __future__ import annotations
import hashlib
import importlib
import logging
import os
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING
import yaml
from packaging.version import Version
if TYPE_CHECKING:
from quart import Quart
logger = logging.getLogger(__name__)
# Track which plugins were successfully loaded so hot-reload knows
# whether to register a blueprint (new plugin) or skip it (already registered).
_LOADED_PLUGINS: set[str] = set()
def load_plugins(app: "Quart") -> None:
"""Import all enabled plugins and register their blueprints and scheduled tasks.
For each plugin listed as enabled in config.yaml under 'plugins':
1. Locate the plugin directory under PLUGIN_DIR
2. Load and validate plugin.yaml (name must match dir, min_app_version check)
3. Merge plugin.yaml config defaults with user overrides in app.config["PLUGINS"]
4. Import the plugin Python package
5. Validate required exports: setup() and get_scheduled_tasks()
6. Call setup(app)
7. Register blueprint at /plugins/<name>/ if get_blueprint() exists
8. Append get_scheduled_tasks() results to app._task_registry
"""
import fabledscryer
plugin_dir = Path(app.config["PLUGIN_DIR"])
plugins_cfg: dict = app.config["PLUGINS"]
# Ensure plugin_dir is on sys.path so plugins are importable by name
plugin_dir_str = str(plugin_dir.resolve())
if plugin_dir_str not in sys.path:
sys.path.insert(0, plugin_dir_str)
for name, cfg in list(plugins_cfg.items()):
if not cfg.get("enabled", False):
continue
plugin_path = plugin_dir / name
if not plugin_path.exists():
logger.error("Plugin %r: directory %s not found, skipping", name, plugin_path)
continue
# Load and validate plugin.yaml
yaml_path = plugin_path / "plugin.yaml"
if not yaml_path.exists():
logger.error("Plugin %r: plugin.yaml not found, skipping", name)
continue
try:
with yaml_path.open() as f:
meta = yaml.safe_load(f) or {}
except Exception:
logger.exception("Plugin %r: failed to read plugin.yaml, skipping", name)
continue
if meta.get("name") != name:
logger.error(
"Plugin %r: plugin.yaml name=%r does not match directory name, skipping",
name, meta.get("name"),
)
continue
min_ver = meta.get("min_app_version")
if min_ver:
try:
if Version(fabledscryer.__version__) < Version(min_ver):
logger.error(
"Plugin %r: requires fabledscryer>=%s but running %s, skipping",
name, min_ver, fabledscryer.__version__,
)
continue
except Exception:
logger.exception("Plugin %r: invalid min_app_version %r, skipping", name, min_ver)
continue
# Merge plugin.yaml config defaults with user overrides (non-"enabled" keys)
defaults = meta.get("config", {})
user_overrides = {k: v for k, v in cfg.items() if k != "enabled"}
plugins_cfg[name] = {"enabled": True, **defaults, **user_overrides}
# Import the plugin package
try:
module = importlib.import_module(name)
except ImportError:
logger.exception("Plugin %r: import failed, skipping", name)
continue
# Validate required exports
if not hasattr(module, "setup"):
logger.error("Plugin %r: missing required export 'setup', skipping", name)
continue
if not hasattr(module, "get_scheduled_tasks"):
logger.error(
"Plugin %r: missing required export 'get_scheduled_tasks', skipping", name
)
continue
# Call setup
try:
module.setup(app)
except Exception:
logger.exception("Plugin %r: setup() raised, skipping", name)
continue
# Register blueprint (optional)
if hasattr(module, "get_blueprint"):
try:
bp = module.get_blueprint()
app.register_blueprint(bp, url_prefix=f"/plugins/{name}")
logger.info("Plugin %r: blueprint registered at /plugins/%s/", name, name)
except Exception:
logger.exception("Plugin %r: get_blueprint() raised", name)
# Register scheduled tasks
try:
tasks = module.get_scheduled_tasks()
app._task_registry.extend(tasks)
logger.info("Plugin %r: registered %d scheduled task(s)", name, len(tasks))
except Exception:
logger.exception("Plugin %r: get_scheduled_tasks() raised", name)
_LOADED_PLUGINS.add(name)
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
async def download_and_install_plugin(
app: "Quart",
name: str,
download_url: str,
expected_sha256: str,
) -> tuple[bool, str]:
"""Download a plugin zip, verify its checksum, and extract it to PLUGIN_DIR.
Returns (success, message). Does NOT hot-reload — call hot_reload_plugin()
after this to activate the plugin without a restart (new plugins only).
"""
import httpx
plugin_dir = Path(app.config["PLUGIN_DIR"]).resolve()
# Ensure plugin_dir is on sys.path (may not be set yet if no plugins were
# enabled at startup)
plugin_dir_str = str(plugin_dir)
if plugin_dir_str not in sys.path:
sys.path.insert(0, plugin_dir_str)
try:
logger.info("Downloading plugin %r from %s", name, download_url)
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
resp = await client.get(download_url)
resp.raise_for_status()
content = resp.content
except Exception as exc:
msg = f"Download failed: {exc}"
logger.error("Plugin %r: %s", name, msg)
return False, msg
# Verify checksum if provided
if expected_sha256:
actual = hashlib.sha256(content).hexdigest()
if actual != expected_sha256.lower():
msg = f"Checksum mismatch: expected {expected_sha256}, got {actual}"
logger.error("Plugin %r: %s", name, msg)
return False, msg
# Extract the zip
try:
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / f"{name}.zip"
zip_path.write_bytes(content)
with zipfile.ZipFile(zip_path) as zf:
members = zf.namelist()
# Collect unique top-level names (dirs and loose files)
top_level = {m.split("/")[0] for m in members}
dest = plugin_dir / name
dest.mkdir(parents=True, exist_ok=True)
# Detect a single top-level directory to strip. Handles:
# name/ (clean plugin zip)
# name-v1.0.0/ (GitHub release tag archive)
# fabledscryer-plugins-main/name/ (whole-repo archive)
# Strategy: if there is exactly one top-level item and it is a
# directory whose name starts with the plugin name (case-insensitive),
# strip it. Otherwise extract flat into dest.
prefix: str | None = None
if len(top_level) == 1:
candidate = list(top_level)[0]
if candidate.lower().startswith(name.lower()):
prefix = candidate + "/"
for member in members:
if prefix:
if not member.startswith(prefix):
continue
rel = member[len(prefix):]
else:
rel = member
if not rel or rel.endswith("/"):
if rel:
(dest / rel.rstrip("/")).mkdir(parents=True, exist_ok=True)
continue
target = dest / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(zf.read(member))
except Exception as exc:
msg = f"Extraction failed: {exc}"
logger.error("Plugin %r: %s", name, msg)
return False, msg
# Run migrations for the newly installed plugin
try:
mdir = plugin_dir / name / "migrations"
if mdir.exists():
from fabledscryer.core.migration_runner import run_plugin_migrations
run_plugin_migrations(app.config["DATABASE_URL"], [mdir])
except Exception:
logger.exception("Plugin %r: migration failed after install", name)
return False, "Plugin extracted but migrations failed — check logs"
logger.info("Plugin %r installed successfully", name)
return True, "Installed"
def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
"""Activate a newly installed plugin without restarting the app.
This works for plugins that were NOT previously loaded. For plugins that
are already registered (blueprint already mounted), a full restart is
required to pick up code changes — use restart_app() in that case.
Returns (success, message).
"""
import fabledscryer
if name in _LOADED_PLUGINS:
return False, "Plugin already loaded — restart required to apply updates"
plugin_dir = Path(app.config["PLUGIN_DIR"]).resolve()
plugin_path = plugin_dir / name
if not plugin_path.exists():
return False, f"Plugin directory {plugin_path} not found"
yaml_path = plugin_path / "plugin.yaml"
if not yaml_path.exists():
return False, "plugin.yaml not found"
try:
with yaml_path.open() as f:
meta = yaml.safe_load(f) or {}
except Exception as exc:
return False, f"Could not read plugin.yaml: {exc}"
if meta.get("name") != name:
return False, f"plugin.yaml name mismatch: expected {name!r}, got {meta.get('name')!r}"
min_ver = meta.get("min_app_version")
if min_ver:
try:
if Version(fabledscryer.__version__) < Version(min_ver):
return False, (
f"Plugin requires fabledscryer>={min_ver} "
f"but running {fabledscryer.__version__}"
)
except Exception:
return False, f"Invalid min_app_version: {min_ver!r}"
# Merge config defaults with any stored user config
defaults = meta.get("config", {})
stored_cfg = app.config.get("PLUGINS", {}).get(name, {})
user_overrides = {k: v for k, v in stored_cfg.items() if k != "enabled"}
merged = {"enabled": True, **defaults, **user_overrides}
app.config["PLUGINS"][name] = merged
# Import
try:
module = importlib.import_module(name)
except ImportError as exc:
return False, f"Import failed: {exc}"
if not hasattr(module, "setup") or not hasattr(module, "get_scheduled_tasks"):
return False, "Plugin missing required exports (setup, get_scheduled_tasks)"
try:
module.setup(app)
except Exception as exc:
return False, f"setup() raised: {exc}"
if hasattr(module, "get_blueprint"):
try:
bp = module.get_blueprint()
app.register_blueprint(bp, url_prefix=f"/plugins/{name}")
except Exception as exc:
return False, f"Blueprint registration failed: {exc}"
try:
tasks = module.get_scheduled_tasks()
# De-duplicate: skip tasks whose name already exists in the registry
existing_names = {t.name for t in app._task_registry}
new_tasks = [t for t in tasks if t.name not in existing_names]
app._task_registry.extend(new_tasks)
except Exception as exc:
return False, f"get_scheduled_tasks() raised: {exc}"
_LOADED_PLUGINS.add(name)
logger.info("Plugin %r hot-reloaded (v%s)", name, meta.get("version", "?"))
return True, f"Plugin activated (v{meta.get('version', '?')})"
def restart_app() -> None:
"""Replace the current process with a fresh copy (in-app restart).
Used when a plugin update requires a full restart to take effect.
In Docker the container will be relaunched by the restart policy.
"""
logger.info("Initiating in-app restart via os.execv")
os.execv(sys.executable, [sys.executable] + sys.argv)
@@ -1,15 +1,15 @@
# fablednetmon/core/settings.py
# fabledscryer/core/settings.py
"""DB-backed application settings.
Keys use dotted notation (e.g. "smtp.host").
Values are JSON-encoded in the DB.
Usage in create_app() (before event loop):
from fablednetmon.core.settings import load_settings_sync
from fabledscryer.core.settings import load_settings_sync
settings = load_settings_sync(db_url)
Usage at runtime (inside async handlers):
from fablednetmon.core.settings import get_setting, set_setting
from fabledscryer.core.settings import get_setting, set_setting
async with app.db_sessionmaker() as session:
value = await get_setting(session, "smtp.host")
await set_setting(session, "smtp.host", "mail.example.com")
@@ -24,7 +24,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fablednetmon.models.settings import AppSetting
from fabledscryer.models.settings import AppSetting
logger = logging.getLogger(__name__)
@@ -50,6 +50,7 @@ DEFAULTS: dict[str, Any] = {
"ansible.sources": [],
"ping.threshold.good_ms": 50,
"ping.threshold.warn_ms": 200,
"plugins.index_url": "https://raw.githubusercontent.com/bvandeusen/fabledscryer-plugins/main/index.yaml",
}
+44
View File
@@ -0,0 +1,44 @@
# fabledscryer/core/time_range.py
"""Shared time-range utilities for scoping queries and sparkline data."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import TypeVar
RANGES: dict[str, timedelta] = {
"1h": timedelta(hours=1),
"6h": timedelta(hours=6),
"24h": timedelta(hours=24),
"7d": timedelta(days=7),
"30d": timedelta(days=30),
}
RANGE_OPTIONS: list[str] = list(RANGES.keys())
DEFAULT_RANGE = "24h"
def parse_range(range_str: str | None) -> tuple[datetime, str]:
"""Return (since_datetime, range_key) from a query-param string like '24h'."""
key = range_str if range_str in RANGES else DEFAULT_RANGE
since = datetime.now(timezone.utc) - RANGES[key]
return since, key
def bucket_seconds(since: datetime, target_points: int = 80) -> int:
"""Return bucket width in seconds so that (now - since) / width ≈ target_points.
Minimum is 60s (one poll interval) so raw data is never re-averaged below
the collection resolution.
"""
range_secs = (datetime.now(timezone.utc) - since).total_seconds()
return max(60, int(range_secs / target_points))
T = TypeVar("T")
def subsample(seq: list[T], n: int = 80) -> list[T]:
"""Return at most n evenly-distributed elements from seq (preserves order)."""
if len(seq) <= n:
return seq
indices = sorted({int(round(i * (len(seq) - 1) / (n - 1))) for i in range(n)})
return [seq[i] for i in indices]
+76
View File
@@ -0,0 +1,76 @@
# fabledscryer/core/widgets.py
#
# Central widget registry. Each entry describes one widget type that can be
# placed on a dashboard. Plugin widgets declare which plugin must be enabled
# for the widget to be available.
#
# Forward-compatibility notes:
# - Step 8 (widget variants) will add multiple entries per plugin and a
# "variant" sub-key rather than one entry per plugin.
# - Step 9 (plugin management) will allow external plugins to register
# entries here at load time via register_widget().
from __future__ import annotations
WIDGET_REGISTRY: dict[str, dict] = {
"ping": {
"key": "ping",
"label": "Ping",
"description": "Live ping status and latency history for all monitored hosts",
"hx_url": "/ping/rows",
"detail_url": "/ping/",
"plugin": None, # built-in; always available
"poll": True,
},
"dns": {
"key": "dns",
"label": "DNS",
"description": "DNS resolution status for all monitored hosts",
"hx_url": "/dns/rows",
"detail_url": "/dns/",
"plugin": None,
"poll": True,
},
"traefik": {
"key": "traefik",
"label": "Traefik",
"description": "Proxy request rates, service health, and recent errors",
"hx_url": "/plugins/traefik/widget",
"detail_url": "/plugins/traefik/",
"plugin": "traefik",
"poll": True,
},
"unifi": {
"key": "unifi",
"label": "UniFi Network",
"description": "WAN status, client counts, active alarms",
"hx_url": "/plugins/unifi/widget",
"detail_url": "/plugins/unifi/",
"plugin": "unifi",
"poll": True,
},
"ups": {
"key": "ups",
"label": "UPS",
"description": "Battery charge, runtime estimate, load percentage",
"hx_url": "/plugins/ups/widget",
"detail_url": "/plugins/ups/",
"plugin": "ups",
"poll": True,
},
}
def register_widget(definition: dict) -> None:
"""Register a widget at runtime (used by external plugins in step 9)."""
WIDGET_REGISTRY[definition["key"]] = definition
def get_available_widgets(plugins_cfg: dict) -> list[dict]:
"""Return widgets whose plugin dependency is satisfied."""
result = []
for w in WIDGET_REGISTRY.values():
if w["plugin"] is None:
result.append(w)
elif plugins_cfg.get(w["plugin"], {}).get("enabled", False):
result.append(w)
return result
+557
View File
@@ -0,0 +1,557 @@
from __future__ import annotations
import hashlib
import secrets
from datetime import datetime, timezone, timedelta
from quart import Blueprint, render_template, current_app, abort, redirect, request, session
from sqlalchemy import select, func, update
from fabledscryer.auth.middleware import require_role, current_user_id
from fabledscryer.models.users import UserRole
from fabledscryer.models.hosts import Host
from fabledscryer.models.monitors import PingResult, DnsResult
from fabledscryer.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken
from fabledscryer.core.widgets import get_available_widgets, WIDGET_REGISTRY
dashboard_bp = Blueprint("dashboard", __name__)
# ── Access helpers ────────────────────────────────────────────────────────────
def _can_view(dash: Dashboard, user_id: str, user_role: str) -> bool:
if user_role == UserRole.admin:
return True
if dash.owner_id is None: # system dashboard
return True
if dash.owner_id == user_id: # own dashboard
return True
return dash.is_shared # shared by another user
def _can_edit(dash: Dashboard, user_id: str, user_role: str) -> bool:
if user_role == UserRole.admin:
return True
return dash.owner_id == user_id
# ── DB helpers ────────────────────────────────────────────────────────────────
async def _get_widgets(db, dashboard_id: int) -> list[DashboardWidget]:
result = await db.execute(
select(DashboardWidget)
.where(DashboardWidget.dashboard_id == dashboard_id)
.order_by(DashboardWidget.position)
)
return list(result.scalars())
async def _normalise_positions(db, dashboard_id: int) -> None:
for i, w in enumerate(await _get_widgets(db, dashboard_id)):
w.position = i
async def _accessible_dashboards(db, user_id: str, user_role: str) -> list[Dashboard]:
"""All dashboards visible to this user, ordered by id."""
result = await db.execute(select(Dashboard).order_by(Dashboard.id))
all_dashes = list(result.scalars())
return [d for d in all_dashes if _can_view(d, user_id, user_role)]
async def _resolve_default(db, user_id: str, user_role: str) -> Dashboard | None:
"""
1. User's own dashboard with is_default=True
2. System dashboard (owner_id=None) with is_default=True
3. First accessible dashboard
"""
result = await db.execute(
select(Dashboard)
.where(Dashboard.owner_id == user_id, Dashboard.is_default.is_(True))
.limit(1)
)
dash = result.scalar_one_or_none()
if dash:
return dash
result = await db.execute(
select(Dashboard)
.where(Dashboard.owner_id.is_(None), Dashboard.is_default.is_(True))
.limit(1)
)
dash = result.scalar_one_or_none()
if dash:
return dash
accessible = await _accessible_dashboards(db, user_id, user_role)
return accessible[0] if accessible else None
async def _get_or_create_system_default(db) -> Dashboard:
"""Ensure a system (owner_id=None) default dashboard exists."""
result = await db.execute(
select(Dashboard).where(Dashboard.owner_id.is_(None)).limit(1)
)
dash = result.scalar_one_or_none()
if dash is None:
dash = Dashboard(name="Default", owner_id=None, is_default=True, is_shared=True)
db.add(dash)
await db.flush()
plugins_cfg = current_app.config.get("PLUGINS", {})
for pos, w in enumerate(get_available_widgets(plugins_cfg)):
db.add(DashboardWidget(
dashboard_id=dash.id, widget_key=w["key"], position=pos,
))
return dash
async def _get_summary_stats(db) -> dict:
latest_ping_subq = (
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
.group_by(PingResult.host_id).subquery()
)
pr = await db.execute(
select(PingResult).join(
latest_ping_subq,
(PingResult.host_id == latest_ping_subq.c.host_id)
& (PingResult.probed_at == latest_ping_subq.c.max_at),
)
)
pings = list(pr.scalars())
ping_up = sum(1 for p in pings if p.status.value == "up")
ping_down = sum(1 for p in pings if p.status.value == "down")
latest_dns_subq = (
select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at"))
.group_by(DnsResult.host_id).subquery()
)
dr = await db.execute(
select(DnsResult).join(
latest_dns_subq,
(DnsResult.host_id == latest_dns_subq.c.host_id)
& (DnsResult.resolved_at == latest_dns_subq.c.max_at),
)
)
dns = list(dr.scalars())
dns_ok = sum(1 for d in dns if d.status.value == "resolved")
dns_fail = sum(1 for d in dns if d.status.value != "resolved")
total = (await db.execute(select(func.count()).select_from(Host))).scalar()
return dict(ping_up=ping_up, ping_down=ping_down,
dns_ok=dns_ok, dns_fail=dns_fail, total_hosts=total)
def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]:
plugins_cfg = current_app.config.get("PLUGINS", {})
out = []
for w in widgets:
defn = WIDGET_REGISTRY.get(w.widget_key)
if defn is None:
continue
if defn["plugin"] and not plugins_cfg.get(defn["plugin"], {}).get("enabled", False):
continue
out.append({"db": w, "defn": defn})
return out
# ── Edit panel helpers ────────────────────────────────────────────────────────
async def _get_panels_data(db, dashboard_id: int) -> tuple:
result = await db.execute(select(Dashboard).where(Dashboard.id == dashboard_id))
dash = result.scalar_one_or_none()
if dash is None:
return None, [], []
widgets = await _get_widgets(db, dashboard_id)
plugins_cfg = current_app.config.get("PLUGINS", {})
available = get_available_widgets(plugins_cfg)
placed_keys = {w.widget_key for w in widgets}
addable = [w for w in available if w["key"] not in placed_keys]
resolved = [
{"db": w, "defn": WIDGET_REGISTRY[w.widget_key]}
for w in widgets if w.widget_key in WIDGET_REGISTRY
]
return dash, resolved, addable
async def _panels_response(dashboard_id: int):
async with current_app.db_sessionmaker() as db:
dash, resolved, addable = await _get_panels_data(db, dashboard_id)
return await render_template(
"dashboard/_edit_panels.html",
dashboard=dash, widgets=resolved, addable=addable,
)
# ── Root redirect ─────────────────────────────────────────────────────────────
@dashboard_bp.get("/")
@require_role(UserRole.viewer)
async def index():
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
await _get_or_create_system_default(db)
dash = await _resolve_default(db, user_id, user_role)
if dash is None:
abort(404)
return redirect(f"/d/{dash.id}")
# ── Dashboard view ────────────────────────────────────────────────────────────
@dashboard_bp.get("/d/<int:dash_id>")
@require_role(UserRole.viewer)
async def view(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_view(dash, user_id, user_role):
abort(404)
stats = await _get_summary_stats(db)
widgets = await _get_widgets(db, dash_id)
accessible = await _accessible_dashboards(db, user_id, user_role)
resolved = _resolve_widgets(widgets)
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
can_edit = _can_edit(dash, user_id, user_role)
return await render_template(
"dashboard/index.html",
dashboard=dash,
all_dashboards=accessible,
widgets=resolved,
poll_interval=poll_interval,
can_edit=can_edit,
**stats,
)
# ── Dashboard management ──────────────────────────────────────────────────────
@dashboard_bp.get("/dashboards/")
@require_role(UserRole.viewer)
async def list_dashboards():
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
await _get_or_create_system_default(db)
accessible = await _accessible_dashboards(db, user_id, user_role)
my_dashes = [d for d in accessible if d.owner_id == user_id]
shared_dashes = [d for d in accessible if d.owner_id != user_id]
return await render_template(
"dashboard/list.html",
my_dashboards=my_dashes,
shared_dashboards=shared_dashes,
user_id=user_id,
user_role=user_role,
)
@dashboard_bp.post("/dashboards/create")
@require_role(UserRole.viewer)
async def create_dashboard():
user_id = current_user_id()
form = await request.form
name = (form.get("name") or "New Dashboard").strip()[:128]
async with current_app.db_sessionmaker() as db:
async with db.begin():
dash = Dashboard(name=name, owner_id=user_id, is_default=False, is_shared=False)
db.add(dash)
await db.flush()
dash_id = dash.id
return redirect(f"/d/{dash_id}/edit")
@dashboard_bp.post("/dashboards/<int:dash_id>/rename")
@require_role(UserRole.viewer)
async def rename_dashboard(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
form = await request.form
name = (form.get("name") or "").strip()[:128]
if name:
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash and _can_edit(dash, user_id, user_role):
dash.name = name
return redirect("/dashboards/")
@dashboard_bp.post("/dashboards/<int:dash_id>/toggle-share")
@require_role(UserRole.viewer)
async def toggle_share(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash and _can_edit(dash, user_id, user_role):
dash.is_shared = not dash.is_shared
return redirect("/dashboards/")
@dashboard_bp.post("/dashboards/<int:dash_id>/set-default")
@require_role(UserRole.viewer)
async def set_default_dashboard(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_view(dash, user_id, user_role):
abort(404)
# Clear is_default on all dashboards this user owns
await db.execute(
update(Dashboard)
.where(Dashboard.owner_id == user_id)
.values(is_default=False)
)
# If setting a system/shared dashboard as default, we instead create
# a personal preference record by marking the system one (safe since
# system dashboards are shared — the flag just signals this user's preference)
dash.is_default = True
return redirect("/dashboards/")
@dashboard_bp.post("/dashboards/<int:dash_id>/delete")
@require_role(UserRole.viewer)
async def delete_dashboard(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
# Count remaining owned by this user (don't delete their last one if any)
owned_count = (await db.execute(
select(func.count()).select_from(Dashboard)
.where(Dashboard.owner_id == user_id)
)).scalar()
if dash.owner_id == user_id and owned_count <= 1:
pass # refuse to delete user's last owned dashboard
else:
await db.delete(dash)
return redirect("/dashboards/")
# ── Dashboard edit ────────────────────────────────────────────────────────────
@dashboard_bp.get("/d/<int:dash_id>/edit")
@require_role(UserRole.viewer)
async def edit(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
dash, resolved, addable = await _get_panels_data(db, dash_id)
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
return await render_template(
"dashboard/edit.html",
dashboard=dash, widgets=resolved, addable=addable,
)
@dashboard_bp.post("/d/<int:dash_id>/edit/add/<widget_key>")
@require_role(UserRole.viewer)
async def add_widget(dash_id: int, widget_key: str):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
if widget_key not in WIDGET_REGISTRY:
abort(400)
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
widgets = await _get_widgets(db, dash_id)
if not any(w.widget_key == widget_key for w in widgets):
next_pos = max((w.position for w in widgets), default=-1) + 1
db.add(DashboardWidget(
dashboard_id=dash_id, widget_key=widget_key, position=next_pos,
))
return await _panels_response(dash_id)
@dashboard_bp.post("/d/<int:dash_id>/edit/remove/<int:widget_id>")
@require_role(UserRole.viewer)
async def remove_widget(dash_id: int, widget_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
result = await db.execute(
select(DashboardWidget).where(DashboardWidget.id == widget_id)
)
widget = result.scalar_one_or_none()
if widget and widget.dashboard_id == dash_id:
await db.delete(widget)
await _normalise_positions(db, dash_id)
return await _panels_response(dash_id)
@dashboard_bp.post("/d/<int:dash_id>/edit/move/<int:widget_id>/<direction>")
@require_role(UserRole.viewer)
async def move_widget(dash_id: int, widget_id: int, direction: str):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
if direction not in ("up", "down"):
abort(400)
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
widgets = await _get_widgets(db, dash_id)
idx = next((i for i, w in enumerate(widgets) if w.id == widget_id), None)
if idx is not None:
swap_idx = (idx - 1) if direction == "up" else (idx + 1)
if 0 <= swap_idx < len(widgets):
widgets[idx].position, widgets[swap_idx].position = (
widgets[swap_idx].position, widgets[idx].position
)
return await _panels_response(dash_id)
# ── Share tokens ──────────────────────────────────────────────────────────────
@dashboard_bp.get("/d/<int:dash_id>/share")
@require_role(UserRole.viewer)
async def manage_share_tokens(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
result = await db.execute(
select(DashboardShareToken)
.where(DashboardShareToken.dashboard_id == dash_id)
.order_by(DashboardShareToken.created_at.desc())
)
tokens = list(result.scalars())
now = datetime.now(timezone.utc)
return await render_template(
"dashboard/share.html",
dashboard=dash, tokens=tokens, now=now,
)
@dashboard_bp.post("/d/<int:dash_id>/share/create")
@require_role(UserRole.viewer)
async def create_share_token(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
form = await request.form
label = (form.get("label") or "").strip()[:128]
expires_in = form.get("expires_in", "")
expires_at = None
if expires_in.isdigit() and int(expires_in) > 0:
expires_at = datetime.now(timezone.utc) + timedelta(days=int(expires_in))
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(DashboardShareToken(
dashboard_id=dash_id,
token_hash=token_hash,
label=label,
expires_at=expires_at,
created_by=user_id,
created_at=datetime.now(timezone.utc),
))
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
return await render_template(
"dashboard/share_created.html",
dashboard=dash,
raw_token=raw_token,
label=label,
expires_at=expires_at,
)
@dashboard_bp.post("/d/<int:dash_id>/share/revoke/<int:token_id>")
@require_role(UserRole.viewer)
async def revoke_share_token(dash_id: int, token_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
dash = result.scalar_one_or_none()
if dash is None or not _can_edit(dash, user_id, user_role):
abort(403)
result = await db.execute(
select(DashboardShareToken).where(DashboardShareToken.id == token_id)
)
token = result.scalar_one_or_none()
if token and token.dashboard_id == dash_id:
await db.delete(token)
return redirect(f"/d/{dash_id}/share")
# ── Public share view (no login required) ────────────────────────────────────
@dashboard_bp.get("/share/<raw_token>")
async def public_share(raw_token: str):
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(DashboardShareToken)
.where(DashboardShareToken.token_hash == token_hash)
)
share = result.scalar_one_or_none()
if share is None:
abort(404)
if share.expires_at is not None and share.expires_at <= now:
abort(410)
result = await db.execute(
select(Dashboard).where(Dashboard.id == share.dashboard_id)
)
dash = result.scalar_one_or_none()
if dash is None:
abort(404)
widgets = await _get_widgets(db, dash.id)
resolved = _resolve_widgets(widgets)
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
return await render_template(
"dashboard/share_view.html",
dashboard=dash,
widgets=resolved,
poll_interval=poll_interval,
raw_token=raw_token,
)
@@ -1,10 +1,10 @@
from __future__ import annotations
from quart import Blueprint, current_app, render_template
from sqlalchemy import select, func
from fablednetmon.auth.middleware import require_role
from fablednetmon.models.users import UserRole
from fablednetmon.models.hosts import Host
from fablednetmon.models.monitors import DnsResult
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.users import UserRole
from fabledscryer.models.hosts import Host
from fabledscryer.models.monitors import DnsResult
dns_bp = Blueprint("dns", __name__, url_prefix="/dns")
@@ -1,10 +1,10 @@
from __future__ import annotations
from quart import Blueprint, render_template, request, redirect, url_for, current_app
from sqlalchemy import select, func
from fablednetmon.auth.middleware import require_role
from fablednetmon.models.hosts import Host, ProbeType
from fablednetmon.models.monitors import PingResult, DnsResult
from fablednetmon.models.users import UserRole
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.hosts import Host, ProbeType
from fabledscryer.models.monitors import PingResult, DnsResult
from fabledscryer.models.users import UserRole
hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts")
@@ -4,8 +4,8 @@ from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from fablednetmon.models.base import Base
import fablednetmon.models # noqa: F401 — registers all models in metadata
from fabledscryer.models.base import Base
import fabledscryer.models # noqa: F401 — registers all models in metadata
config = context.config
if config.config_file_name is not None:
@@ -16,7 +16,7 @@ target_metadata = Base.metadata
def get_url() -> str:
import os, yaml
cfg_path = os.environ.get("FABLEDNETMON_CONFIG", "config.yaml")
cfg_path = os.environ.get("FABLEDSCRYER_CONFIG", "config.yaml")
try:
with open(cfg_path) as f:
cfg = yaml.safe_load(f) or {}
@@ -24,8 +24,8 @@ def get_url() -> str:
except FileNotFoundError:
url = ""
return (
os.environ.get("FABLEDNETMON_DATABASE_URL")
or os.environ.get("FABLEDNETMON_DATABASE__URL")
os.environ.get("FABLEDSCRYER_DATABASE_URL")
or os.environ.get("FABLEDSCRYER_DATABASE__URL")
or url
)
@@ -0,0 +1,41 @@
"""Dashboard layout tables
Revision ID: 0005_dashboards
Revises: 0004_app_settings
Create Date: 2026-03-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0005_dashboards"
down_revision: Union[str, None] = "0004_app_settings"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"dashboards",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"dashboard_widgets",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("dashboard_id", sa.Integer,
sa.ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False),
sa.Column("widget_key", sa.String(64), nullable=False),
sa.Column("position", sa.Integer, nullable=False),
)
op.create_index(
"ix_dashboard_widgets_dashboard_id",
"dashboard_widgets", ["dashboard_id"],
)
def downgrade() -> None:
op.drop_index("ix_dashboard_widgets_dashboard_id", "dashboard_widgets")
op.drop_table("dashboard_widgets")
op.drop_table("dashboards")
@@ -0,0 +1,25 @@
"""Add is_default to dashboards
Revision ID: 0006_dashboard_is_default
Revises: 0005_dashboards
Create Date: 2026-03-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0006_dashboard_is_default"
down_revision: Union[str, None] = "0005_dashboards"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"dashboards",
sa.Column("is_default", sa.Boolean, nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("dashboards", "is_default")
@@ -0,0 +1,33 @@
"""Add owner_id and is_shared to dashboards
Revision ID: 0007_dashboard_ownership
Revises: 0006_dashboard_is_default
Create Date: 2026-03-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0007_dashboard_ownership"
down_revision: Union[str, None] = "0006_dashboard_is_default"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# owner_id nullable — existing dashboards keep NULL (system-owned)
op.add_column(
"dashboards",
sa.Column("owner_id", sa.String(36),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
)
# Existing dashboards become shared so all users still see them
op.add_column(
"dashboards",
sa.Column("is_shared", sa.Boolean, nullable=False, server_default="1"),
)
def downgrade() -> None:
op.drop_column("dashboards", "is_shared")
op.drop_column("dashboards", "owner_id")
@@ -0,0 +1,41 @@
"""Dashboard share tokens
Revision ID: 0008_share_tokens
Revises: 0007_dashboard_ownership
Create Date: 2026-03-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0008_share_tokens"
down_revision: Union[str, None] = "0007_dashboard_ownership"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"dashboard_share_tokens",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("dashboard_id", sa.Integer,
sa.ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
sa.Column("label", sa.String(128), nullable=False, server_default=""),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by", sa.String(36),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index(
"ix_share_tokens_dashboard_id", "dashboard_share_tokens", ["dashboard_id"],
)
op.create_index(
"ix_share_tokens_hash", "dashboard_share_tokens", ["token_hash"],
)
def downgrade() -> None:
op.drop_index("ix_share_tokens_hash", "dashboard_share_tokens")
op.drop_index("ix_share_tokens_dashboard_id", "dashboard_share_tokens")
op.drop_table("dashboard_share_tokens")
@@ -6,6 +6,7 @@ from .metrics import PluginMetric
from .alerts import AlertRule, AlertState, AlertEvent, AlertOperator, AlertStateEnum
from .ansible import AnsibleRun, AnsibleRunStatus
from .settings import AppSetting
from .dashboard import Dashboard, DashboardWidget, DashboardShareToken
__all__ = [
"Base", "User",
@@ -15,4 +16,5 @@ __all__ = [
"AlertRule", "AlertState", "AlertEvent", "AlertOperator", "AlertStateEnum",
"AnsibleRun", "AnsibleRunStatus",
"AppSetting",
"Dashboard", "DashboardWidget", "DashboardShareToken",
]
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class Dashboard(Base):
__tablename__ = "dashboards"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False, default="Default")
# NULL owner_id = system dashboard (visible to all, editable by admins)
owner_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, default=None,
)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# is_shared = True → visible to all logged-in users (read-only to non-owners)
is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc),
)
class DashboardShareToken(Base):
__tablename__ = "dashboard_share_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
dashboard_id: Mapped[int] = mapped_column(
Integer, ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False,
)
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
label: Mapped[str] = mapped_column(String(128), nullable=False, default="")
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc),
)
class DashboardWidget(Base):
__tablename__ = "dashboard_widgets"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
dashboard_id: Mapped[int] = mapped_column(
Integer, ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False,
)
widget_key: Mapped[str] = mapped_column(String(64), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from fablednetmon.models.base import Base
from fabledscryer.models.base import Base
class AppSetting(Base):
@@ -6,9 +6,9 @@ import socket
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fablednetmon.core.alerts import record_metric
from fablednetmon.models.hosts import Host
from fablednetmon.models.monitors import DnsResult, DnsStatus
from fabledscryer.core.alerts import record_metric
from fabledscryer.models.hosts import Host
from fabledscryer.models.monitors import DnsResult, DnsStatus
logger = logging.getLogger(__name__)
@@ -5,9 +5,9 @@ import time
from sqlalchemy.ext.asyncio import AsyncSession
from fablednetmon.core.alerts import record_metric
from fablednetmon.models.hosts import Host, ProbeType
from fablednetmon.models.monitors import PingResult, PingStatus
from fabledscryer.core.alerts import record_metric
from fabledscryer.models.hosts import Host, ProbeType
from fabledscryer.models.monitors import PingResult, PingStatus
logger = logging.getLogger(__name__)
@@ -1,18 +1,21 @@
from __future__ import annotations
import logging
from quart import Blueprint, current_app, render_template, request, redirect, url_for
from sqlalchemy import select, func
from fablednetmon.auth.middleware import require_role
from fablednetmon.models.users import UserRole
from fablednetmon.models.hosts import Host
from fablednetmon.models.monitors import PingResult
from fablednetmon.core.settings import get_all_settings, set_setting, DEFAULTS
from sqlalchemy import select, func, case
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.users import UserRole
from fabledscryer.models.hosts import Host
from fabledscryer.models.monitors import PingResult
from fabledscryer.core.settings import get_all_settings, set_setting, DEFAULTS
from fabledscryer.core.time_range import parse_range, DEFAULT_RANGE
ping_bp = Blueprint("ping", __name__, url_prefix="/ping")
logger = logging.getLogger(__name__)
PILL_COUNT = 30 # pills always show the last N pings regardless of range
async def _last_n_pings(db, host_ids: list[str], n: int = 30) -> dict[str, list]:
async def _last_n_pings(db, host_ids: list[str], n: int = PILL_COUNT) -> dict[str, list]:
"""Return {host_id: [PingResult]} for last n pings per host, oldest first."""
if not host_ids:
return {}
@@ -37,6 +40,27 @@ async def _last_n_pings(db, host_ids: list[str], n: int = 30) -> dict[str, list]
return result
async def _uptime_pcts(db, host_ids: list[str], since) -> dict[str, float | None]:
"""Return {host_id: uptime_%} for results since the given datetime."""
if not host_ids:
return {}
result = await db.execute(
select(
PingResult.host_id,
func.count().label("total"),
func.sum(case((PingResult.status == "up", 1), else_=0)).label("up_count"),
)
.where(PingResult.host_id.in_(host_ids))
.where(PingResult.probed_at >= since)
.group_by(PingResult.host_id)
)
pcts: dict[str, float | None] = {hid: None for hid in host_ids}
for row in result.all():
if row.total and row.total > 0:
pcts[row.host_id] = (row.up_count or 0) / row.total * 100.0
return pcts
async def _thresholds(db) -> tuple[int, int]:
settings = await get_all_settings(db)
good = int(settings.get("ping.threshold.good_ms", DEFAULTS["ping.threshold.good_ms"]))
@@ -48,40 +72,40 @@ async def _thresholds(db) -> tuple[int, int]:
@require_role(UserRole.viewer)
async def index():
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
)
hosts = result.scalars().all()
pings_by_host = await _last_n_pings(db, [h.id for h in hosts])
good_ms, warn_ms = await _thresholds(db)
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
current_range = request.args.get("range", DEFAULT_RANGE)
return await render_template(
"ping/index.html",
hosts=hosts,
pings_by_host=pings_by_host,
good_ms=good_ms,
warn_ms=warn_ms,
poll_interval=poll_interval,
current_range=current_range,
)
@ping_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
"""HTMX fragment — host rows with pills, no page shell."""
"""HTMX fragment — host rows with pills + uptime % for selected range."""
since, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
)
hosts = result.scalars().all()
pings_by_host = await _last_n_pings(db, [h.id for h in hosts])
host_ids = [h.id for h in hosts]
pings_by_host = await _last_n_pings(db, host_ids)
uptime_pcts = await _uptime_pcts(db, host_ids, since)
good_ms, warn_ms = await _thresholds(db)
return await render_template(
"ping/rows.html",
hosts=hosts,
pings_by_host=pings_by_host,
uptime_pcts=uptime_pcts,
range_key=range_key,
good_ms=good_ms,
warn_ms=warn_ms,
)
+1
View File
@@ -0,0 +1 @@
# fabledscryer/settings/__init__.py
+344
View File
@@ -0,0 +1,344 @@
# fabledscryer/settings/routes.py
from __future__ import annotations
import json
import logging
from pathlib import Path
from quart import Blueprint, current_app, render_template, request, redirect, url_for
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.users import UserRole
from fabledscryer.core.settings import (
DEFAULTS, get_all_settings, set_setting,
to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg,
)
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
logger = logging.getLogger(__name__)
def _discover_plugins() -> list[dict]:
"""Scan PLUGIN_DIR for plugin.yaml files."""
import yaml
plugin_dir = Path(current_app.config.get("PLUGIN_DIR", "plugins")).resolve()
plugins = []
if not plugin_dir.exists():
return plugins
for entry in sorted(plugin_dir.iterdir()):
yaml_path = entry / "plugin.yaml"
if entry.is_dir() and yaml_path.exists():
try:
with yaml_path.open() as f:
meta = yaml.safe_load(f) or {}
meta["_dir"] = entry.name
plugins.append(meta)
except Exception:
logger.warning("Could not read %s", yaml_path)
return plugins
def _merge_plugin_config(discovered: list[dict], plugins_cfg: dict) -> None:
"""Merge stored settings into discovered plugin dicts in-place."""
for plugin in discovered:
name = plugin["_dir"]
stored = plugins_cfg.get(name, {})
plugin["_enabled"] = stored.get("enabled", False)
defaults = plugin.get("config", {})
plugin["_config"] = {**defaults, **{k: v for k, v in stored.items() if k != "enabled"}}
async def _reload_app_config() -> None:
"""Reload app.config from DB so settings take effect without restart."""
async with current_app.db_sessionmaker() as db:
fresh = await get_all_settings(db)
current_app.config.update(
SESSION_LIFETIME_HOURS=fresh.get("session.lifetime_hours", 8),
DATA_RETENTION_DAYS=fresh.get("data.retention_days", 90),
MONITORS_POLL_INTERVAL=fresh.get("monitors.poll_interval_seconds", 60),
SMTP=to_smtp_cfg(fresh),
WEBHOOK=to_webhook_cfg(fresh),
ANSIBLE=to_ansible_cfg(fresh),
PLUGINS=to_plugins_cfg(fresh),
)
# ── Redirect root to general tab ──────────────────────────────────────────────
@settings_bp.get("/")
@require_role(UserRole.admin)
async def index():
return redirect(url_for("settings.general"))
# ── General ───────────────────────────────────────────────────────────────────
@settings_bp.get("/general/")
@require_role(UserRole.admin)
async def general():
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
return await render_template("settings/general.html", settings=settings)
@settings_bp.post("/general/")
@require_role(UserRole.admin)
async def save_general():
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
await set_setting(db, "session.lifetime_hours",
int(form.get("session.lifetime_hours", 8)))
await set_setting(db, "data.retention_days",
int(form.get("data.retention_days", 90)))
await set_setting(db, "monitors.poll_interval_seconds",
int(form.get("monitors.poll_interval_seconds", 60)))
await _reload_app_config()
return redirect(url_for("settings.general"))
# ── Notifications (SMTP + Webhook) ────────────────────────────────────────────
@settings_bp.get("/notifications/")
@require_role(UserRole.admin)
async def notifications():
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
return await render_template("settings/notifications.html", settings=settings)
@settings_bp.post("/notifications/")
@require_role(UserRole.admin)
async def save_notifications():
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
recipients_raw = form.get("smtp.recipients", "")
recipients = [r.strip() for r in recipients_raw.split(",") if r.strip()]
await set_setting(db, "smtp.host", form.get("smtp.host", ""))
await set_setting(db, "smtp.port", int(form.get("smtp.port", 587)))
await set_setting(db, "smtp.tls", "smtp.tls" in form)
await set_setting(db, "smtp.username", form.get("smtp.username", ""))
if form.get("smtp.password"):
await set_setting(db, "smtp.password", form.get("smtp.password"))
await set_setting(db, "smtp.recipients", recipients)
await set_setting(db, "webhook.url", form.get("webhook.url", ""))
await set_setting(db, "webhook.template", form.get("webhook.template", ""))
await _reload_app_config()
return redirect(url_for("settings.notifications"))
# ── Ansible Sources ───────────────────────────────────────────────────────────
@settings_bp.get("/ansible/")
@require_role(UserRole.admin)
async def ansible():
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
return await render_template("settings/ansible.html", settings=settings)
@settings_bp.post("/ansible/")
@require_role(UserRole.admin)
async def save_ansible():
form = await request.form
sources_raw = form.get("ansible.sources", "[]")
try:
sources = json.loads(sources_raw)
if not isinstance(sources, list):
sources = []
except json.JSONDecodeError:
sources = []
async with current_app.db_sessionmaker() as db:
async with db.begin():
await set_setting(db, "ansible.sources", sources)
await _reload_app_config()
return redirect(url_for("settings.ansible"))
# ── Plugins ───────────────────────────────────────────────────────────────────
@settings_bp.get("/plugins/")
@require_role(UserRole.admin)
async def plugins():
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
discovered = _discover_plugins()
_merge_plugin_config(discovered, to_plugins_cfg(settings))
return await render_template(
"settings/plugins.html",
discovered_plugins=discovered,
settings=settings,
)
@settings_bp.post("/plugins/")
@require_role(UserRole.admin)
async def save_plugins():
form = await request.form
discovered = _discover_plugins()
async with current_app.db_sessionmaker() as db:
async with db.begin():
# Save plugin index URL if provided
index_url = form.get("plugins.index_url", "").strip()
await set_setting(db, "plugins.index_url", index_url)
for plugin in discovered:
name = plugin["_dir"]
enabled = f"plugin.{name}.enabled" in form
plugin_cfg: dict = {"enabled": enabled}
for cfg_key in plugin.get("config", {}).keys():
field_name = f"plugin.{name}.{cfg_key}"
default_val = plugin["config"][cfg_key]
if isinstance(default_val, dict):
sub_dict = {}
for sub_key, sub_default in default_val.items():
sub_field = f"plugin.{name}.{cfg_key}.{sub_key}"
if isinstance(sub_default, bool):
sub_dict[sub_key] = sub_field in form
elif sub_field in form:
raw_sub = form[sub_field]
if isinstance(sub_default, int):
try:
sub_dict[sub_key] = int(raw_sub)
except ValueError:
sub_dict[sub_key] = sub_default
else:
sub_dict[sub_key] = raw_sub
else:
sub_dict[sub_key] = sub_default
plugin_cfg[cfg_key] = sub_dict
elif field_name in form:
raw_val = form[field_name]
if isinstance(default_val, bool):
plugin_cfg[cfg_key] = raw_val.lower() in ("1", "true", "yes", "on")
elif isinstance(default_val, int):
try:
plugin_cfg[cfg_key] = int(raw_val)
except ValueError:
plugin_cfg[cfg_key] = default_val
else:
plugin_cfg[cfg_key] = raw_val
await set_setting(db, f"plugin.{name}", plugin_cfg)
await _reload_app_config()
from fabledscryer.core.plugin_index import clear_catalog_cache
clear_catalog_cache()
return redirect(url_for("settings.plugins"))
# ── Plugin catalog (HTMX partial) ─────────────────────────────────────────────
@settings_bp.get("/plugins/catalog/")
@require_role(UserRole.admin)
async def plugins_catalog():
"""HTMX partial: list plugins available in the remote catalog."""
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
index_url = settings.get("plugins.index_url", "").strip()
if not index_url:
return await render_template(
"settings/_catalog_partial.html",
catalog=[],
installed_names=set(),
loaded_names=set(),
error="No plugin index URL configured.",
)
from fabledscryer.core.plugin_index import fetch_catalog
from fabledscryer.core.plugin_manager import _LOADED_PLUGINS
force = request.args.get("refresh") == "1"
try:
catalog = await fetch_catalog(index_url, force=force)
error = None if catalog else "Catalog is empty or could not be fetched."
except Exception as exc:
catalog = []
error = str(exc)
discovered = _discover_plugins()
installed_names = {p["_dir"] for p in discovered}
return await render_template(
"settings/_catalog_partial.html",
catalog=catalog,
installed_names=installed_names,
loaded_names=_LOADED_PLUGINS,
error=error,
)
# ── Plugin install ─────────────────────────────────────────────────────────────
@settings_bp.post("/plugins/install/<name>")
@require_role(UserRole.admin)
async def install_plugin(name: str):
"""Download and install a plugin from the catalog, then hot-reload it."""
form = await request.form
download_url = form.get("download_url", "").strip()
checksum = form.get("checksum_sha256", "").strip()
if not download_url:
return await render_template(
"settings/_install_result.html",
name=name, success=False,
message="No download URL provided.",
)
from fabledscryer.core.plugin_manager import download_and_install_plugin, hot_reload_plugin
ok, msg = await download_and_install_plugin(
current_app._get_current_object(), name, download_url, checksum
)
if not ok:
return await render_template(
"settings/_install_result.html",
name=name, success=False, message=msg,
)
# Attempt hot-reload
ok2, msg2 = hot_reload_plugin(current_app._get_current_object(), name)
if ok2:
return await render_template(
"settings/_install_result.html",
name=name, success=True,
message=f"Installed and activated. {msg2}",
)
else:
return await render_template(
"settings/_install_result.html",
name=name, success=True,
message=f"Installed. {msg2}",
restart_required=True,
)
# ── Plugin hot-reload ──────────────────────────────────────────────────────────
@settings_bp.post("/plugins/reload/<name>")
@require_role(UserRole.admin)
async def reload_plugin(name: str):
"""Hot-reload a plugin that was installed but not yet active."""
from fabledscryer.core.plugin_manager import hot_reload_plugin
ok, msg = hot_reload_plugin(current_app._get_current_object(), name)
return await render_template(
"settings/_install_result.html",
name=name, success=ok, message=msg,
restart_required=(not ok and "restart" in msg.lower()),
)
# ── App restart ───────────────────────────────────────────────────────────────
@settings_bp.post("/plugins/restart/")
@require_role(UserRole.admin)
async def restart_app_route():
"""Trigger an in-app restart (replaces the process via os.execv)."""
import asyncio
from fabledscryer.core.plugin_manager import restart_app
# Schedule the restart slightly after we return the response
async def _delayed_restart():
await asyncio.sleep(0.5)
restart_app()
asyncio.create_task(_delayed_restart())
return await render_template("settings/_restart_pending.html")
+15
View File
@@ -0,0 +1,15 @@
{#
Reusable time-range selector.
Requires `current_range` to be in template context.
Places a hidden input #time-range (name="range") that HTMX hx-include picks up,
plus a visible button group that calls setTimeRange() defined in base.html.
#}
<div style="display:flex;align-items:center;gap:0.2rem;">
<input type="hidden" id="time-range" name="range" value="{{ current_range }}">
{% for key in ["1h", "6h", "24h", "7d", "30d"] %}
<button type="button"
class="range-btn{% if current_range == key %} active{% endif %}"
data-range="{{ key }}"
onclick="setTimeRange('{{ key }}')">{{ key }}</button>
{% endfor %}
</div>
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Alerts — FabledNetMon{% endblock %}
{% block title %}Alerts — Fabled Scryer{% endblock %}
{% block content %}
<h1 class="page-title">Alerts</h1>
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}New Alert Rule — FabledNetMon{% endblock %}
{% block title %}New Alert Rule — Fabled Scryer{% endblock %}
{% block content %}
<div style="max-width:560px;margin:2rem auto;">
<h1 class="page-title">New Alert Rule</h1>
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Browse Playbooks — FabledNetMon{% endblock %}
{% block title %}Browse Playbooks — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Browse Playbooks</h1>
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Ansible Runs — FabledNetMon{% endblock %}
{% block title %}Ansible Runs — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Ansible Runs</h1>
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Run {{ run.id[:8] }} — FabledNetMon{% endblock %}
{% block title %}Run {{ run.id[:8] }} — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
<a href="/ansible/" style="color:#6060c0;font-size:0.9rem;">← Runs</a>
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Login — FabledNetMon{% endblock %}
{% block title %}Login — Fabled Scryer{% endblock %}
{% block content %}
<div style="max-width:400px;margin:4rem auto;">
<div class="card">
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}First-Run Setup — FabledNetMon{% endblock %}
{% block title %}First-Run Setup — Fabled Scryer{% endblock %}
{% block content %}
<div style="max-width:400px;margin:4rem auto;">
<div class="card">
+355
View File
@@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Fabled Scryer{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Libertinus+Serif:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
<style>
:root {
--bg: #07071a;
--bg-card: #0f0f24;
--bg-elevated: #17172e;
--border: #1e1e3e;
--border-mid: #28284c;
--text: #d2cef0;
--text-muted: #6858a0;
--text-dim: #282860;
--accent: #7c5ef4;
--accent-hover: #9070f8;
--gold: #c9a84c;
--gold-dim: #221b00;
--green: #26d96b;
--green-dim: #0e3a1e;
--yellow: #e6b818;
--yellow-dim: #3a3000;
--orange: #e07828;
--orange-dim: #3a1e00;
--red: #e83848;
--red-dim: #3a0e14;
--font-serif: 'Libertinus Serif', Georgia, serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
/* Star field */
#star-field { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
@keyframes star-orbit { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes star-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* Nav */
nav {
background: var(--bg-card);
padding: 0 1.5rem;
display: flex;
align-items: center;
gap: 0;
border-bottom: 1px solid var(--border-mid);
height: 48px;
position: relative;
z-index: 10;
}
nav .brand {
font-family: var(--font-serif);
font-weight: 700;
font-size: 1.1rem;
margin-right: 2rem;
letter-spacing: 0.01em;
display: flex;
align-items: center;
gap: 0.45rem;
background: linear-gradient(110deg, #c8b4f8 0%, var(--gold) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-decoration: none;
}
nav .brand svg { flex-shrink: 0; }
nav a { color: var(--text-muted); font-size: 0.875rem; padding: 0 0.875rem; height: 48px; display: flex; align-items: center; border-bottom: 2px solid transparent; transition: color .15s, border-color .15s; }
nav a:hover { color: var(--text); border-bottom-color: var(--border-mid); }
nav a.nav-end { margin-left: auto; }
/* Layout */
main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; width: 100%; box-sizing: border-box; position: relative; z-index: 1; }
/* Alerts */
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
.alert-error { background: var(--red-dim); border: 1px solid #6a1820; color: #ffb0b8; }
.alert-success { background: var(--green-dim); border: 1px solid #1a5a28; color: #90f0b0; }
/* Cards */
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 1rem; }
/* Typography */
.page-title { font-family: var(--font-serif); font-size: 1.6rem; font-weight: 600; color: #c8b4f8; margin-bottom: 1.5rem; letter-spacing: 0.01em; }
.section-title { font-family: var(--font-serif); font-size: 0.82rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
h1, h2, h3 { font-family: var(--font-serif); }
/* Tables */
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 0.6rem 1rem; color: var(--text-muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-mid); }
.table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
.table tbody tr:last-child td { border-bottom: none; }
.table tbody tr:hover td { background: var(--bg-elevated); }
.table .td-actions { text-align: right; white-space: nowrap; }
/* Forms */
.form-group { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.3rem; font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-serif); }
input[type=text], input[type=email], input[type=password], input[type=number], input[type=url], select, textarea {
width: 100%; padding: 0.5rem 0.75rem; background: var(--bg); border: 1px solid var(--border-mid);
border-radius: 5px; color: var(--text); font-size: 0.9rem; font-family: inherit;
transition: border-color .15s;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
select { cursor: pointer; }
textarea { resize: vertical; }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 1rem; background: var(--accent); color: #fff; border: none; border-radius: 5px; cursor: pointer; font-size: 0.875rem; font-family: inherit; font-weight: 500; transition: background .15s; text-decoration: none; }
.btn:hover { background: var(--accent-hover); color: #fff; }
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-mid); }
.btn-ghost:hover { background: var(--bg-elevated); color: var(--text); }
.btn-danger { background: #7a1820; color: #ffb0b0; }
.btn-danger:hover { background: #9a2030; }
.btn-warn { background: var(--yellow-dim); color: var(--yellow); border: 1px solid #6a5000; }
.btn-warn:hover { background: #4a4000; }
/* Badges / Status */
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 4px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.03em; }
.badge-green { background: var(--green-dim); color: var(--green); }
.badge-red { background: var(--red-dim); color: #ff8090; }
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
.badge-orange { background: var(--orange-dim); color: var(--orange); }
.badge-gold { background: var(--gold-dim); color: var(--gold); }
.badge-dim { background: var(--bg-elevated); color: var(--text-muted); }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-up { background: var(--green); box-shadow: 0 0 4px var(--green); }
.dot-down { background: var(--red); }
.dot-warn { background: var(--yellow); }
.dot-dim { background: var(--text-dim); }
/* Empty state */
.empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
/* Stat cards (dashboard) */
.stat-val { font-size: 2rem; font-weight: 700; line-height: 1; }
.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-left: 0.3rem; }
/* Ping pills */
.ping-card { padding: 0.75rem 1.25rem; }
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid var(--border); }
.ping-row:last-child { border-bottom:none; }
.ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
.ping-name { font-size:0.875rem; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block; }
.ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ping-pills { display:flex; gap:2px; flex:1; min-width:0; overflow:hidden; align-items:center; }
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; cursor:default; transition:opacity .1s; }
.pill:hover { opacity:.75; }
.pill-empty { background:var(--bg-elevated); }
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
.ping-cur-good { color:var(--green); }
.ping-cur-warn { color:var(--yellow); }
.ping-cur-bad { color:var(--orange); }
.ping-cur-down { color:var(--red); font-weight:bold; }
.ping-cur-nd { color:var(--text-dim); }
/* Widget headers */
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
.widget-title { font-family:var(--font-serif); font-size:0.8rem; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; }
.widget-link { font-size:0.8rem; color:var(--text-muted); }
.widget-link:hover { color:var(--text); }
/* Time range selector */
.range-btn { background:none; border:1px solid var(--border-mid); border-radius:4px; color:var(--text-muted); padding:0.18rem 0.5rem; cursor:pointer; font-size:0.75rem; font-family:inherit; transition:background .1s,color .1s,border-color .1s; }
.range-btn:hover { background:var(--bg-elevated); color:var(--text); border-color:var(--accent); }
.range-btn.active { background:var(--accent); border-color:var(--accent); color:#fff; }
</style>
{% block head %}{% endblock %}
</head>
<body>
{# Star field — orbits around top-left corner, populated by JS below #}
<div id="star-field"></div>
{% if session.user_id is defined %}
<nav>
<a href="/" class="brand">
{# Crystal ball logo #}
<svg width="20" height="24" viewBox="0 0 20 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<radialGradient id="orb-grad" cx="38%" cy="32%" r="62%">
<stop offset="0%" stop-color="#6040c8"/>
<stop offset="55%" stop-color="#2a1468"/>
<stop offset="100%" stop-color="#0e0830"/>
</radialGradient>
<radialGradient id="glow-grad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#c9a84c" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#c9a84c" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Ball body -->
<circle cx="10" cy="10" r="9" fill="url(#orb-grad)" stroke="#7c5ef4" stroke-width="0.5"/>
<!-- Inner gold glow -->
<circle cx="10" cy="10" r="5" fill="url(#glow-grad)"/>
<!-- Reflection highlight -->
<ellipse cx="7.5" cy="7" rx="2.2" ry="1.1" fill="white" opacity="0.14" transform="rotate(-25 7.5 7)"/>
<!-- Inner star -->
<path d="M10 7.2 L10.45 8.85 L12.2 8.85 L10.88 9.9 L11.35 11.55 L10 10.55 L8.65 11.55 L9.12 9.9 L7.8 8.85 L9.55 8.85 Z"
fill="#c9a84c" opacity="0.55"/>
<!-- Stand neck -->
<path d="M8.2 19 L8.9 15.8 L11.1 15.8 L11.8 19" fill="none" stroke="#5040a0" stroke-width="0.75" stroke-linejoin="round"/>
<!-- Stand base -->
<ellipse cx="10" cy="19.5" rx="4" ry="1" fill="none" stroke="#5040a0" stroke-width="0.75"/>
</svg>
Fabled Scryer
</a>
<a href="/">Dashboard</a>
<a href="/hosts/">Hosts</a>
<a href="/ping/">Ping</a>
<a href="/dns/">DNS</a>
<a href="/alerts/">Alerts</a>
<a href="/ansible/">Ansible</a>
{% if session.user_role == 'admin' %}
<a href="/settings/">Settings</a>
{% endif %}
<a href="/logout" class="nav-end">{{ session.username }}</a>
</nav>
{% endif %}
<script>
function setTimeRange(val) {
var input = document.getElementById('time-range');
if (input) input.value = val;
document.querySelectorAll('.range-btn').forEach(function(b) {
b.classList.toggle('active', b.dataset.range === val);
});
var url = new URL(window.location);
url.searchParams.set('range', val);
history.replaceState({}, '', url);
document.body.dispatchEvent(new CustomEvent('rangeChange'));
}
</script>
<main>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<script>
(function () {
var field = document.getElementById('star-field');
if (!field) return;
var W = window.innerWidth;
var H = window.innerHeight;
var maxR = Math.sqrt(W * W + H * H);
// Subtle purple glow anchoring the field to the top-left corner
var glow = document.createElement('div');
glow.style.cssText = 'position:absolute;width:500px;height:500px;left:-250px;top:-250px;border-radius:50%;background:radial-gradient(circle,rgba(124,94,244,0.06) 0%,transparent 65%);pointer-events:none;';
field.appendChild(glow);
// Compute polygon points for a 5-pointed star inside a size×size box
function starPoints(size) {
var cx = size / 2, cy = size / 2;
var R = size / 2 * 0.95; // outer radius
var r = R * 0.382; // inner radius (golden ratio)
var pts = [];
for (var i = 0; i < 5; i++) {
var oa = (i * 72 - 90) * Math.PI / 180;
var ia = (i * 72 - 90 + 36) * Math.PI / 180;
pts.push((cx + R * Math.cos(oa)).toFixed(2) + ',' + (cy + R * Math.sin(oa)).toFixed(2));
pts.push((cx + r * Math.cos(ia)).toFixed(2) + ',' + (cy + r * Math.sin(ia)).toFixed(2));
}
return pts.join(' ');
}
// 22 wizard-hat stars: [size(px), opacity, isGold, orbitDuration(s), spinDuration(s)]
var defs = [
[230, 0.09, true, 200, 28],
[144, 0.08, true, 260, 40],
[ 92, 0.10, false, 150, 22],
[190, 0.07, true, 310, 35],
[116, 0.09, false, 175, 18],
[260, 0.07, true, 280, 45],
[ 80, 0.11, false, 135, 20],
[164, 0.08, true, 230, 32],
[104, 0.09, false, 165, 24],
[200, 0.07, true, 295, 38],
[ 70, 0.10, false, 120, 16],
[150, 0.08, true, 245, 30],
[ 88, 0.09, false, 140, 21],
[210, 0.07, true, 320, 42],
[128, 0.08, false, 180, 26],
[ 76, 0.10, true, 160, 19],
[175, 0.07, true, 290, 36],
[ 96, 0.09, false, 155, 23],
[220, 0.07, true, 305, 44],
[110, 0.08, false, 170, 25],
[ 84, 0.09, true, 235, 31],
[140, 0.08, false, 185, 27],
];
var n = defs.length;
defs.forEach(function (def, i) {
var size = def[0], opacity = def[1], isGold = def[2], orbitDur = def[3], spinDur = def[4];
// Spread evenly across a ~110° fan from top-left, small random jitter
var angleDeg = 4 + (i / (n - 1)) * 108 + (Math.random() * 8 - 4);
var angle = angleDeg * Math.PI / 180;
var radius = (0.15 + (i / n) * 0.7 + Math.random() * 0.15) * maxR * 0.88 + 60;
// Star center position in parent coords
var cx = Math.cos(angle) * radius;
var cy = Math.sin(angle) * radius;
var left = cx - size / 2;
var top = cy - size / 2;
var orbitDelay = -(Math.random() * orbitDur);
var spinDelay = -(Math.random() * spinDur);
var color = isGold ? '#c9a84c' : '#c8b4f8';
// Outer wrapper handles orbit — transform-origin points back to (0,0) corner
var wrapper = document.createElement('div');
wrapper.style.cssText = [
'position:absolute',
'left:' + left + 'px',
'top:' + top + 'px',
'width:' + size + 'px',
'height:'+ size + 'px',
'opacity:' + opacity,
'transform-origin:' + (-left) + 'px ' + (-top) + 'px',
'animation:star-orbit ' + orbitDur + 's ' + orbitDelay + 's linear infinite',
'pointer-events:none'
].join(';');
// Inner SVG handles spin — transform-origin defaults to 50% 50% (star center)
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', size);
svg.setAttribute('height', size);
svg.setAttribute('viewBox', '0 0 ' + size + ' ' + size);
svg.style.cssText = 'display:block;animation:star-spin ' + spinDur + 's ' + spinDelay + 's linear infinite;';
var poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
poly.setAttribute('points', starPoints(size));
poly.setAttribute('fill', color);
svg.appendChild(poly);
wrapper.appendChild(svg);
field.appendChild(wrapper);
});
}());
</script>
</body>
</html>
@@ -0,0 +1,70 @@
{# dashboard/_edit_panels.html — returned by all mutation routes and included by edit.html #}
<div id="edit-panels" style="display:grid;grid-template-columns:1fr 320px;gap:1.5rem;align-items:start;">
{# ── Current layout ───────────────────────────────────────────────────── #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Current Layout</div>
{% if widgets %}
<div style="display:grid;gap:0.5rem;">
{% for item in widgets %}
<div class="card" style="margin-bottom:0;display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
<div style="display:flex;flex-direction:column;gap:2px;flex-shrink:0;">
<button hx-post="/d/{{ dashboard.id }}/edit/move/{{ item.db.id }}/up"
hx-target="#edit-panels" hx-swap="outerHTML"
class="btn btn-ghost btn-sm"
style="padding:0.1rem 0.4rem;font-size:0.7rem;line-height:1;"
{% if loop.first %}disabled{% endif %}></button>
<button hx-post="/d/{{ dashboard.id }}/edit/move/{{ item.db.id }}/down"
hx-target="#edit-panels" hx-swap="outerHTML"
class="btn btn-ghost btn-sm"
style="padding:0.1rem 0.4rem;font-size:0.7rem;line-height:1;"
{% if loop.last %}disabled{% endif %}></button>
</div>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.9rem;">{{ item.defn.label }}</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.1rem;">{{ item.defn.description }}</div>
</div>
<button hx-post="/d/{{ dashboard.id }}/edit/remove/{{ item.db.id }}"
hx-target="#edit-panels" hx-swap="outerHTML"
class="btn btn-danger btn-sm" style="flex-shrink:0;">Remove</button>
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;font-size:0.9rem;">
No widgets yet — add some from the panel on the right.
</div>
{% endif %}
</div>
{# ── Available widgets ────────────────────────────────────────────────── #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Available Widgets</div>
{% if addable %}
<div style="display:grid;gap:0.5rem;">
{% for w in addable %}
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;">
<div>
<div style="font-weight:600;font-size:0.875rem;">{{ w.label }}</div>
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;">{{ w.description }}</div>
</div>
<button hx-post="/d/{{ dashboard.id }}/edit/add/{{ w.key }}"
hx-target="#edit-panels" hx-swap="outerHTML"
class="btn btn-sm" style="flex-shrink:0;">Add</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:1rem 0;">
All available widgets are on this dashboard.
</div>
{% endif %}
</div>
</div>
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Edit: {{ dashboard.name }} — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Edit: {{ dashboard.name }}</h1>
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost btn-sm">Done</a>
</div>
{% include "dashboard/_edit_panels.html" %}
{% endblock %}
@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}{{ dashboard.name }} — Fabled Scryer{% endblock %}
{% block content %}
{# ── Header ────────────────────────────────────────────────────────────────── #}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:0.75rem;">
<h1 class="page-title" style="margin-bottom:0;">{{ dashboard.name }}</h1>
{% if dashboard.is_default %}
<span class="badge badge-gold" style="font-size:0.7rem;">Default</span>
{% endif %}
</div>
<div style="display:flex;align-items:center;gap:0.75rem;">
{# Dashboard switcher — shown when more than one dashboard exists #}
{% if all_dashboards | length > 1 %}
<select onchange="window.location='/d/'+this.value"
style="font-size:0.82rem;padding:0.3rem 0.6rem;background:var(--bg-card);border:1px solid var(--border-mid);border-radius:5px;color:var(--text);cursor:pointer;">
{% for d in all_dashboards %}
<option value="{{ d.id }}" {% if d.id == dashboard.id %}selected{% endif %}>{{ d.name }}</option>
{% endfor %}
</select>
{% endif %}
{% if can_edit %}
<a href="/d/{{ dashboard.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
<a href="/d/{{ dashboard.id }}/share" class="btn btn-ghost btn-sm">Share</a>
{% endif %}
<a href="/dashboards/" class="btn btn-ghost btn-sm">Dashboards</a>
</div>
</div>
{# ── Summary strip ─────────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Hosts</div>
<span class="stat-val">{{ total_hosts }}</span>
<span class="stat-label">total</span>
<div style="margin-top:0.5rem;"><a href="/hosts/" style="font-size:0.8rem;">Manage →</a></div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Ping</div>
{% if ping_up + ping_down == 0 %}
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
{% else %}
<span class="stat-val" style="color:var(--green);">{{ ping_up }}</span><span class="stat-label">up</span>
{% if ping_down %}&nbsp;<span class="stat-val" style="color:var(--red);">{{ ping_down }}</span><span class="stat-label">down</span>{% endif %}
{% endif %}
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">DNS</div>
{% if dns_ok + dns_fail == 0 %}
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
{% else %}
<span class="stat-val" style="color:var(--green);">{{ dns_ok }}</span><span class="stat-label">ok</span>
{% if dns_fail %}&nbsp;<span class="stat-val" style="color:var(--red);">{{ dns_fail }}</span><span class="stat-label">failed</span>{% endif %}
{% endif %}
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Alerts</div>
<a href="/alerts/" style="font-size:0.85rem;">View rules →</a>
</div>
</div>
{# ── Widget grid ───────────────────────────────────────────────────────────── #}
{% if widgets %}
<div style="column-width:380px;column-count:3;column-gap:1rem;">
{% for item in widgets %}
<div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="widget-header">
<span class="widget-title">{{ item.defn.label }}</span>
<a href="{{ item.defn.detail_url }}" class="widget-link">Details →</a>
</div>
<div id="widget-{{ item.db.id }}"
hx-get="{{ item.defn.hx_url }}"
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-swap="innerHTML">
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<div style="font-size:1.1rem;color:var(--text-muted);margin-bottom:1rem;">No widgets on this dashboard yet.</div>
{% if can_edit %}
<a href="/d/{{ dashboard.id }}/edit" class="btn">Add Widgets</a>
{% endif %}
</div>
{% endif %}
{% endblock %}
+132
View File
@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block title %}Dashboards — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Dashboards</h1>
</div>
<div style="display:grid;grid-template-columns:1fr 300px;gap:1.5rem;align-items:start;">
{# ── Left: dashboard lists ─────────────────────────────────────────── #}
<div style="display:grid;gap:1.5rem;">
{# My dashboards #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">My Dashboards</div>
{% if my_dashboards %}
<div style="display:grid;gap:0.5rem;">
{% for dash in my_dashboards %}
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;">
<div style="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
{% if dash.is_default %}
<span class="badge badge-gold" style="font-size:0.68rem;">Your Default</span>
{% endif %}
{% if dash.is_shared %}
<span class="badge badge-dim" style="font-size:0.68rem;">Shared</span>
{% endif %}
</div>
</div>
<div style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0;">
<a href="/d/{{ dash.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
<details style="position:relative;">
<summary class="btn btn-ghost btn-sm" style="cursor:pointer;list-style:none;">Rename</summary>
<div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:0 4px 16px rgba(0,0,0,0.4);">
<form method="post" action="/dashboards/{{ dash.id }}/rename" style="display:flex;gap:0.4rem;">
<input type="text" name="name" value="{{ dash.name }}"
style="flex:1;font-size:0.85rem;padding:0.3rem 0.5rem;" required>
<button type="submit" class="btn btn-sm">Save</button>
</form>
</div>
</details>
<form method="post" action="/dashboards/{{ dash.id }}/toggle-share" style="margin:0;" title="{{ 'Make private' if dash.is_shared else 'Share with all users' }}">
<button type="submit" class="btn btn-ghost btn-sm">{{ '🔒' if dash.is_shared else '🔗' }}</button>
</form>
{% if not dash.is_default %}
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">
<button type="submit" class="btn btn-ghost btn-sm"></button>
</form>
{% endif %}
{% if my_dashboards | length > 1 %}
<form method="post" action="/dashboards/{{ dash.id }}/delete" style="margin:0;"
onsubmit="return confirm('Delete \'{{ dash.name }}\'?')">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
You don't have any personal dashboards yet.
</div>
{% endif %}
</div>
{# Shared / system dashboards #}
{% if shared_dashboards %}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Shared Dashboards</div>
<div style="display:grid;gap:0.5rem;">
{% for dash in shared_dashboards %}
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;">
<div style="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
{% if dash.owner_id is none %}
<span class="badge badge-dim" style="font-size:0.68rem;">System</span>
{% endif %}
{% if dash.is_default %}
<span class="badge badge-gold" style="font-size:0.68rem;">Global Default</span>
{% endif %}
</div>
</div>
<div style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0;">
<a href="/d/{{ dash.id }}" class="btn btn-ghost btn-sm">View</a>
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">
<button type="submit" class="btn btn-ghost btn-sm"></button>
</form>
{# Admins can also edit system dashboards #}
{% if user_role == 'admin' %}
<a href="/d/{{ dash.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{# ── Right: create new dashboard ──────────────────────────────────── #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">New Dashboard</div>
<div class="card" style="margin-bottom:0;">
<form method="post" action="/dashboards/create">
<div class="form-group" style="margin-bottom:0.75rem;">
<label>Name</label>
<input type="text" name="name" placeholder="e.g. Network Overview" required>
</div>
<button type="submit" class="btn">Create</button>
</form>
</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.75rem;line-height:1.5;">
New dashboards are private by default.<br>
Use 🔗 to share with all users.<br>
Use ★ to set as your default view.
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Share: {{ dashboard.name }} — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Share: {{ dashboard.name }}</h1>
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost btn-sm">Back to Dashboard</a>
</div>
<div style="display:grid;grid-template-columns:1fr 320px;gap:1.5rem;align-items:start;max-width:900px;">
{# ── Active tokens ─────────────────────────────────────────────────── #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Active Share Links</div>
{% if tokens %}
<div style="display:grid;gap:0.5rem;">
{% for token in tokens %}
{% set expired = token.expires_at is not none and token.expires_at <= now %}
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;{% if expired %}opacity:0.5;{% endif %}">
<div style="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.875rem;">
{{ token.label or "Unnamed link" }}
{% if expired %}<span class="badge badge-red" style="font-size:0.68rem;">Expired</span>{% endif %}
</div>
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.15rem;">
Created {{ token.created_at.strftime("%Y-%m-%d") }}
{% if token.expires_at %}
· Expires {{ token.expires_at.strftime("%Y-%m-%d") }}
{% else %}
· Never expires
{% endif %}
</div>
</div>
<form method="post" action="/d/{{ dashboard.id }}/share/revoke/{{ token.id }}"
style="margin:0;" onsubmit="return confirm('Revoke this share link?')">
<button type="submit" class="btn btn-danger btn-sm">Revoke</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
No active share links. Create one on the right.
</div>
{% endif %}
</div>
{# ── Create new token ──────────────────────────────────────────────── #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Create Share Link</div>
<div class="card" style="margin-bottom:0;">
<form method="post" action="/d/{{ dashboard.id }}/share/create">
<div class="form-group" style="margin-bottom:0.75rem;">
<label>Label <span style="color:var(--text-muted);font-size:0.78rem;">(optional)</span></label>
<input type="text" name="label" placeholder="e.g. Public display screen">
</div>
<div class="form-group" style="margin-bottom:0.75rem;">
<label>Expires after</label>
<select name="expires_in">
<option value="">Never</option>
<option value="1">1 day</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</div>
<button type="submit" class="btn">Generate Link</button>
</form>
</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.75rem;line-height:1.5;">
The link is shown <strong>once</strong> after creation.<br>
Revoke it at any time to cut off access immediately.
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Share Link Created — Fabled Scryer{% endblock %}
{% block content %}
<div style="max-width:640px;">
<h1 class="page-title">Share Link Created</h1>
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;">
<div style="font-size:0.8rem;font-weight:600;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
Copy this link now — it cannot be shown again
</div>
{% set share_url = request.host_url.rstrip('/') + '/share/' + raw_token %}
<div style="display:flex;gap:0.5rem;align-items:center;">
<input type="text" id="share-url" value="{{ share_url }}" readonly
style="flex:1;font-family:ui-monospace,monospace;font-size:0.8rem;background:var(--bg);color:var(--text);">
<button onclick="navigator.clipboard.writeText(document.getElementById('share-url').value).then(()=>this.textContent='Copied!')"
class="btn btn-ghost btn-sm" style="white-space:nowrap;">Copy</button>
</div>
<div style="margin-top:0.75rem;font-size:0.82rem;color:var(--text-muted);">
{% if label %}<strong>Label:</strong> {{ label }} &nbsp;·&nbsp;{% endif %}
{% if expires_at %}<strong>Expires:</strong> {{ expires_at.strftime("%Y-%m-%d") }}
{% else %}<strong>Expires:</strong> Never{% endif %}
</div>
</div>
<div style="display:flex;gap:0.75rem;">
<a href="/d/{{ dashboard.id }}/share" class="btn btn-ghost">Manage Share Links</a>
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost">Back to Dashboard</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ dashboard.name }} — Fabled Scryer</title>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Libertinus+Serif:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
<style>
:root {
--bg: #07071a; --bg-card: #0f0f24; --bg-elevated: #17172e;
--border: #1e1e3e; --border-mid: #28284c;
--text: #d2cef0; --text-muted: #6858a0; --text-dim: #282860;
--accent: #7c5ef4; --gold: #c9a84c;
--green: #26d96b; --green-dim: #0e3a1e;
--yellow: #e6b818; --red: #e83848; --red-dim: #3a0e14;
--orange: #e07828;
--font-serif: 'Libertinus Serif', Georgia, serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
a { color: var(--accent); text-decoration: none; }
code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 1rem; }
.section-title { font-family: var(--font-serif); font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 0.6rem 1rem; color: var(--text-muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-mid); }
.table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
.table tbody tr:last-child td { border-bottom: none; }
.table tbody tr:hover td { background: var(--bg-elevated); }
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 4px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.03em; }
.badge-green { background: var(--green-dim); color: var(--green); }
.badge-red { background: var(--red-dim); color: #ff8090; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-up { background: var(--green); box-shadow: 0 0 4px var(--green); }
.dot-down { background: var(--red); }
.dot-warn { background: var(--yellow); }
.dot-dim { background: var(--text-dim); }
.ping-card { padding: 0.75rem 1.25rem; }
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid var(--border); }
.ping-row:last-child { border-bottom:none; }
.ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
.ping-name { font-size:0.875rem; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block; }
.ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ping-pills { display:flex; gap:2px; flex:1; min-width:0; overflow:hidden; align-items:center; }
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; }
.pill-empty { background:var(--bg-elevated); }
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
.ping-cur-good { color:var(--green); } .ping-cur-warn { color:var(--yellow); }
.ping-cur-bad { color:var(--orange); } .ping-cur-down { color:var(--red); font-weight:bold; }
.ping-cur-nd { color:var(--text-dim); }
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
.widget-title { font-family:var(--font-serif); font-size:0.8rem; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; }
.stat-val { font-size: 2rem; font-weight: 700; line-height: 1; }
.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-left: 0.3rem; }
header { background: var(--bg-card); padding: 0 1.5rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border-mid); height: 48px; font-family: var(--font-serif); }
main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; }
</style>
</head>
<body>
<header>
<span style="font-weight:700;font-size:1rem;background:linear-gradient(110deg,#c8b4f8,#c9a84c);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;">
{{ dashboard.name }}
</span>
<span style="font-size:0.78rem;color:var(--text-muted);">Fabled Scryer · read-only view</span>
</header>
<main>
{% if widgets %}
<div style="column-width:380px;column-count:3;column-gap:1rem;">
{% for item in widgets %}
<div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="widget-header">
<span class="widget-title">{{ item.defn.label }}</span>
</div>
{# Pass share token as ?s= param so require_role allows through #}
<div id="widget-{{ item.db.id }}"
hx-get="{{ item.defn.hx_url }}?s={{ raw_token }}"
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-swap="innerHTML">
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="color:var(--text-muted);text-align:center;padding:4rem;font-size:0.9rem;">
This dashboard has no widgets.
</div>
{% endif %}
</main>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More