Merge pull request 'feat: host_agent plugin — push-model host resource monitoring' (#1) from feat/host-agent into main

This commit was merged in pull request #1.
This commit is contained in:
2026-04-15 13:43:57 +00:00
166 changed files with 9602 additions and 1533 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# Copy to .env for Docker / local development secrets
# These override values in config.yaml
FABLEDSCRYER_SECRET_KEY=change-me
FABLEDSCRYER_DATABASE__URL=postgresql+asyncpg://fabledscryer:password@localhost/fabledscryer
FABLEDSCRYER_SMTP__PASSWORD=
ROUNDTABLE_SECRET_KEY=change-me
ROUNDTABLE_DATABASE__URL=postgresql+asyncpg://roundtable:password@localhost/roundtable
ROUNDTABLE_SMTP__PASSWORD=
+1 -1
View File
@@ -1,7 +1,7 @@
# Planning files
docs/superpowers/
# Plugin directory — managed as a separate repo (bvandeusen/fabledscryer-plugins)
# Plugin directory — managed as a separate repo (bvandeusen/roundtable-plugins)
# PLUGIN_DIR in config.yaml points here at runtime
/plugins/
+12 -6
View File
@@ -1,7 +1,10 @@
FROM python:3.13-slim
RUN DEBIAN_FRONTEND=noninteractive apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iputils-ping \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
iputils-ping \
# gosu — minimal privilege-drop tool used by entrypoint.sh
gosu \
&& rm -rf /var/lib/apt/lists/*
# Upgrade pip to suppress the version notice
@@ -10,18 +13,21 @@ RUN pip install --upgrade pip
WORKDIR /app
COPY pyproject.toml .
COPY fabledscryer/ fabledscryer/
COPY roundtable/ roundtable/
RUN pip install --no-cache-dir .
COPY alembic.ini .
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Runtime directories — owned by app user
# Runtime directories — owned by app user. The container starts as root
# so the entrypoint can fix up /data permissions if needed, then drops to
# 'app' (uid 1000) via gosu before launching roundtable.
RUN useradd -m -u 1000 app \
&& mkdir -p /data/playbook_cache /data/plugins \
&& chown -R app:app /data /app
USER app
EXPOSE 5000
CMD ["fabledscryer", "--host", "0.0.0.0", "--port", "5000"]
ENTRYPOINT ["/entrypoint.sh"]
CMD ["roundtable", "--host", "0.0.0.0", "--port", "5000"]
+5 -5
View File
@@ -1,6 +1,6 @@
# Fabled Scryer
# Roundtable
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.
A self-hosted network monitoring and infrastructure management hub for home servers. Roundtable gives you a single pane of glass over your hosts, services, and automation — with live-updating dashboards, alerting, and Ansible integration.
---
@@ -21,7 +21,7 @@ No JavaScript framework. No build step. No external workers or message brokers.
```bash
cp .env.example .env
# Set FABLEDSCRYER_DATABASE_URL in .env
# Set ROUNDTABLE_DATABASE_URL in .env
docker compose up -d
```
@@ -41,7 +41,7 @@ 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
roundtable --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.
@@ -54,7 +54,7 @@ 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` |
| Database URL | `ROUNDTABLE_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/`.
+1 -1
View File
@@ -1,5 +1,5 @@
[alembic]
script_location = fabledscryer/migrations
script_location = roundtable/migrations
prepend_sys_path = .
version_path_separator = os
+3 -3
View File
@@ -1,9 +1,9 @@
# Fabled Scryer — Bootstrap Configuration Example
# Roundtable — Bootstrap Configuration Example
#
# The only REQUIRED setting is the database URL.
# Set it via env var (recommended for Docker):
#
# FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://user:pass@host/db
# ROUNDTABLE_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://fabledscryer:password@localhost/fabledscryer"
url: "postgresql+asyncpg://roundtable:password@localhost/roundtable"
# Optional: override the auto-generated secret key.
# If not set, a key is auto-generated on first run and saved to /data/secret.key.
+5 -3
View File
@@ -1,15 +1,17 @@
services:
app:
roundtable:
build: .
container_name: roundtable
image: roundtable:latest
ports:
- "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
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@db/fabledscryer
- ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@db/fabledscryer
depends_on:
db:
condition: service_healthy
+13 -13
View File
@@ -1,12 +1,12 @@
# 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.
Roundtable 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:
`create_app()` in `roundtable/app.py` runs these steps synchronously before the event loop starts:
| Step | What happens |
|---|---|
@@ -32,14 +32,14 @@ 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` |
| `/auth/` | `auth_bp` | `roundtable/auth/routes.py` |
| `/` | `dashboard_bp` | `roundtable/dashboard/routes.py` |
| `/hosts/` | `hosts_bp` | `roundtable/hosts/routes.py` |
| `/ping/` | `ping_bp` | `roundtable/ping/routes.py` |
| `/dns/` | `dns_bp` | `roundtable/dns/routes.py` |
| `/alerts/` | `alerts_bp` | `roundtable/alerts/routes.py` |
| `/ansible/` | `ansible_bp` | `roundtable/ansible/routes.py` |
| `/settings/` | `settings_bp` | `roundtable/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.
@@ -48,7 +48,7 @@ Plugin blueprints are mounted automatically by `load_plugins()` using the plugin
## Scheduler
`fabledscryer/core/scheduler.py` exports two things:
`roundtable/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
@@ -93,9 +93,9 @@ This means the only file you must touch to get the app running is the database U
No JavaScript framework, no build step. The frontend is:
- **Jinja2 templates** rendered server-side (`fabledscryer/templates/`)
- **Jinja2 templates** rendered server-side (`roundtable/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
- A single CSS design system in `roundtable/templates/base.html` using CSS custom properties
Live-updating widgets use HTMX polling:
```html
+2 -2
View File
@@ -14,7 +14,7 @@ When any monitor or plugin calls `record_metric()`:
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)`
**Key function:** `roundtable/core/alerts.py``record_metric(session, source_module, resource_name, metric_name, value)`
`record_metric()` must always be called inside an active transaction:
@@ -130,7 +130,7 @@ Webhook is skipped if `webhook.url` is empty.
## Data Models
Defined in `fabledscryer/models/alerts.py`:
Defined in `roundtable/models/alerts.py`:
- **`alert_rules`** — one row per configured rule
- **`alert_states`** — one row per rule, tracks current state and consecutive failure count
+6 -6
View File
@@ -1,6 +1,6 @@
# 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.
Roundtable 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.
---
@@ -115,7 +115,7 @@ Output stored in the DB is capped at 1 MB. If truncated, `[output truncated]` is
## Data Model
`ansible_runs` table (defined in `fabledscryer/models/ansible.py`):
`ansible_runs` table (defined in `roundtable/models/ansible.py`):
| Column | Type | Description |
|---|---|---|
@@ -137,7 +137,7 @@ Runs older than `data.retention_days` (default 90) are pruned by the `data_clean
| 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 |
| `roundtable/ansible/sources.py` | Source discovery, git pull logic |
| `roundtable/ansible/executor.py` | Subprocess execution and output streaming |
| `roundtable/ansible/routes.py` | HTTP routes (browse, trigger, stream, history) |
| `roundtable/models/ansible.py` | `AnsibleRun` model |
+12 -10
View File
@@ -1,6 +1,6 @@
# 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.
Roundtable 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.
---
@@ -10,29 +10,31 @@ These three values are read at startup from `config.yaml` and/or environment var
| 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. |
| `database.url` | `ROUNDTABLE_DATABASE_URL` | — | PostgreSQL async URL. **Required.** |
| `secret_key` | `ROUNDTABLE_SECRET_KEY` | auto-generated | Flask/Quart session signing key. Auto-generated and saved to `/data/secret.key` if not set. |
| `plugin_dir` | `ROUNDTABLE_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 `database_url`:** env var `ROUNDTABLE_DATABASE_URL` → env var `ROUNDTABLE_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`.
**Resolution order for `secret_key`:** env var `ROUNDTABLE_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"
url: "postgresql+asyncpg://user:password@localhost/roundtable"
```
### Minimal env-only setup (Docker)
```bash
FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://user:password@db/fabledscryer
ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://user:password@db/roundtable
```
A `.env` file is loaded automatically if present.
> **Legacy env vars:** `FABLEDSCRYER_*` env vars are still accepted as a fallback for existing deployments. They will be removed in a future release — migrate to `ROUNDTABLE_*` when convenient.
---
## App Settings (Database-backed)
@@ -66,7 +68,7 @@ Default webhook template:
### Reading and Writing Settings in Code
```python
from fabledscryer.core.settings import get_setting, set_setting
from roundtable.core.settings import get_setting, set_setting
# Read
async with current_app.db_sessionmaker() as session:
@@ -82,7 +84,7 @@ async with current_app.db_sessionmaker() as session:
### 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.
`roundtable/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.
---
+7 -7
View File
@@ -1,13 +1,13 @@
# 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.
Roundtable 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`
**Source:** `roundtable/monitors/ping.py`
**Scheduler task:** `ping_monitor` in `roundtable/app.py`
**Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup
### How It Works
@@ -29,7 +29,7 @@ Each probe writes a `PingResult` row and calls `record_metric()`:
### Data Model
`ping_results` table (defined in `fabledscryer/models/monitors.py`):
`ping_results` table (defined in `roundtable/models/monitors.py`):
| Column | Type | Description |
|---|---|---|
@@ -51,8 +51,8 @@ Old rows are pruned by the `data_cleanup` task (default: 90 days).
## DNS Monitor
**Source:** `fabledscryer/monitors/dns.py`
**Scheduler task:** `dns_monitor` in `fabledscryer/app.py`
**Source:** `roundtable/monitors/dns.py`
**Scheduler task:** `dns_monitor` in `roundtable/app.py`
**Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup
### How It Works
@@ -70,7 +70,7 @@ Each check writes a `DnsResult` row and calls `record_metric()`:
### Data Model
`dns_results` table (defined in `fabledscryer/models/monitors.py`):
`dns_results` table (defined in `roundtable/models/monitors.py`):
| Column | Type | Description |
|---|---|---|
+535
View File
@@ -0,0 +1,535 @@
# Host Agent Plugin — Design Spec
**Date:** 2026-04-14
**Status:** Approved, ready for implementation planning
**Scope:** Roundtable plugin for remote host resource monitoring (CPU, memory, storage, load, uptime) via a lightweight Python push agent.
---
## Goal
Give Roundtable a fleet-glance view of remote Linux hosts — current resource usage, history, and alert integration — without requiring agentless SSH/Ansible round-trips, a new toolchain, or SNMP gymnastics on every target.
## Non-goals
- Not a fleet-management system (no remote agent updates, no centrally-pushed config beyond the initial install).
- Not a log collector. Time-series metrics only.
- Not a container monitor — that is the existing `docker` plugin's job. The host agent observes the host itself, not containers running on it.
- Not a network monitor. Traffic counters are deferred to a follow-up.
- Not event-driven. Sampling is interval-based; OOM-killer/FS-read-only/crash events are explicitly a separate design (see follow-ups).
---
## Architecture
```
┌──────────────┐ POST /plugins/host_agent/ingest ┌────────────────────────┐
│ Agent │ ──────────────────────────────────────────→ │ Roundtable │
│ (Python) │ Authorization: Bearer <per-host-token> │ host_agent plugin │
│ on target │ JSON body: metrics snapshot │ │
│ host │ │ routes.py (ingest + │
│ │ ← 200 OK or 401/400 │ install.sh render + │
│ │ │ UI) │
│ systemd │ │ │
│ unit, │ │ writes to: │
│ dedicated │ │ - PluginMetric │
│ user, │ │ (time-series bus) │
│ 30s │ │ - HostAgentRegistr- │
│ interval, │ │ ation (plugin- │
│ in-memory │ │ private table) │
│ ring buffer │ │ │
└──────────────┘ │ registers widgets + │
│ METRIC_CATALOG entry │
└────────────────────────┘
```
### Core principles
- **Agent** is a single Python file. No external dependencies beyond Python 3.8+ stdlib.
- **Plugin** is self-contained under `plugins/host_agent/`. Writes to the core `PluginMetric` time-series bus (the designed cross-plugin integration point) and its own private `host_agent_registrations` table. Writes to the core `Host` model for identity, but adds no new columns to it.
- **Auth** is per-host bearer tokens, minted on "Add host" in the plugin's settings page.
- **Install** is a one-line `curl | sh` command rendered per-host by Roundtable with the token already baked in.
- **Failure** is handled at the agent (in-memory ring buffer + exponential backoff) so Roundtable outages don't lose brief-window data.
---
## Agent design
### File layout on the target host
```
/usr/local/lib/roundtable-agent/agent.py # the script, target ~300 lines
/etc/roundtable-agent.conf # key=value config, 0640 root:roundtable-agent
/etc/systemd/system/roundtable-agent.service # unit file
# dedicated system user: roundtable-agent
```
### Config file format
Flat `key = value`, parsed by a ~20-line homegrown parser (no TOML/YAML dependency):
```
url = https://roundtable.home.lan
token = a1b2c3d4...
interval_seconds = 30
hostname = myhost # optional; defaults to uname -n
mounts = /, /mnt/data # optional; defaults to all real mounts (excluding tmpfs/devtmpfs/etc.)
```
### Agent internals (function list, not classes)
- `read_config(path)` — parses the conf file into a dict.
- `collect_cpu()` — reads `/proc/stat` twice around a short sleep, returns overall CPU % (0100 float).
- `collect_memory()` — parses `/proc/meminfo`, returns `{total, used, available, swap_used}` in bytes. `used = total - available` (the *available* field, not *free* — Linux monitoring footgun).
- `collect_storage(mounts)``shutil.disk_usage()` per mount, returns `[{mount, total, used}, ...]`.
- `collect_load()` — reads `/proc/loadavg`, returns `[1m, 5m, 15m]`.
- `collect_uptime()` — reads `/proc/uptime`, returns seconds since boot (int).
- `collect_metadata()``os.uname()` for kernel + arch, `/etc/os-release` for distro. Called once at startup and cached.
- `build_payload()` — assembles a snapshot from all collectors into one dict.
- `post_payload(url, token, payloads)` — POSTs a list of samples, returns success/failure.
- `RingBuffer(maxlen=20)` — tiny FIFO wrapper, drops oldest when full.
- `main_loop()` — the 30s loop: collect → try POST → on failure push to buffer + backoff → on success flush buffer.
**Target: ~300 lines total including docstrings.** More than that is a smell that the agent is over-scoping.
### Dependencies
Python 3.8+ stdlib only. `urllib.request` for the POST, plus `json`, `os`, `shutil`, `time`, `socket`, `signal`. No `requests`, no `httpx`, no TOML libraries.
### Signals and lifecycle
- **SIGHUP** → re-read config file without restart (lets admins change interval/mounts cleanly).
- **SIGTERM** (from systemd) → finish current poll cycle → flush buffer one last time → exit.
- **Systemd restart policy** → `Restart=on-failure`, `RestartSec=10`.
### Logging
To stderr only (systemd captures to journal). No file logging, no log rotation. Levels:
- `INFO` on start and on successful flush-after-buffered-samples.
- `WARN` on POST failure.
- `ERROR` on config errors, token rejection.
### Identity
The agent's self-reported hostname in the payload is advisory. The real identity is the bearer token — Roundtable looks up the `Host` row via `HostAgentRegistration.token_hash`, not via hostname. Changing a host's hostname does not break its identity, and two hosts accidentally sharing a hostname can't collide.
### Failure behavior
- POST fails → push sample to ring buffer (max 20), exponential backoff `30s → 60s → 120s → 300s cap`, resets on first success.
- On next success → flush all buffered samples in a single batched POST.
- Ring buffer full → drop oldest, keep newest. Logged at DEBUG (expected during extended outages).
- Malformed payload rejection (400) → drop the sample (don't re-buffer, it'll just be rejected again). Indicates agent/server version skew.
- Token rejection (401) → log ERROR with remediation hint, continue retry loop at backoff cadence. Admin fixes via UI + conf file edit; no restart needed.
---
## Plugin design (server-side)
### File layout
Mirrors the existing `plugins/snmp/` structure.
```
plugins/host_agent/
├── __init__.py
├── plugin.yaml # metadata + default config
├── routes.py # blueprint: ingest, install.sh, UI pages, settings actions
├── models.py # HostAgentRegistration SQLAlchemy model
├── agent.py # the Python agent, served to targets at GET /agent.py
├── migrations/
│ ├── env.py
│ ├── __init__.py
│ └── versions/
│ └── host_agent_001_initial.py # creates host_agent_registrations table
├── templates/
│ ├── detail.html # per-host detail page
│ ├── widget_table.html # dashboard fleet table widget
│ ├── widget_history.html # dashboard history chart widget
│ ├── settings_list.html # plugin settings: list of registered hosts
│ └── install.sh.j2 # install script template (Jinja)
└── scheduler.py # periodic task: mark stale agents as "offline"
```
### `HostAgentRegistration` model (plugin-private table)
| Column | Type | Notes |
|---|---|---|
| `id` | UUID str | primary key |
| `host_id` | str, FK → `hosts.id`, unique | one registration per Host |
| `token_hash` | str | `sha256(token)`; raw token shown only once at creation |
| `token_created_at` | datetime | for audit and rotation history |
| `agent_version` | str, nullable | reported on each ingest; updated on change |
| `kernel` | str, nullable | from `os.uname()` — e.g. `6.8.0-45-generic` |
| `distro` | str, nullable | parsed from `/etc/os-release` — e.g. `Ubuntu 24.04` |
| `arch` | str, nullable | e.g. `x86_64` |
| `last_seen_at` | datetime, nullable | updated on every successful ingest; source of "is this agent alive" |
| `created_at` / `updated_at` | datetime | standard |
### Routes (blueprint prefix `/plugins/host_agent`)
| Route | Auth | Purpose |
|---|---|---|
| `POST /ingest` | bearer token | Agent push. Validates `sha256(Authorization header)` against `token_hash` → writes `PluginMetric` rows + updates `HostAgentRegistration`. Returns 200, 400, or 401. |
| `GET /install.sh` | query-param token | Renders the Jinja `install.sh.j2` template with URL, token, and agent version substituted. Returns `text/plain`. |
| `GET /agent.py` | none | Serves the bundled agent source from `plugins/host_agent/agent.py` as `text/x-python`. Fetched by the install script. |
| `GET /<host_id>/` | session | Per-host detail page. |
| `POST /settings/add-host` | admin | Creates (or reuses) a `Host` row + creates `HostAgentRegistration` + generates token. Returns the install one-liner for UI display. |
| `POST /settings/<host_id>/rotate-token` | admin | Generates a new token, invalidates the old one, returns new install one-liner. |
| `POST /settings/<host_id>/delete` | admin | Removes `HostAgentRegistration`. Does not delete the backing `Host` row unless no other monitors reference it. |
| `GET /widget` | session | HTMX partial: fleet table widget body. |
| `GET /widget/history` | session | HTMX partial: history chart widget body (takes `host_id` query param). |
### Token handling
- Tokens are generated with `secrets.token_urlsafe(32)` at host creation.
- Only `sha256(token)` is persisted.
- The raw token is shown exactly once in the UI (as part of the install one-liner copy box) and is recoverable only via rotation.
- Standard "hash-like-a-password-but-not-really" pattern for high-entropy API tokens. Plain SHA-256 is fine; no bcrypt/argon2 needed because the tokens have enough entropy that offline brute force isn't a threat.
### Stale-agent scheduler
One `ScheduledTask` running every 60 seconds that flags `HostAgentRegistration` rows with `last_seen_at > (3 × interval_seconds)` ago as stale. This is a computed flag the UI reads; no separate "online/offline" column.
**Note:** the existing alerts system can fire on "no metric for N seconds" which is arguably a cleaner signal. The scheduled task may end up redundant once alert rules exist. Build it anyway as a minimal safety net; it's one query plus one update.
### `METRIC_CATALOG` registration
Add to `roundtable/alerts/routes.py`:
```python
"host_agent": ["cpu_pct", "mem_used_pct", "mem_available_bytes",
"swap_used_bytes", "disk_used_pct_worst", "load_1m",
"load_5m", "load_15m", "uptime_secs"],
```
This is the one edit outside the plugin directory, matching the pattern every other plugin already follows. Not ideal from a pure-plugin-independence standpoint but unavoidable until Roundtable core grows a plugin-registered catalog API — deferred as future core work.
---
## Wire format
### Request
```http
POST /plugins/host_agent/ingest HTTP/1.1
Host: roundtable.home.lan
Authorization: Bearer a1b2c3d4e5f6...
Content-Type: application/json
```
```json
{
"agent_version": "1.0.0",
"hostname": "myhost",
"metadata": {
"kernel": "6.8.0-45-generic",
"distro": "Ubuntu 24.04",
"arch": "x86_64"
},
"samples": [
{
"ts": "2026-04-14T02:55:00Z",
"cpu_pct": 12.4,
"mem": {
"total_bytes": 33554432000,
"used_bytes": 18253611008,
"available_bytes": 15300820992,
"swap_used_bytes": 0
},
"load": { "1m": 0.42, "5m": 0.55, "15m": 0.61 },
"uptime_secs": 482934,
"storage": [
{ "mount": "/", "total_bytes": 499289948160, "used_bytes": 312456789012 },
{ "mount": "/mnt/data", "total_bytes": 4000787030016, "used_bytes": 2847193820928 }
]
}
]
}
```
### Design points
- **`samples` is always a list**, even for a single sample. This is how the ring buffer drains after a failure — agent POSTs `[current, ...buffered]` in a single request. One-path server code.
- **`ts` is agent-reported, ISO-8601 UTC.** Server trusts it for `PluginMetric.recorded_at`. Host clocks drift but NTP is reliable enough for our purposes.
- **If `|ts - now| > 5 minutes`**, server logs a WARN but still accepts. Don't reject, don't lose data over clock skew.
- **`metadata` is sent on every POST**, not just on change. Server-side diff detects actual changes and only writes on change. Cost per POST is one dict — negligible. Benefit: server can cleanly detect agent restarts.
- **Raw bytes, not percentages, for memory and storage.** Percentages are derived server-side. Changing the "what counts as used" math doesn't require re-releasing the agent.
- **CPU is the one exception** — reported as a percentage because it's inherently a derivative (delta over time), not a snapshot. The agent must sample twice to compute it.
### Server expansion into `PluginMetric` rows
Per sample, the server writes:
| `metric_name` | `resource_name` | derivation |
|---|---|---|
| `cpu_pct` | `<host.name>` | `sample.cpu_pct` as-is |
| `mem_used_pct` | `<host.name>` | `100 * (total - available) / total` |
| `mem_available_bytes` | `<host.name>` | `sample.mem.available_bytes` |
| `swap_used_bytes` | `<host.name>` | `sample.mem.swap_used_bytes` |
| `load_1m` | `<host.name>` | `sample.load["1m"]` |
| `load_5m` | `<host.name>` | `sample.load["5m"]` |
| `load_15m` | `<host.name>` | `sample.load["15m"]` |
| `uptime_secs` | `<host.name>` | `sample.uptime_secs` |
| `disk_used_pct` | `<host.name>:<mount>` | `100 * used / total` per mount |
| `disk_used_bytes` | `<host.name>:<mount>` | raw per mount |
| `disk_total_bytes` | `<host.name>:<mount>` | raw per mount |
| `disk_used_pct_worst` | `<host.name>` | `max(disk_used_pct across mounts)` — fleet table column |
All values are `float`. That's ~8 host-level metrics plus 3 per mount. A typical host with 3 mounts writes ~17 rows per 30s = ~34/min = ~49k/day. Well within `PluginMetric` and the existing cleanup path.
Plus one update to `HostAgentRegistration`:
```python
reg.last_seen_at = max(sample.ts for sample in samples)
reg.agent_version = payload.agent_version
reg.kernel = payload.metadata.kernel
reg.distro = payload.metadata.distro
reg.arch = payload.metadata.arch
```
(Only written if changed, to keep `updated_at` meaningful.)
### Response
```json
{ "ok": true, "samples_accepted": 3 }
```
Failures:
```json
{ "ok": false, "error": "invalid_token" } // 401
{ "ok": false, "error": "malformed_payload", "detail": "missing 'samples'" } // 400
```
Agent treats anything non-2xx as failure → ring buffer. Agent treats 401 specially (logs, continues trying so that token rotation + conf edit recovers without restart).
---
## Install script
### The one-liner (what the UI shows)
```
curl -sSL 'https://roundtable.home.lan/plugins/host_agent/install.sh?token=a1b2c3...' | sudo sh
```
The UI also offers a "review script before running" link that opens a modal showing the two-step form:
```
curl -sSL '…' -o install.sh
less install.sh
sudo sh install.sh
```
### Rendered script structure (Jinja template `install.sh.j2`)
```sh
#!/bin/sh
# Roundtable host agent installer
# Generated for: {{ host_name }} ({{ host_address }})
# Roundtable URL: {{ url }}
set -e
ROUNDTABLE_URL="{{ url }}"
AGENT_TOKEN="{{ token }}"
AGENT_VERSION="{{ agent_version }}"
AGENT_USER="roundtable-agent"
AGENT_DIR="/usr/local/lib/roundtable-agent"
CONF_FILE="/etc/roundtable-agent.conf"
UNIT_FILE="/etc/systemd/system/roundtable-agent.service"
# ── preflight ────────────────────────────────────────────────────────────────
[ "$(id -u)" = "0" ] || { echo "must run as root (use sudo)"; exit 1; }
command -v systemctl >/dev/null 2>&1 || { echo "systemd not found — unsupported init system"; exit 1; }
command -v python3 >/dev/null 2>&1 || { echo "python3 not found — install python3 first"; exit 1; }
# Handle --uninstall
if [ "${1:-}" = "--uninstall" ]; then
systemctl disable --now roundtable-agent.service 2>/dev/null || true
rm -f "$UNIT_FILE" "$CONF_FILE"
rm -rf "$AGENT_DIR"
systemctl daemon-reload
# keep the system user — removing it risks orphaned files elsewhere
echo "uninstalled"
exit 0
fi
# ── create system user ───────────────────────────────────────────────────────
if ! id "$AGENT_USER" >/dev/null 2>&1; then
useradd --system --no-create-home --shell /usr/sbin/nologin "$AGENT_USER"
fi
# ── drop the agent file ──────────────────────────────────────────────────────
mkdir -p "$AGENT_DIR"
curl -sSL "${ROUNDTABLE_URL}/plugins/host_agent/agent.py" -o "$AGENT_DIR/agent.py"
chmod 0755 "$AGENT_DIR/agent.py"
chown root:root "$AGENT_DIR/agent.py"
# ── write config ─────────────────────────────────────────────────────────────
cat > "$CONF_FILE" <<EOF
url = $ROUNDTABLE_URL
token = $AGENT_TOKEN
interval_seconds = 30
EOF
chown "root:$AGENT_USER" "$CONF_FILE"
chmod 0640 "$CONF_FILE"
# ── write systemd unit ───────────────────────────────────────────────────────
cat > "$UNIT_FILE" <<EOF
[Unit]
Description=Roundtable host agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$AGENT_USER
ExecStart=/usr/bin/python3 $AGENT_DIR/agent.py
Restart=on-failure
RestartSec=10
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadOnlyPaths=/proc /sys
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now roundtable-agent.service
echo
echo "Roundtable host agent $AGENT_VERSION installed and running."
echo "Check status: systemctl status roundtable-agent"
echo "Logs: journalctl -u roundtable-agent -f"
```
### Design points
- **Agent binary served by Roundtable itself.** `GET /plugins/host_agent/agent.py` serves the script bundled with the plugin, so version drift between install-script and agent is impossible — re-running the installer picks up whatever agent the currently-running Roundtable ships.
- **Systemd hardening is cheap and correct.** `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `ReadOnlyPaths=/proc /sys`. The agent only needs read access to `/proc`, `/sys`, and mount points; denying everything else narrows blast radius.
- **Uninstall is a first-class flag**, not a separate script. Same one-liner with `--uninstall` appended.
- **Fail-fast on preflight.** Missing systemd or python3 → clear error, exit. No half-installed agent.
- **Query-string token for install fetch is acceptable.** It's over the user's reverse-proxy TLS, and the token is fresh per-install. Runtime POSTs use the `Authorization` header.
- **No auto-upgrade.** Upgrade = re-run the installer. Self-update implies trust decisions and rollback semantics that are YAGNI for home-lab scale.
---
## UI surfaces
### Dashboard widgets (`roundtable/core/widgets.py`)
- **`host_resources`** — table widget. One row per monitored host: name, CPU %, mem %, disk % (worst mount), load 1m, "last seen Xs ago" with red/yellow/green coloring. Fleet glance.
- **`host_resource_history`** — chart widget for one host. CPU / mem / disk over a selectable time range (1h, 6h, 24h, 7d). Same pattern as every other history widget — Chart.js.
### Per-host detail page
`GET /plugins/host_agent/<host_id>/`:
- Current values as big numbers (CPU %, mem %, swap %, load 1m/5m/15m).
- Per-mount storage breakdown (the metric that doesn't fit in a dashboard row).
- Full history charts, reusing `widget_history.html` with a wider layout.
- Metadata block: kernel, distro, arch, uptime (human-readable), agent version.
- Agent status: last heartbeat timestamp, "live/stale" flag.
- "Copy install command" button (for re-install / rotation) that fetches the current one-liner.
### Plugin settings page
List of registered hosts with their enable flags, an "Add host" button that opens the registration flow (creates/reuses `Host` row → creates `HostAgentRegistration` → generates token → displays the curl install one-liner), and a "rotate token" action per host.
---
## Error handling
| Failure | Who handles it | Response |
|---|---|---|
| Agent can't reach Roundtable | Agent | Push to ring buffer, exponential backoff (30→60→120→300s cap), log WARN. Resets on success. |
| Roundtable rejects with 401 | Agent | Log ERROR with remediation hint, continue retry loop. Admin fixes via UI + conf edit; no restart needed. |
| Roundtable rejects with 400 | Agent | Log ERROR, drop the sample (don't re-buffer), continue. Indicates agent/server version skew. |
| Ring buffer full | Agent | Drop oldest, keep newest. DEBUG log (expected during outages). |
| Config file missing or malformed | Agent | Log ERROR, exit non-zero. Systemd restarts after 10s. Repeated restarts are visible in journal. |
| `/proc/stat` read fails between samples | Agent | Skip CPU for this cycle, still POST the rest. Partial samples allowed. |
| Mount disappears between reads | Agent | Skip that mount for this sample. If permanent, its rows simply stop appearing. |
| Ingest token not found | Server | 401 + `{"error": "invalid_token"}`. |
| Ingest body malformed | Server | 400 + descriptive `detail`. |
| Ingest writes fail mid-batch | Server | 500 + rollback. Agent retries whole batch on next cycle. |
| Agent clock skew > 5 min | Server | Accept, log WARN. |
| Host deleted from UI | Server | `HostAgentRegistration` removed; agent's next POST → 401 → admin re-adds or uninstalls. |
### Edge cases explicitly accepted, not handled
- Two agents sharing a token → last-write-wins on `last_seen_at`, metrics get stored under the same `resource_name`. Admin should never do this.
- Agent clock running backward (NTP correction) → `recorded_at` values non-monotonic. Charts render fine; the DB doesn't care.
- Hostname change on target → `HostAgentRegistration.host_id` is still the token's identity; `resource_name` tracks `Host.name`. Admin edits `Host.name` if they want the display to follow.
### Idempotency note
v1 doesn't enforce unique constraints on `PluginMetric` rows. Re-submitting the same sample (e.g., agent retries after a spurious 5xx) creates duplicate rows. Acceptable for v1; if duplicates become a UX issue, add a unique constraint on `(source_module, resource_name, metric_name, recorded_at)` later.
---
## Testing strategy
- **Unit tests**
- Agent collectors against fixture `/proc/stat`, `/proc/meminfo`, `/proc/loadavg`, `/proc/uptime` content.
- Config file parser: valid, invalid, comments, whitespace edge cases.
- Ring buffer: fill, drain, overflow drops oldest.
- Server ingest route: happy path, bad token, malformed body, clock skew warning, partial payload (missing CPU).
- Plugin migration smoke test: `alembic upgrade head` in a temp DB, then `downgrade base`.
- **Integration test**
- Spin up the plugin's ingest route in-process (Quart test client).
- Feed a real payload generated by the agent's `build_payload()` using mocked `/proc` content.
- Assert `PluginMetric` rows land correctly and `HostAgentRegistration` is updated.
- **Install script verification**
- `sh -n install.sh` (syntax).
- `shellcheck install.sh`.
- Snapshot test of the rendered output against a committed fixture.
- No live systemd-in-container test — high cost, low value for a one-time install script.
- **Agent is importable as a Python module.** `main_loop()` is guarded by `if __name__ == "__main__":`, so everything else is unit-testable.
---
## Scope boundaries — deferred to follow-ups
- **Ansible-action alert type** (existing follow-up, Fable task 250) — how "host CPU pinned for 5 min" can run a playbook.
- **Plugin synergy detection** (new follow-up, to be filed) — host_agent plugin noticing the Ansible plugin and offering to auto-deploy itself.
- **Fleet-provision mode** — bootstrap registration secret for self-registering new hosts. Additive to per-host-token foundation.
- **Event signals** — OOM killer, read-only FS, process crashes. Separate endpoint, not an extension.
- **Non-systemd init systems** — OpenRC, runit, sysvinit. Install-script conditionals; no architectural change.
- **macOS / BSD / Windows agents** — separate code paths.
- **Self-upgrade mechanism** — re-run installer for now.
- **Disk buffering** when RAM ring fills — drop-in upgrade, no protocol change.
- **Network and per-process metrics** — additive; agent v2.
- **`metadata_json` column on core `Host`** — rejected in favor of strict plugin isolation.
---
## Constraints worth being aware of
These are not blockers; they are feature boundaries inherent to the design.
1. **Stdlib-only Python is a ceiling.** Anything requiring a C library (NVMe SMART data, GPU stats, eBPF syscall tracing, `psutil`-class per-process detail) will either need shelling out to system tools or abandoning the one-file install. v1 metrics are all comfortably stdlib-reachable.
2. **Interval push is not event push.** OOM killer, FS-read-only, process crash are events that lose meaning at 30s sampling. A future event-signal channel would be a *sibling* endpoint, not an extension of this design.
3. **`PluginMetric` only stores floats.** String states and JSON blobs don't fit. All v1 metrics are float-able; discrete state metrics would need the same numeric-enum encoding the SNMP plugin already uses.
---
## Implementation order (rough sketch — real plan comes from writing-plans)
1. Plugin scaffolding: `plugin.yaml`, `__init__.py`, empty routes blueprint, migration for `host_agent_registrations`.
2. Agent v1 as a standalone script: all collectors + config parser + `build_payload()` working locally. Testable without any server at all.
3. Server `/ingest` route: accepts payload, writes `PluginMetric`, updates `HostAgentRegistration`. Token auth.
4. Agent ring buffer + backoff + retry logic on top of the working collectors.
5. Install script template + `/install.sh` and `/agent.py` routes.
6. Settings page: list, add-host flow, rotate-token, delete.
7. Dashboard widgets: table + history chart. Register in `core/widgets.py`.
8. Per-host detail page.
9. `METRIC_CATALOG` entry in `roundtable/alerts/routes.py`.
10. Scheduler: stale-agent marker.
11. Tests at every stage; final integration test to tie it together.
The writing-plans skill will turn this into a proper stepwise plan with file-level specifics.
File diff suppressed because it is too large Load Diff
+55 -22
View File
@@ -1,10 +1,10 @@
# fabledscryer-plugins / index.yaml
# roundtable-plugins / index.yaml
#
# This file is the catalog index for the fabledscryer plugin repository.
# This file is the catalog index for the roundtable plugin repository.
# It is fetched by the app's Settings → Plugins → Plugin Catalog UI.
#
# Fabled Scryer reads this file from:
# https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/raw/branch/main/index.yaml
# Roundtable reads this file from:
# https://git.fabledsword.com/bvandeusen/Roundtable-plugins/raw/branch/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).
@@ -25,7 +25,7 @@
#
# Download URL conventions:
# Gitea release assets (recommended):
# https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/traefik-v1.0.0/traefik.zip
# https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/traefik-v1.0.0/traefik.zip
# Upload the zip as a release attachment in Gitea; paste the URL here.
# Source archive tarballs work too but release assets are preferred (smaller, plugin-only).
@@ -34,15 +34,46 @@ updated: "2026-03-22"
plugins:
- name: http
version: "1.0.0"
description: "Synthetic HTTP endpoint monitoring — status code, response time, content match, TLS expiry"
author: "Roundtable"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/http"
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/http-v1.0.0/http.zip"
checksum_sha256: ""
tags:
- monitoring
- http
- synthetic
- uptime
- name: docker
version: "1.0.0"
description: "Docker container status, resource usage, and restart tracking via Docker socket"
author: "Roundtable"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/docker"
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/docker-v1.0.0/docker.zip"
checksum_sha256: ""
tags:
- containers
- docker
- infrastructure
- name: traefik
version: "1.0.0"
description: "Traefik reverse proxy metrics and access log integration"
author: "FabledScryer"
author: "Roundtable"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/traefik"
download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/traefik-v1.0.0/traefik.zip"
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/traefik"
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/traefik-v1.0.0/traefik.zip"
checksum_sha256: "" # fill in after running: sha256sum traefik.zip
tags:
- proxy
@@ -52,29 +83,31 @@ plugins:
- name: unifi
version: "1.0.0"
description: "UniFi Network controller integration — WAN health, devices, clients, DPI"
author: "FabledScryer"
author: "Roundtable"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/unifi"
download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/unifi-v1.0.0/unifi.zip"
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/unifi"
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/unifi-v1.0.0/unifi.zip"
checksum_sha256: ""
tags:
- network
- unifi
- ubiquiti
- name: ups
- name: host_agent
version: "1.0.0"
description: "UPS monitoring via NUT (Network UPS Tools) with Ansible shutdown automation"
author: "FabledScryer"
description: "Remote Linux host resource monitoring via a lightweight Python push agent"
author: "Roundtable"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/ups"
download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/ups-v1.0.0/ups.zip"
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/host_agent"
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/host_agent-v1.0.0/host_agent.zip"
checksum_sha256: ""
tags:
- ups
- power
- nut
- host
- monitoring
- cpu
- memory
- storage
+5 -5
View File
@@ -1,12 +1,12 @@
# 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.
Plugins extend Roundtable 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`.
Plugin loading happens in step 9 of `create_app()`, after all core blueprints and tasks are registered. The entrypoint is `load_plugins(app)` in `roundtable/core/plugin_manager.py`.
For each plugin listed as `enabled: true` in the `PLUGINS` config:
@@ -57,7 +57,7 @@ description: "Does a thing"
# Optional
author: "Your Name"
min_app_version: "0.1.0" # Minimum Fabled Scryer version required
min_app_version: "0.1.0" # Minimum Roundtable version required
# Default config — merged with user overrides at runtime
# Access at runtime via: app.config["PLUGINS"]["myplugin"]["my_setting"]
@@ -90,7 +90,7 @@ def setup(app):
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
from roundtable.core.scheduler import ScheduledTask
def get_scheduled_tasks():
app = _app
@@ -154,7 +154,7 @@ 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.
The dashboard (`roundtable/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.
---
+16 -16
View File
@@ -46,7 +46,7 @@ config:
## Step 3: Define Models (if needed)
If your plugin stores data, define SQLAlchemy models using the shared `Base` from `fabledscryer.models.base`.
If your plugin stores data, define SQLAlchemy models using the shared `Base` from `roundtable.models.base`.
```python
# plugins/myplugin/models.py
@@ -55,7 +55,7 @@ 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
from roundtable.models.base import Base
class MyPluginMetric(Base):
@@ -79,7 +79,7 @@ Generate the initial migration:
# From the project root
alembic --config alembic.ini revision \
--autogenerate \
--head=fabledscryer@head \
--head=roundtable@head \
--branch-label=myplugin \
-m "myplugin initial"
```
@@ -106,8 +106,8 @@ Keep task logic in a separate file so `__init__.py` stays clean.
# plugins/myplugin/scheduler.py
from __future__ import annotations
import logging
from fabledscryer.core.scheduler import ScheduledTask
from fabledscryer.core.alerts import record_metric
from roundtable.core.scheduler import ScheduledTask
from roundtable.core.alerts import record_metric
logger = logging.getLogger(__name__)
@@ -174,8 +174,8 @@ async def _fetch_value(url: str) -> float:
```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 roundtable.auth.middleware import require_role
from roundtable.models.users import UserRole
from .models import MyPluginMetric
myplugin_bp = Blueprint("myplugin", __name__, template_folder="templates")
@@ -215,7 +215,7 @@ Templates live in `plugins/myplugin/templates/myplugin/` (the extra nesting avoi
```html
{# plugins/myplugin/templates/myplugin/index.html #}
{% extends "base.html" %}
{% block title %}My Plugin — Fabled Scryer{% endblock %}
{% block title %}My Plugin — Roundtable{% endblock %}
{% block content %}
<div class="page-title">My Plugin</div>
{% for row in rows %}
@@ -283,7 +283,7 @@ On next startup, the plugin will be loaded, its migrations applied, and its blue
`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
from roundtable.core.alerts import record_metric
# Must be inside an active transaction
async with session.begin():
@@ -302,11 +302,11 @@ async with session.begin():
## Auth in Routes
Use the `@require_role` decorator from `fabledscryer.auth.middleware`:
Use the `@require_role` decorator from `roundtable.auth.middleware`:
```python
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.users import UserRole
from roundtable.auth.middleware import require_role
from roundtable.models.users import UserRole
@myplugin_bp.get("/admin-only")
@require_role(UserRole.admin)
@@ -325,13 +325,13 @@ Role hierarchy: `admin > operator > viewer`. Requiring `viewer` grants access to
## Publishing to the Catalog
The official plugin catalog is hosted at `https://git.fabledsword.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.
The official plugin catalog is hosted at `https://git.fabledsword.com/bvandeusen/Roundtable-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
roundtable-plugins/
├── index.yaml ← catalog index — the only file Roundtable fetches
├── myplugin/
│ ├── plugin.yaml
│ ├── __init__.py
@@ -381,7 +381,7 @@ myplugin.zip
Generate the zip and its checksum:
```bash
cd fabledscryer-plugins
cd roundtable-plugins
zip -r myplugin.zip myplugin/
sha256sum myplugin.zip # paste this into index.yaml checksum_sha256
```
+59 -59
View File
@@ -8,15 +8,15 @@ Quick reference for where key functions, models, and entry points live in the co
| 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()` |
| App factory | `roundtable/app.py` | `create_app()` |
| CLI entry point | `roundtable/cli.py` | `main()` |
| Bootstrap config loading | `roundtable/config.py` | `load_bootstrap()` |
| Secret key resolution | `roundtable/config.py` | `_resolve_secret_key()` |
| DB engine init | `roundtable/database.py` | `init_db()` |
| Core migrations | `roundtable/core/migration_runner.py` | `run_core_migrations()` |
| Plugin migrations | `roundtable/core/migration_runner.py` | `run_plugin_migrations()` |
| Core task registration | `roundtable/app.py` | `_register_core_tasks()` |
| Scheduler loop | `roundtable/core/scheduler.py` | `start_scheduler()` |
---
@@ -24,15 +24,15 @@ Quick reference for where key functions, models, and entry points live in the co
| 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)` |
| All defaults | `roundtable/core/settings.py` | `DEFAULTS` dict |
| Read a setting | `roundtable/core/settings.py` | `get_setting(session, key)` |
| Write a setting | `roundtable/core/settings.py` | `set_setting(session, key, value)` |
| Read all settings | `roundtable/core/settings.py` | `get_all_settings(session)` |
| Sync load at startup | `roundtable/core/settings.py` | `load_settings_sync(db_url)` |
| Extract SMTP dict | `roundtable/core/settings.py` | `to_smtp_cfg(settings)` |
| Extract webhook dict | `roundtable/core/settings.py` | `to_webhook_cfg(settings)` |
| Extract Ansible dict | `roundtable/core/settings.py` | `to_ansible_cfg(settings)` |
| Extract plugins dict | `roundtable/core/settings.py` | `to_plugins_cfg(settings)` |
---
@@ -40,9 +40,9 @@ Quick reference for where key functions, models, and entry points live in the co
| 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)` |
| Plugin loading | `roundtable/core/plugin_manager.py` | `load_plugins(app)` |
| ScheduledTask dataclass | `roundtable/core/scheduler.py` | `ScheduledTask` |
| Task runner | `roundtable/core/scheduler.py` | `start_scheduler(tasks)` |
---
@@ -50,11 +50,11 @@ Quick reference for where key functions, models, and entry points live in the co
| 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()` |
| Write metric + evaluate alerts | `roundtable/core/alerts.py` | `record_metric(session, source_module, resource_name, metric_name, value)` |
| Init alert pipeline | `roundtable/core/alerts.py` | `init_alerts(app)` |
| Rule evaluation | `roundtable/core/alerts.py` | `_evaluate_rule()` (internal) |
| Notification dispatch | `roundtable/core/alerts.py` | `_dispatch_notification()` (internal) |
| Email + webhook send | `roundtable/core/notifications.py` | `dispatch_notifications()` |
---
@@ -62,9 +62,9 @@ Quick reference for where key functions, models, and entry points live in the co
| 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)` |
| Ping a host | `roundtable/monitors/ping.py` | `ping_check(host, session)` |
| DNS check a host | `roundtable/monitors/dns.py` | `dns_check(host, session)` |
| Data cleanup | `roundtable/core/cleanup.py` | `run_cleanup(app)` |
---
@@ -72,9 +72,9 @@ Quick reference for where key functions, models, and entry points live in the co
| 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)` |
| Role-based access decorator | `roundtable/auth/middleware.py` | `@require_role(UserRole.X)` |
| Login / session handling | `roundtable/auth/middleware.py` | `login_user()`, `logout_user()` |
| User count (for first-run) | `roundtable/auth/middleware.py` | `get_user_count(app)` |
---
@@ -82,18 +82,18 @@ Quick reference for where key functions, models, and entry points live in the co
| 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` |
| `Host` | `roundtable/models/hosts.py` | `hosts` |
| `PingResult` | `roundtable/models/monitors.py` | `ping_results` |
| `DnsResult` | `roundtable/models/monitors.py` | `dns_results` |
| `AlertRule` | `roundtable/models/alerts.py` | `alert_rules` |
| `AlertState` | `roundtable/models/alerts.py` | `alert_states` |
| `AlertEvent` | `roundtable/models/alerts.py` | `alert_events` |
| `PluginMetric` | `roundtable/models/metrics.py` | `plugin_metrics` |
| `AnsibleRun` | `roundtable/models/ansible.py` | `ansible_runs` |
| `User` | `roundtable/models/users.py` | `users` |
| `AppSetting` | `roundtable/models/settings.py` | `app_settings` |
| `TraefikMetric` | `plugins/traefik/models.py` | `traefik_metrics` |
| SQLAlchemy `Base` | `fabledscryer/models/base.py` | (shared declarative base) |
| SQLAlchemy `Base` | `roundtable/models/base.py` | (shared declarative base) |
---
@@ -101,16 +101,16 @@ Quick reference for where key functions, models, and entry points live in the co
| 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` |
| `/` (dashboard) | `dashboard_bp` | `roundtable/dashboard/routes.py` |
| `/auth/login`, `/auth/logout` | `auth_bp` | `roundtable/auth/routes.py` |
| `/hosts/` | `hosts_bp` | `roundtable/hosts/routes.py` |
| `/ping/`, `/ping/rows`, `/ping/settings` | `ping_bp` | `roundtable/ping/routes.py` |
| `/dns/`, `/dns/rows` | `dns_bp` | `roundtable/dns/routes.py` |
| `/alerts/` | `alerts_bp` | `roundtable/alerts/routes.py` |
| `/ansible/` | `ansible_bp` | `roundtable/ansible/routes.py` |
| `/settings/` | `settings_bp` | `roundtable/settings/routes.py` |
| `/plugins/traefik/`, `/plugins/traefik/widget` | `traefik_bp` | `plugins/traefik/routes.py` |
| `/health` | (inline) | `fabledscryer/app.py` |
| `/health` | (inline) | `roundtable/app.py` |
---
@@ -118,12 +118,12 @@ Quick reference for where key functions, models, and entry points live in the co
| 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 |
| `roundtable/templates/base.html` | Layout, navigation, full CSS design system |
| `roundtable/templates/dashboard/index.html` | Dashboard with stat strip and widget grid |
| `roundtable/templates/ping/rows.html` | HTMX fragment: ping pill rows (shared by dashboard and /ping/) |
| `roundtable/templates/ping/index.html` | Full /ping/ page |
| `roundtable/templates/dns/rows.html` | HTMX fragment: DNS status rows |
| `roundtable/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 |
@@ -133,6 +133,6 @@ Quick reference for where key functions, models, and entry points live in the co
| Location | Covers |
|---|---|
| `fabledscryer/migrations/versions/` | Core schema (hosts, users, monitors, alerts, app_settings) |
| `roundtable/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 |
@@ -0,0 +1,927 @@
# Roundtable Rebrand Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> **Testing:** Per project feedback, skip all test authoring and test runs during this work. Verify manually by running the app.
**Goal:** Rebrand FabledScryer to Roundtable: full visual reskin plus full rename of package, config, containers, and docs.
**Architecture:** Staged as six sequential PRs. PR 1 is a pure visual/copy reskin that still ships under the FabledScryer name. PRs 24 perform the mechanical rename in order (package → config → container + user-visible strings) with a fallback shim in PR 3 so self-hosters don't break mid-upgrade. PR 5 updates docs. PR 6 removes the shim after one release cycle.
**Tech Stack:** Python 3.13, Quart, SQLAlchemy + Alembic (asyncpg), Jinja2 templates, Docker Compose, Postgres 16.
**Spec:** `docs/superpowers/specs/2026-04-13-rebrand-design.md`
---
## File Structure
**Modified (PR 1 — visual reskin):**
- `fabledscryer/templates/base.html` — palette tokens, logo SVG, font, background, wordmark, `<title>`
- `fabledscryer/templates/auth/login.html` — themed tagline
- `fabledscryer/templates/errors/404.html` (create if missing) — themed 404
- `fabledscryer/templates/errors/500.html` (create if missing) — themed 500
- `fabledscryer/templates/dashboard/*.html` — hero title "Under Watch, Under Care"
- Any template with an empty-state message — themed line
**Renamed (PR 2 — package):**
- `fabledscryer/``roundtable/` (directory + every `from fabledscryer` / `import fabledscryer` reference)
- `pyproject.toml` — name, entry point, hatch packages
- `alembic.ini``script_location`
- `tests/**` — imports (tests not executed, but must not break collection; update imports mechanically)
**Modified (PR 3 — config & env):**
- `fabledscryer/config.py``roundtable/config.py` (already moved in PR 2) — read both `ROUNDTABLE_*` and `FABLEDSCRYER_*` with deprecation warning
- `.env.example`, `config.example.yaml` — new names
- First-boot migration of `~/.config/fabledscryer``~/.config/roundtable` (if the app uses it — verify during task)
**Modified (PR 4 — container + user strings):**
- `Dockerfile` — COPY paths, CMD, image labels
- `docker-compose.yml` — service name, image, container_name, volumes, env
- `entrypoint.sh` — package path for `nut_setup.py` invocation
- `roundtable/templates/base.html` — wordmark text "Fabled Scryer" → "Roundtable", `<title>`
- `roundtable/templates/auth/login.html` — any residual "Fabled Scryer" text
- `README.md` — first-page references (full doc sweep is PR 5)
**Modified (PR 5 — docs):**
- `README.md`, `docs/**/*.md`
**Modified (PR 6 — cleanup):**
- `roundtable/config.py` — remove fallback shim
---
## PR 1 — Visual Reskin
### Task 1: Replace palette tokens in `base.html`
**Files:**
- Modify: `fabledscryer/templates/base.html:13-35`
- [ ] **Step 1: Open `fabledscryer/templates/base.html` and replace the `:root` block (lines 1335) with the Pewter & Gold palette**
```css
:root {
--bg: #131315;
--bg-card: #1d1d20;
--bg-elevated: #24242a;
--border: #30303a;
--border-mid: #3a3a44;
--text: #d4d0c0;
--text-muted: #b8b8b0;
--text-dim: #8a8a92;
--gold: #c8a840;
--gold-hover: #d8b850;
--gold-dim: #2a2410;
--pewter: #8a8a92;
--accent: var(--gold);
--accent-hover: var(--gold-hover);
--green: #4aa86a;
--green-dim: #162a1c;
--yellow: #d4a840;
--yellow-dim: #2a2410;
--orange: #c87840;
--orange-dim: #2a1a10;
--red: #c84048;
--red-dim: #2a1014;
--font-serif: 'EB Garamond', Georgia, serif;
}
```
- [ ] **Step 2: Commit**
```bash
git add fabledscryer/templates/base.html
git commit -m "style: pewter & gold palette tokens"
```
### Task 2: Swap Google Font to EB Garamond
**Files:**
- Modify: `fabledscryer/templates/base.html:9-11`
- [ ] **Step 1: Replace the font preconnect + stylesheet link (lines 911) with EB Garamond**
```html
<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=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
```
- [ ] **Step 2: Commit**
```bash
git add fabledscryer/templates/base.html
git commit -m "style: swap Libertinus Serif → EB Garamond"
```
### Task 3: Replace crystal ball logo with Heraldic Seal
**Files:**
- Modify: `fabledscryer/templates/base.html:187-214`
- [ ] **Step 1: Replace the `<svg>` block inside `nav .brand` (lines 188213) with the locked seal SVG**
```html
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" fill="none" stroke="#c8a840" stroke-width="1.5"/>
<circle cx="12" cy="12" r="9.3" fill="#8a8a92" opacity="0.1"/>
<path d="M7 10 L7 5.8 L9 7.8 L10.5 5 L12 7.2 L13.5 5 L15 7.8 L17 5.8 L17 10 Z" fill="#c8a840"/>
<ellipse cx="12" cy="15.5" rx="5.2" ry="1" fill="#c8a840"/>
<rect x="9.6" y="15.5" width="1" height="3.8" fill="#c8a840"/>
<rect x="13.4" y="15.5" width="1" height="3.8" fill="#c8a840"/>
</svg>
```
- [ ] **Step 2: Change the brand wordmark gradient (lines 6871) to solid gold**
Replace:
```css
background: linear-gradient(110deg, #c8b4f8 0%, var(--gold) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
```
With:
```css
color: var(--gold);
```
- [ ] **Step 3: Commit**
```bash
git add fabledscryer/templates/base.html
git commit -m "feat: heraldic seal logo (crown & ellipse table)"
```
### Task 4: Replace star field with candlelit glow
**Files:**
- Modify: `fabledscryer/templates/base.html:42-46` (CSS), `182` (markup), `266-369` (script)
- [ ] **Step 1: Replace the `#star-field` CSS rules (lines 4246) with a candlelit glow rule**
```css
#candle-glow {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 50% 40%, rgba(200, 168, 64, 0.045) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 20% 80%, rgba(200, 168, 64, 0.025) 0%, transparent 65%);
}
```
- [ ] **Step 2: Rename the markup `<div id="star-field">` (line 182) to `<div id="candle-glow">`**
```html
<div id="candle-glow"></div>
```
- [ ] **Step 3: Delete the entire star-field IIFE script block (lines 266369)** — from `<script>` through `</script>` that contains `var field = document.getElementById('star-field');`. The glow is CSS-only; no JS needed.
- [ ] **Step 4: Commit**
```bash
git add fabledscryer/templates/base.html
git commit -m "style: candlelit glow background replaces star field"
```
### Task 5: Update `<title>` tag
**Files:**
- Modify: `fabledscryer/templates/base.html:6`
- [ ] **Step 1: Change the title block**
```html
<title>{% block title %}Roundtable{% endblock %}</title>
```
Note: the visible wordmark text still reads "Fabled Scryer" — that flips in PR 4. This only changes the browser tab title, which is acceptable to flip early since it's not visible inside the UI.
Actually, leave the visible `<title>` as "Fabled Scryer" in PR 1 to keep this PR purely about palette/logo/font/background. Revert this change if already applied.
- [ ] **Step 2: Revert line 6 back to `Fabled Scryer` if modified, then commit nothing for this task.** (PR 1 does not change any user-visible strings.)
### Task 6: Add themed empty-state and login tagline
**Files:**
- Modify: `fabledscryer/templates/auth/login.html`
- Modify: existing empty-state markup in list templates (identify via grep)
- [ ] **Step 1: Identify empty-state lines**
Run:
```bash
grep -rn 'class="empty"' fabledscryer/templates/
```
- [ ] **Step 2: Replace each empty-state copy with a one-line themed variant**
For each hit, replace the inner text with a single themed sentence. Examples:
- Hosts list empty → `No hosts yet. Summon one to the table.`
- Alerts list empty → `Silence in the realm. No alerts to show.`
- Plugins list empty → `No plugins installed. The table awaits new seats.`
Use judgment — one sentence per list. Keep them short.
- [ ] **Step 3: Add login tagline**
Open `fabledscryer/templates/auth/login.html`. Below the wordmark/heading, add:
```html
<p style="color: var(--text-dim); font-family: var(--font-serif); font-style: italic; font-size: 0.95rem; margin-top: 0.25rem;">Where the realm's watch convenes.</p>
```
- [ ] **Step 4: Commit**
```bash
git add fabledscryer/templates/
git commit -m "copy: themed empty states + login tagline"
```
### Task 7: Add 404 and 500 templates
**Files:**
- Check: `fabledscryer/templates/errors/` (create if missing)
- Create or modify: `fabledscryer/templates/errors/404.html`, `fabledscryer/templates/errors/500.html`
- [ ] **Step 1: Check if error templates and handlers already exist**
Run:
```bash
ls fabledscryer/templates/errors/ 2>/dev/null
grep -rn "errorhandler\|@app.error" fabledscryer/app.py fabledscryer/*/routes.py
```
- [ ] **Step 2: If templates exist, update their copy to themed messages. If they don't exist, create them and wire handlers in `fabledscryer/app.py`.**
Content for `fabledscryer/templates/errors/404.html`:
```html
{% extends "base.html" %}
{% block title %}Not Found — Roundtable{% endblock %}
{% block content %}
<div style="text-align:center; padding: 4rem 2rem; font-family: var(--font-serif);">
<h1 style="color: var(--gold); font-size: 2.5rem; margin-bottom: 0.5rem;">404</h1>
<p style="color: var(--text); font-size: 1.2rem; margin-bottom: 0.25rem;">No such seat at this table.</p>
<p style="color: var(--text-dim); margin-bottom: 1.5rem;">The page you sought is not among us.</p>
<a href="/" class="btn">Return to the hall</a>
</div>
{% endblock %}
```
Content for `fabledscryer/templates/errors/500.html`:
```html
{% extends "base.html" %}
{% block title %}Error — Roundtable{% endblock %}
{% block content %}
<div style="text-align:center; padding: 4rem 2rem; font-family: var(--font-serif);">
<h1 style="color: var(--red); font-size: 2.5rem; margin-bottom: 0.5rem;">500</h1>
<p style="color: var(--text); font-size: 1.2rem; margin-bottom: 0.25rem;">The watch has faltered.</p>
<p style="color: var(--text-dim); margin-bottom: 1.5rem;">An unexpected error occurred. The cause has been logged.</p>
<a href="/" class="btn">Return to the hall</a>
</div>
{% endblock %}
```
- [ ] **Step 3: If handlers are missing, add them to `fabledscryer/app.py` near other Quart error wiring**
```python
@app.errorhandler(404)
async def not_found(_):
return await render_template("errors/404.html"), 404
@app.errorhandler(500)
async def server_error(_):
return await render_template("errors/500.html"), 500
```
Match `render_template` import style already used in the file.
- [ ] **Step 4: Commit**
```bash
git add fabledscryer/templates/errors/ fabledscryer/app.py
git commit -m "feat: themed 404 and 500 pages"
```
### Task 8: Dashboard hero title "Under Watch, Under Care"
**Files:**
- Modify: `fabledscryer/templates/dashboard/index.html` (or equivalent)
- [ ] **Step 1: Locate the dashboard top-of-page template**
Run:
```bash
ls fabledscryer/templates/dashboard/
grep -rn "page-title\|dashboard" fabledscryer/templates/dashboard/ | head
```
- [ ] **Step 2: At the top of the dashboard page content block, add a hero title**
```html
<h1 class="page-title" style="text-align:center; color: var(--gold); font-size: 1.8rem; letter-spacing: 0.02em;">Under Watch, Under Care</h1>
```
If the dashboard already has a `.page-title`, replace its text content with "Under Watch, Under Care" rather than adding a second title.
- [ ] **Step 3: Commit**
```bash
git add fabledscryer/templates/dashboard/
git commit -m "copy: dashboard hero title"
```
### Task 9: Manual visual verification
- [ ] **Step 1: Start the app**
Run:
```bash
docker compose up --build
```
- [ ] **Step 2: Open `http://localhost:5000` in a browser and walk through**
- Login page — tagline visible, gold wordmark, candle glow background
- Dashboard — hero title, logo in nav, no purple anywhere
- Hosts / Alerts / Plugins / Settings — palette applied consistently
- Trigger a 404 (e.g. `/nope`) — themed page renders
- Check any empty state you can produce
- [ ] **Step 3: Note visually off items in a scratch file, fix them, and commit**
```bash
git add -A
git commit -m "style: visual polish after manual review"
```
**PR 1 done.** Open PR with title `feat: roundtable visual reskin (pewter & gold)`.
---
## PR 2 — Python Package Rename
### Task 10: Rename the package directory
**Files:**
- Rename: `fabledscryer/``roundtable/`
- [ ] **Step 1: Rename the directory**
```bash
git mv fabledscryer roundtable
```
- [ ] **Step 2: Confirm the move**
```bash
ls roundtable/ | head
git status | head
```
Expected: see `__init__.py`, `app.py`, `cli.py`, etc. inside `roundtable/`.
- [ ] **Step 3: Commit**
```bash
git commit -m "refactor: rename package directory fabledscryer → roundtable"
```
### Task 11: Rewrite all `fabledscryer` imports to `roundtable`
**Files:**
- Modify: every file under `roundtable/`, `tests/`, `alembic.ini`, `entrypoint.sh`, `Dockerfile` that imports from the old package
- [ ] **Step 1: Find every remaining `fabledscryer` reference in Python code**
Run:
```bash
grep -rn "fabledscryer" roundtable/ tests/ alembic.ini entrypoint.sh Dockerfile pyproject.toml
```
Keep the output visible as a checklist.
- [ ] **Step 2: Run a safe codemod across Python files**
```bash
grep -rl "fabledscryer" roundtable/ tests/ | xargs sed -i 's/fabledscryer/roundtable/g'
```
This touches only files under `roundtable/` and `tests/`. Do NOT run it across the whole repo yet — `docs/`, `.env.example`, `docker-compose.yml`, `config.example.yaml` and `README.md` are handled in later PRs.
- [ ] **Step 3: Spot-check a few critical files**
```bash
grep -n "roundtable\|fabledscryer" roundtable/app.py roundtable/cli.py roundtable/config.py roundtable/migrations/env.py
```
Expected: only `roundtable` references remain; no stray `fabledscryer`.
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "refactor: update imports fabledscryer → roundtable"
```
### Task 12: Update `pyproject.toml`
**Files:**
- Modify: `pyproject.toml`
- [ ] **Step 1: Replace the project name, script entry, and hatch packages**
```toml
[project]
name = "roundtable"
version = "0.1.0"
# ... dependencies unchanged ...
[project.scripts]
roundtable = "roundtable.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["roundtable"]
```
Leave `[project.optional-dependencies]`, `[tool.pytest.ini_options]`, and all dependency pins untouched.
- [ ] **Step 2: Commit**
```bash
git add pyproject.toml
git commit -m "build: rename package to roundtable in pyproject"
```
### Task 13: Update `alembic.ini`
**Files:**
- Modify: `alembic.ini`
- [ ] **Step 1: Change `script_location`**
```ini
[alembic]
script_location = roundtable/migrations
prepend_sys_path = .
version_path_separator = os
```
- [ ] **Step 2: Commit**
```bash
git add alembic.ini
git commit -m "build: point alembic at roundtable/migrations"
```
### Task 14: Verify the package installs and imports cleanly
- [ ] **Step 1: Install in editable mode into a throwaway venv**
```bash
python -m venv /tmp/rt-verify
/tmp/rt-verify/bin/pip install -e .
/tmp/rt-verify/bin/python -c "import roundtable; import roundtable.app; import roundtable.cli; print('ok')"
/tmp/rt-verify/bin/roundtable --help
```
Expected: `ok` and CLI help output. No ImportError.
- [ ] **Step 2: Remove venv**
```bash
rm -rf /tmp/rt-verify
```
- [ ] **Step 3: No commit needed (verification only).**
**PR 2 done.** Open PR with title `refactor: rename python package fabledscryer → roundtable`.
---
## PR 3 — Config & Environment Variables
### Task 15: Add env-var fallback shim in `roundtable/config.py`
**Files:**
- Modify: `roundtable/config.py`
- [ ] **Step 1: Replace the env var lookups with a fallback helper**
Open `roundtable/config.py` and replace the existing `load_bootstrap` body so it reads `ROUNDTABLE_*` first and falls back to `FABLEDSCRYER_*` with a deprecation warning.
```python
def _env_with_fallback(new_name: str, old_name: str) -> str | None:
val = os.environ.get(new_name)
if val is not None:
return val
val = os.environ.get(old_name)
if val is not None:
logger.warning(
"%s is deprecated, use %s instead. Fallback will be removed "
"in a future release.",
old_name, new_name,
)
return val
return None
```
Then inside `load_bootstrap`, replace the existing env lookups with:
```python
database_url = (
_env_with_fallback("ROUNDTABLE_DATABASE_URL", "FABLEDSCRYER_DATABASE_URL")
or _env_with_fallback("ROUNDTABLE_DATABASE__URL", "FABLEDSCRYER_DATABASE__URL")
or raw.get("database", {}).get("url")
)
if not database_url:
raise ValueError(
"Database URL is required. Set ROUNDTABLE_DATABASE_URL env var "
"or add 'database.url' to config.yaml."
)
# ...
plugin_dir = (
_env_with_fallback("ROUNDTABLE_PLUGIN_DIR", "FABLEDSCRYER_PLUGIN_DIR")
or raw.get("plugin_dir", "plugins")
)
```
And in `_resolve_secret_key`:
```python
from_env = (
_env_with_fallback("ROUNDTABLE_SECRET_KEY", "FABLEDSCRYER_SECRET_KEY")
or raw.get("secret_key")
)
```
- [ ] **Step 2: Grep for any other `FABLEDSCRYER_` or `FABLEDNETMON_` env var reads across `roundtable/`**
```bash
grep -rn "FABLEDSCRYER_\|FABLEDNETMON_" roundtable/
```
For each hit, apply the same pattern (new name first, old as fallback with warning). If `FABLEDNETMON_` is pure residue with no current consumer, delete the reference.
- [ ] **Step 3: Commit**
```bash
git add roundtable/config.py
git commit -m "feat: ROUNDTABLE_* env vars with FABLEDSCRYER_* fallback"
```
### Task 16: Update `.env.example` and `config.example.yaml`
**Files:**
- Modify: `.env.example`, `config.example.yaml`
- [ ] **Step 1: Rewrite `.env.example`**
```bash
grep -n "FABLEDSCRYER\|FABLEDNETMON" .env.example
```
Replace every occurrence with `ROUNDTABLE_*`. Add a short comment at top:
```
# Roundtable environment configuration.
# Only ROUNDTABLE_DATABASE_URL is required. Other settings live in the DB.
```
- [ ] **Step 2: Rewrite `config.example.yaml`**
Replace the header and example strings:
```yaml
# Roundtable — Bootstrap Configuration Example
#
# The only REQUIRED setting is the database URL.
# Set it via env var (recommended for Docker):
#
# ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://user:pass@host/db
#
# All other settings are stored in the database and managed via
# the Settings UI at /settings/.
database:
url: "postgresql+asyncpg://roundtable:password@localhost/roundtable"
```
- [ ] **Step 3: Commit**
```bash
git add .env.example config.example.yaml
git commit -m "docs: ROUNDTABLE_* env var examples"
```
### Task 17: Manual config verification
- [ ] **Step 1: Run the app with only the new env var**
```bash
ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@localhost/fabledscryer \
python -m roundtable.cli --host 127.0.0.1 --port 5001
```
(Use whatever local DB you have; the old db name is fine — data isn't being renamed.)
Expected: app starts, no deprecation warning.
- [ ] **Step 2: Run the app with only the old env var**
```bash
FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@localhost/fabledscryer \
python -m roundtable.cli --host 127.0.0.1 --port 5001
```
Expected: app starts, deprecation warning logged once.
- [ ] **Step 3: Kill processes.** No commit.
**PR 3 done.** Open PR with title `feat: ROUNDTABLE_* env vars (FABLEDSCRYER_* fallback)`.
---
## PR 4 — Container, Deploy, User-Visible Strings
### Task 18: Update `Dockerfile`
**Files:**
- Modify: `Dockerfile`
- [ ] **Step 1: Replace `fabledscryer` references**
Change:
```dockerfile
COPY fabledscryer/ fabledscryer/
```
to
```dockerfile
COPY roundtable/ roundtable/
```
Change the CMD:
```dockerfile
CMD ["roundtable", "--host", "0.0.0.0", "--port", "5000"]
```
Leave everything else (apt packages, gosu, app user, EXPOSE 5000) untouched.
- [ ] **Step 2: Commit**
```bash
git add Dockerfile
git commit -m "build: update Dockerfile for roundtable package"
```
### Task 19: Update `entrypoint.sh`
**Files:**
- Modify: `entrypoint.sh`
- [ ] **Step 1: Replace the `nut_setup.py` invocation path**
Change every `/app/fabledscryer/nut_setup.py` to `/app/roundtable/nut_setup.py`.
Update any comment that says "FabledScryer container entrypoint." to "Roundtable container entrypoint."
- [ ] **Step 2: Commit**
```bash
git add entrypoint.sh
git commit -m "build: entrypoint.sh uses roundtable package path"
```
### Task 20: Update `docker-compose.yml`
**Files:**
- Modify: `docker-compose.yml`
- [ ] **Step 1: Replace the service definition**
```yaml
services:
roundtable:
build: .
container_name: roundtable
image: roundtable:latest
ports:
- "5000:5000"
volumes:
- app_data:/data
- ./plugins:/app/plugins:ro
- /mnt/Data/traefik/log:/var/log/traefik:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://roundtable:roundtable@db/roundtable
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: roundtable
POSTGRES_PASSWORD: roundtable
POSTGRES_DB: roundtable
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U roundtable"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata:
app_data:
```
**Data note:** the existing dev database is named `fabledscryer` with user `fabledscryer`. For local dev, either (a) drop and recreate the DB with the new name, or (b) leave the existing compose `environment` values pointing at the old DB name if you want to preserve data. Pick based on whether the local DB has data worth keeping — document the choice in the commit message.
- [ ] **Step 2: Commit**
```bash
git add docker-compose.yml
git commit -m "build: docker-compose service → roundtable"
```
### Task 21: Flip user-visible wordmark and title
**Files:**
- Modify: `roundtable/templates/base.html`
- [ ] **Step 1: Update the `<title>` block (around line 6)**
```html
<title>{% block title %}Roundtable{% endblock %}</title>
```
- [ ] **Step 2: Update the nav wordmark (around line 214)**
Change `Fabled Scryer` inside `nav .brand` to `Roundtable`.
- [ ] **Step 3: Grep for any other user-visible "Fabled Scryer" strings in templates**
```bash
grep -rn "Fabled Scryer\|FabledScryer" roundtable/templates/
```
Replace each with "Roundtable".
- [ ] **Step 4: Commit**
```bash
git add roundtable/templates/
git commit -m "copy: flip visible wordmark → Roundtable"
```
### Task 22: Manual container verification
- [ ] **Step 1: Rebuild and run**
```bash
docker compose down
docker compose up --build
```
- [ ] **Step 2: Verify**
- Container named `roundtable` runs
- Browser shows "Roundtable" in tab title and nav
- Login, dashboard, and error pages all render with the new palette and wordmark
- Logs show no import errors and no unhandled deprecation warnings
- [ ] **Step 3: Stop**
```bash
docker compose down
```
**PR 4 done.** Open PR with title `feat: roundtable container & wordmark flip`.
---
## PR 5 — Docs & Repo
### Task 23: Sweep `README.md` and `docs/`
**Files:**
- Modify: `README.md`, `docs/**/*.md`
- [ ] **Step 1: Find every remaining reference**
```bash
grep -rn "fabledscryer\|FabledScryer\|Fabled Scryer" README.md docs/
```
- [ ] **Step 2: Replace mechanically, then proofread**
```bash
grep -rl "fabledscryer\|FabledScryer\|Fabled Scryer" README.md docs/ \
| xargs sed -i \
-e 's/FabledScryer/Roundtable/g' \
-e 's/fabledscryer/roundtable/g' \
-e 's/Fabled Scryer/Roundtable/g'
```
Then read each changed file top-to-bottom and fix any mangled sentences (e.g. capitalization at the start of sentences, code blocks that now reference a directory that still needs a `./` prefix, etc.). Pay extra attention to:
- `docs/architecture.md`
- `docs/reference/code-map.md`
- `docs/core/configuration.md`
- `docs/plugins/writing-a-plugin.md`
- [ ] **Step 3: Commit**
```bash
git add README.md docs/
git commit -m "docs: roundtable rename sweep"
```
### Task 24: Rename the Forgejo repo
- [ ] **Step 1: Via Forgejo UI or API, rename the repo from `FabledScryer` to `Roundtable`.** The old URL will redirect for one grace period.
- [ ] **Step 2: Update your local remote URL**
```bash
git remote set-url origin <new-url>
git remote -v
```
- [ ] **Step 3: Optionally rename the local working directory**
```bash
# from the parent dir
cd ..
mv FabledScryer Roundtable
cd Roundtable
```
- [ ] **Step 4: No commit.**
**PR 5 done.** Open PR with title `docs: roundtable rename sweep`.
---
## PR 6 — Cleanup (deferred one release cycle)
### Task 25: Remove env var fallback shim
**Files:**
- Modify: `roundtable/config.py`
- [ ] **Step 1: Delete `_env_with_fallback` and inline direct `os.environ.get("ROUNDTABLE_*")` lookups**
Replace each `_env_with_fallback("ROUNDTABLE_X", "FABLEDSCRYER_X")` call with `os.environ.get("ROUNDTABLE_X")`. Delete the helper.
- [ ] **Step 2: Grep to confirm no `FABLEDSCRYER_` references remain**
```bash
grep -rn "FABLEDSCRYER_\|FABLEDNETMON_" .
```
Expected: zero hits (or only historical commit messages, which don't show up in `grep`).
- [ ] **Step 3: Commit**
```bash
git add roundtable/config.py
git commit -m "refactor: remove FABLEDSCRYER_* env var fallback shim"
```
### Task 26: Final sweep
- [ ] **Step 1: Search the entire repo one last time**
```bash
grep -rn "fabledscryer\|FabledScryer\|Fabled Scryer\|FABLEDSCRYER\|FABLEDNETMON" .
```
Expected: zero results outside of `.git/` and historical `docs/superpowers/plans/` or `docs/superpowers/specs/` files (those record the rename intentionally — leave them).
- [ ] **Step 2: If any unexpected hits appear, fix and commit**
```bash
git add -A
git commit -m "refactor: final rename sweep"
```
**PR 6 done.** Rebrand complete.
---
## Self-Review Notes
- Spec coverage: all 5 spec sections mapped to PRs 15; PR 6 handles the cleanup implied by the shim in PR 3.
- No placeholders — every template replacement includes concrete code; every grep includes the actual pattern.
- Type consistency: config helper is `_env_with_fallback` in both Task 15 and Task 25; env var names are `ROUNDTABLE_DATABASE_URL`, `ROUNDTABLE_PLUGIN_DIR`, `ROUNDTABLE_SECRET_KEY` consistently.
- Tests intentionally skipped per project feedback; verification is manual (browser + container).
@@ -0,0 +1,161 @@
# Roundtable Rebrand — Design
**Date:** 2026-04-13
**Status:** Approved for planning
## 1. Identity
**Name:** Roundtable (dropping the "Fabled" prefix).
**Why the change:** The old name `FabledScryer` framed the app as a single seer peering into one crystal ball. The actual product is a gathering point — hosts, metrics, alerts, plugins, and dashboards from across the homelab converge here. "Roundtable" captures that: a place where the realm's tools and information meet.
**Name conflict check:**
- *Roundtable Software* — a Progress ABL tooling vendor. Different demographic, no overlap.
- *Fabled.gg* — a virtual tabletop product. Closest concern, and the reason the "Fabled" prefix is dropped entirely to avoid confusion inside the tabletop/fantasy space.
**Umbrella:** `fabledsword.com` remains the family umbrella domain; `git.fabledsword.com` remains the plugin catalog host. Roundtable becomes one product under that umbrella.
**Tone:** Heraldic / Arthurian register. The roundtable is the gathering place of those who keep watch over the realm.
## 2. Visual System
### Palette — Pewter & Gold
Design tokens (map to CSS custom properties in `base.html` `:root`):
| Token | Value | Role |
|---|---|---|
| `--bg-0` | `#131315` | Page background (near-black, no blue cast) |
| `--bg-1` | `#1d1d20` | Card / panel background |
| `--bg-2` | `#24242a` | Hover / elevated surface |
| `--border` | `#30303a` | Default border |
| `--border-accent` | `#c8a840` | Gold accent border (focus, active) |
| `--text-0` | `#d4d0c0` | Primary text (warm parchment) |
| `--text-1` | `#b8b8b0` | Secondary text |
| `--text-2` | `#8a8a92` | Tertiary / pewter text |
| `--gold` | `#c8a840` | Brand gold |
| `--pewter` | `#8a8a92` | Brand pewter |
No purple. No steel blue. Backgrounds are neutral near-black; text is warm parchment; the only chromatic accents are gold and pewter.
### Buttons — full action palette
| Role | Color | Use |
|---|---|---|
| Primary | Gold (`#c8a840`) | Main CTA, submit, confirm |
| Success | Green | Positive state changes, "all clear" |
| Danger | Crimson | Destructive / warning actions |
| Secondary | Pewter (`#8a8a92`) | Cancel, neutral |
Exact green/crimson hues finalized in implementation; must sit harmoniously next to gold and against the near-black background.
### Logo — Heraldic Seal (crown & table)
Locked SVG (24×24 viewBox, solid gold fills only):
```html
<!-- Seal: gold rim -->
<circle cx="12" cy="12" r="10.5" fill="none" stroke="#c8a840" stroke-width="1.5"/>
<!-- Faint pewter wash -->
<circle cx="12" cy="12" r="9.3" fill="#8a8a92" opacity="0.1"/>
<!-- Crown above, five points -->
<path d="M7 10 L7 5.8 L9 7.8 L10.5 5 L12 7.2 L13.5 5 L15 7.8 L17 5.8 L17 10 Z" fill="#c8a840"/>
<!-- Table: filled ellipse tabletop + two legs -->
<ellipse cx="12" cy="15.5" rx="5.2" ry="1" fill="#c8a840"/>
<rect x="9.6" y="15.5" width="1" height="3.8" fill="#c8a840"/>
<rect x="13.4" y="15.5" width="1" height="3.8" fill="#c8a840"/>
```
Reads as a heraldic seal: gold rim, faint pewter wash, five-point crown, solid-gold elliptical table with two legs. No inner detail to clutter at small sizes. Legible from 22px nav icon to 200px marketing size.
### Typography
**EB Garamond** via Google Fonts. Used for the wordmark, headings, and body. Warm, classical, matches the heraldic register without being costume-y. Replaces Libertinus Serif.
### Background
Candlelit glow — a soft, warm radial wash behind the main content area. Replaces the animated star field. Static (no animation) to stay calm and low-distraction.
### Wordmark
"Roundtable" in EB Garamond, gold (`#c8a840`), paired with the seal mark to the left in the nav header.
## 3. Voice & Copy
**Strategy:** plain, functional UI copy. Themed flavor appears only at the edges — places where a little atmosphere costs nothing and rewards exploration.
**Plain (no flavor):**
- Host list, alert list, plugin settings, forms, tables, error validation
- Button labels ("Save", "Delete", "Add host")
- Column headers, tooltips, help text
**Themed (heraldic flavor):**
- **Login screen** — brief atmospheric tagline below the wordmark
- **Empty states** — "No hosts yet. Summon one to the table." style, one line each
- **404 / 500 pages** — themed error messages with clear recovery actions
- **Dashboard hero title** — **"Under Watch, Under Care"** (optional, can be disabled)
**Not used:** themed language in validation errors, confirmation dialogs, or anywhere the user is trying to accomplish a task. Flavor must never get in the way of understanding what's happening.
## 4. Rename Mechanics
**Code & packaging**
- `fabledscryer/` package directory → `roundtable/`
- All `from fabledscryer...` / `import fabledscryer...``roundtable`
- `pyproject.toml`: project name, CLI entry point (`fabledscryer = "fabledscryer.cli:main"``roundtable = "roundtable.cli:main"`), tool sections
- `__init__.py` package metadata
**Runtime config**
- Env vars: `FABLEDSCRYER_*``ROUNDTABLE_*`
- Residual `FABLEDNETMON_*` env vars removed
- Config dir: `~/.config/fabledscryer``~/.config/roundtable` (one-shot copy on first boot)
- Log file names, systemd unit names if any
**Container & deploy**
- `Dockerfile` labels, workdir
- `docker-compose.yml` service name, image tag, volume names, `container_name`
- Published image: `fabledscryer:latest``roundtable:latest`
**Database**
- Alembic `script_location` and any customized version table
- Table prefixes (if any — confirm via grep during implementation)
- Rename migration only if prefixed tables exist
**Templates & UI**
- `base.html` wordmark, `<title>`, meta tags
- Hardcoded "FabledScryer" strings in templates, flash messages, emails
- Favicon / app icon assets
**Docs & repo**
- README, CONTRIBUTING, `docs/`
- Forgejo repo rename (redirect preserves old URL)
- Local directory rename (optional)
**Preserved (not renamed)**
- `fabledsword.com` umbrella domain
- `git.fabledsword.com` plugin catalog host
- Plugin repo names
## 5. Sequencing
Staged so `main` stays runnable between PRs.
**PR 1 — Visual reskin (no rename)**
New palette tokens, locked logo SVG, EB Garamond font swap, candlelit glow background, themed edge copy (login, empty states, 404/500, dashboard hero "Under Watch, Under Care"). Still named FabledScryer internally. Ships independently, low risk.
**PR 2 — Python package rename**
`fabledscryer/``roundtable/`, all imports, `pyproject.toml` name + entry point, `__init__.py` metadata. Docker/config untouched. Package installable as `roundtable` locally.
**PR 3 — Config & env vars**
`FABLEDSCRYER_*``ROUNDTABLE_*` with a short-lived fallback reader (accept both, warn on old) so self-hosters don't break mid-upgrade. Config dir migration on first boot. Drop `FABLEDNETMON_*` residue.
**PR 4 — Container & deploy**
`Dockerfile`, `docker-compose.yml` service/image/volume/container names. Published image tag switch. Template wordmark + `<title>` + meta (the last user-visible string flip).
**PR 5 — Repo & docs**
README, docs, CONTRIBUTING. Forgejo repo rename. Local directory rename (optional).
**PR 6 — Cleanup (after one release cycle)**
Remove env var fallback shim from PR 3. Remove config dir migration code.
**Rationale:** reskin first is safe and visible; package rename is the mechanical core; config/container flips are the breaking-change moments gated behind the fallback shim; docs last so they reflect final state.
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Roundtable container entrypoint.
#
# Drops from root to the 'app' user via gosu, then runs the command.
# The container starts as root only so /data permissions can be fixed up
# if needed before handing off to the unprivileged user.
set -e
exec gosu app "$@"
-1
View File
@@ -1 +0,0 @@
__version__ = "0.1.0"
-104
View File
@@ -1,104 +0,0 @@
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 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")
_OPERATORS = [(op.value, op.value) for op in AlertOperator]
@alerts_bp.get("/")
@require_role(UserRole.viewer)
async def list_alerts():
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(AlertState, AlertRule)
.join(AlertRule, AlertState.rule_id == AlertRule.id)
.where(AlertState.state.in_([
AlertStateEnum.firing, AlertStateEnum.acknowledged, AlertStateEnum.pending
]))
.order_by(AlertState.last_transition_at.desc())
)
active = result.all()
rules_result = await db.execute(
select(AlertRule).order_by(AlertRule.name)
)
rules = rules_result.scalars().all()
return await render_template("alerts/list.html", active=active, rules=rules, operators=_OPERATORS)
@alerts_bp.get("/rules/new")
@require_role(UserRole.operator)
async def new_rule():
return await render_template("alerts/rules_form.html", rule=None, operators=_OPERATORS)
@alerts_bp.post("/rules")
@require_role(UserRole.operator)
async def create_rule():
form = await request.form
user_id = session.get("user_id")
now = datetime.now(timezone.utc)
rule = AlertRule(
name=form["name"].strip(),
source_module=form["source_module"].strip(),
resource_name=form["resource_name"].strip(),
metric_name=form["metric_name"].strip(),
operator=AlertOperator(form["operator"]),
threshold=float(form["threshold"]),
consecutive_failures_required=int(form.get("consecutive_failures_required") or 1),
enabled=True,
created_by=user_id,
)
state = AlertState(
rule_id=rule.id,
state=AlertStateEnum.inactive,
last_transition_at=now,
)
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(rule)
await db.flush()
state.rule_id = rule.id
db.add(state)
return redirect(url_for("alerts.list_alerts"))
@alerts_bp.post("/rules/<rule_id>/delete")
@require_role(UserRole.admin)
async def delete_rule(rule_id: str):
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if rule:
await db.delete(rule)
return redirect(url_for("alerts.list_alerts"))
@alerts_bp.post("/<rule_id>/acknowledge")
@require_role(UserRole.operator)
async def acknowledge(rule_id: str):
user_id = session.get("user_id")
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(
select(AlertState).where(AlertState.rule_id == rule_id)
)
state = result.scalar_one_or_none()
if state is None:
return "Not found", 404
if state.state != AlertStateEnum.firing:
return "Conflict: alert is not in FIRING state", 409
state.state = AlertStateEnum.acknowledged
state.acknowledged_by = user_id
state.acknowledged_at = now
state.last_transition_at = now
return redirect(url_for("alerts.list_alerts"))
-77
View File
@@ -1,77 +0,0 @@
from __future__ import annotations
import bcrypt
from quart import Blueprint, render_template, request, redirect, url_for, session
from sqlalchemy import select, func
from fabledscryer.auth.middleware import login_user, logout_user
from fabledscryer.models.users import User, UserRole
auth_bp = Blueprint("auth", __name__)
async def get_user_count(app) -> int:
async with app.db_sessionmaker() as db:
result = await db.execute(select(func.count()).select_from(User))
return result.scalar_one()
async def get_user_by_username(app, username: str) -> User | None:
async with app.db_sessionmaker() as db:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
@auth_bp.get("/login")
async def login():
from quart import current_app
if await get_user_count(current_app) == 0:
return redirect(url_for("auth.setup"))
return await render_template("auth/login.html")
@auth_bp.post("/login")
async def login_post():
from quart import current_app
form = await request.form
username = form.get("username", "").strip()
password = form.get("password", "").encode()
user = await get_user_by_username(current_app, username)
if not user or not user.is_active:
return await render_template("auth/login.html", error="Invalid credentials"), 400
if not bcrypt.checkpw(password, user.password_hash.encode()):
return await render_template("auth/login.html", error="Invalid credentials"), 400
login_user(user)
return redirect(url_for("dashboard.index"))
@auth_bp.get("/logout")
async def logout():
logout_user()
return redirect(url_for("auth.login"))
@auth_bp.get("/setup")
async def setup():
from quart import current_app
if await get_user_count(current_app) > 0:
return redirect(url_for("auth.login"))
return await render_template("auth/setup.html")
@auth_bp.post("/setup")
async def setup_post():
from quart import current_app
if await get_user_count(current_app) > 0:
return redirect(url_for("auth.login"))
form = await request.form
username = form.get("username", "").strip()
email = form.get("email", "").strip()
password = form.get("password", "").encode()
if not username or not email or not password:
return await render_template("auth/setup.html", error="All fields required"), 400
pw_hash = bcrypt.hashpw(password, bcrypt.gensalt()).decode()
user = User(username=username, email=email, password_hash=pw_hash, role=UserRole.admin)
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(user)
login_user(user)
return redirect(url_for("dashboard.index"))
-76
View File
@@ -1,76 +0,0 @@
# 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
-1
View File
@@ -1 +0,0 @@
# fabledscryer/settings/__init__.py
-344
View File
@@ -1,344 +0,0 @@
# 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")
@@ -1,49 +0,0 @@
{% extends "base.html" %}
{% 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>
<div class="card">
<form method="post" action="/alerts/rules">
<div class="form-group">
<label>Rule Name</label>
<input type="text" name="name" required autofocus placeholder="e.g. Home server down">
</div>
<div class="form-group">
<label>Source Module</label>
<input type="text" name="source_module" required placeholder="ping, dns, traefik, ...">
</div>
<div class="form-group">
<label>Resource Name</label>
<input type="text" name="resource_name" required placeholder="host name, router name, ...">
</div>
<div class="form-group">
<label>Metric Name</label>
<input type="text" name="metric_name" required placeholder="up, response_time_ms, ...">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label>Operator</label>
<select name="operator">
{% for val, label in operators %}
<option value="{{ val }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Threshold</label>
<input type="number" name="threshold" step="any" required placeholder="0.5">
</div>
</div>
<div class="form-group">
<label>Consecutive failures required before FIRING (default: 1)</label>
<input type="number" name="consecutive_failures_required" value="1" min="1">
</div>
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
<button type="submit" class="btn">Create Rule</button>
<a href="/alerts/" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
@@ -1,53 +0,0 @@
{% extends "base.html" %}
{% 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>
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
</div>
{% set status_color = {"running": "#a0a000", "success": "#40a040", "failed": "#a04040", "interrupted": "#806040"}.get(run.status.value, "#808080") %}
<div class="card">
<div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1rem;font-size:0.9rem;margin-bottom:1rem;">
<span style="color:#606080;">ID</span><span style="color:#a0a0c0;font-family:monospace;">{{ run.id }}</span>
<span style="color:#606080;">Playbook</span><span style="color:#e0e0e0;">{{ run.playbook_path }}</span>
<span style="color:#606080;">Inventory</span><span style="color:#e0e0e0;">{{ run.inventory_path }}</span>
<span style="color:#606080;">Source</span><span style="color:#e0e0e0;">{{ run.source_name }}</span>
<span style="color:#606080;">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span>
<span style="color:#606080;">Started</span><span style="color:#a0a0c0;">{{ run.started_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
{% if run.finished_at %}
<span style="color:#606080;">Finished</span><span style="color:#a0a0c0;">{{ run.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
{% endif %}
</div>
</div>
<div class="card" style="padding:0;">
<div style="padding:0.75rem 1rem;background:#12122a;border-bottom:1px solid #2a2a4a;">
<span style="color:#a0a0c0;font-size:0.85rem;">Output</span>
</div>
{% if run.status.value == "running" %}
<pre id="live-output"
style="margin:0;padding:1rem;background:#080810;font-size:0.78rem;color:#a0c0a0;max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "" }}</pre>
<div id="run-done-status" style="padding:0.5rem 1rem;font-size:0.85rem;color:#606080;">
Streaming…
</div>
<script>
(function() {
var es = new EventSource('/ansible/runs/{{ run.id }}/stream');
var pre = document.getElementById('live-output');
var status = document.getElementById('run-done-status');
es.addEventListener('output', function(e) {
pre.textContent += e.data + '\n';
pre.scrollTop = pre.scrollHeight;
});
es.addEventListener('done', function(e) {
es.close();
status.textContent = 'Finished: ' + e.data;
});
})();
</script>
{% else %}
<pre style="margin:0;padding:1rem;background:#080810;font-size:0.78rem;color:#a0c0a0;max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "(no output)" }}</pre>
{% endif %}
</div>
{% endblock %}
-20
View File
@@ -1,20 +0,0 @@
{% extends "base.html" %}
{% block title %}Login — Fabled Scryer{% endblock %}
{% block content %}
<div style="max-width:400px;margin:4rem auto;">
<div class="card">
<h2 class="page-title">Sign In</h2>
<form method="post" action="/login">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" autofocus required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn">Sign In</button>
</form>
</div>
</div>
{% endblock %}
@@ -1,70 +0,0 @@
{# 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>
@@ -1,9 +0,0 @@
{% 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 %}
@@ -1,22 +0,0 @@
{# fabledscryer/templates/settings/ansible.html #}
{% extends "base.html" %}
{% block title %}Settings — Ansible — Fabled Scryer{% endblock %}
{% block content %}
{% set active_tab = "ansible" %}
{% include "settings/_tabs.html" %}
<form method="post" action="/settings/ansible/">
<div class="card" style="max-width:720px;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Ansible Sources</h2>
<p style="color:var(--text-muted);font-size:0.85rem;margin-bottom:1rem;">
JSON array of source objects. Each requires <code>name</code>, <code>type</code>
(<code>git</code> or <code>local</code>), and either <code>url</code> or <code>path</code>.
</p>
<textarea name="ansible.sources" rows="12"
style="font-family:ui-monospace,monospace;font-size:0.85rem;width:100%;">{{ settings['ansible.sources'] | tojson(indent=2) }}</textarea>
</div>
<div style="margin-top:1rem;">
<button type="submit" class="btn">Save</button>
</div>
</form>
{% endblock %}
@@ -1,161 +0,0 @@
{# fabledscryer/templates/settings/plugins.html #}
{% extends "base.html" %}
{% block title %}Settings — Plugins — Fabled Scryer{% endblock %}
{% block content %}
{% set active_tab = "plugins" %}
{% include "settings/_tabs.html" %}
{# ── Restart banner (replaced by _restart_pending.html after action) #}
<div id="restart-banner"></div>
{# ── Installed Plugins ─────────────────────────────────────────────────────── #}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<div class="section-title">Installed Plugins</div>
<form method="post" action="/settings/plugins/restart/"
hx-post="/settings/plugins/restart/"
hx-target="#restart-banner"
hx-swap="outerHTML"
onsubmit="return confirm('Restart the app now? This will briefly interrupt all monitoring.')"
style="margin:0;">
<button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Restart App</button>
</form>
</div>
{% if not discovered_plugins %}
<div class="card" style="color:var(--text-muted);font-size:0.9rem;">
No plugins found in the plugin directory.
</div>
{% else %}
<form method="post" action="/settings/plugins/">
<div style="display:grid;gap:1rem;max-width:720px;">
{# Plugin index URL #}
<div class="card" style="padding:1rem 1.25rem;">
<div class="section-title" style="margin-bottom:0.6rem;">Plugin Index URL</div>
<div class="form-group" style="margin:0;">
<input type="url" name="plugins.index_url"
value="{{ settings.get('plugins.index_url', '') }}"
placeholder="https://raw.githubusercontent.com/your-org/fabledscryer-plugins/main/index.yaml"
style="font-size:0.85rem;">
</div>
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.4rem;">
URL to the remote <code>index.yaml</code> used by the plugin catalog below.
</div>
</div>
{% for plugin in discovered_plugins %}
<div class="card" style="padding:1.25rem;">
{# Plugin header: enable toggle + name/description #}
<div style="display:flex;align-items:flex-start;gap:0.75rem;margin-bottom:{% if plugin.get('config') %}1rem{% else %}0{% endif %};">
<input type="checkbox"
name="plugin.{{ plugin._dir }}.enabled"
id="plugin-{{ plugin._dir }}-enabled"
{% if plugin._enabled %}checked{% endif %}
style="margin-top:0.2rem;flex-shrink:0;">
<label for="plugin-{{ plugin._dir }}-enabled" style="margin:0;cursor:pointer;">
<span style="font-weight:600;color:var(--text);">{{ plugin.get('name', plugin._dir) }}</span>
<span style="color:var(--text-muted);font-size:0.78rem;margin-left:0.4rem;">v{{ plugin.get('version', '?') }}</span>
{% if plugin.get('description') %}
<div style="color:var(--text-muted);font-size:0.83rem;margin-top:0.15rem;font-weight:normal;">{{ plugin.description }}</div>
{% endif %}
</label>
</div>
{# Config fields — only shown when there are config keys #}
{% if plugin.get('config') %}
<div style="border-top:1px solid var(--border);padding-top:1rem;display:grid;gap:0.6rem;">
{% for cfg_key, cfg_default in plugin.config.items() %}
{% if cfg_default is mapping %}
{# Nested dict group #}
<div style="padding:0.6rem 0.75rem;background:var(--bg);border-radius:5px;border:1px solid var(--border);">
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.6rem;">{{ cfg_key }}</div>
{% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %}
<div style="display:grid;gap:0.5rem;">
{% for sub_key, sub_default in cfg_default.items() %}
{% if sub_default is sameas false or sub_default is sameas true %}
<div style="display:flex;align-items:center;gap:0.5rem;">
<input type="checkbox"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
id="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
{% if sub_cfg.get(sub_key, sub_default) %}checked{% endif %}>
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}-{{ sub_key }}"
style="margin:0;font-size:0.85rem;color:var(--text);">{{ sub_key }}</label>
</div>
{% else %}
<div class="form-group" style="margin:0;">
<label style="font-size:0.82rem;">{{ sub_key }}</label>
<input type="text"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}.{{ sub_key }}"
value="{{ sub_cfg.get(sub_key, sub_default) }}">
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% elif cfg_default is sameas false or cfg_default is sameas true %}
<div style="display:flex;align-items:center;gap:0.5rem;">
<input type="checkbox"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
id="plugin-{{ plugin._dir }}-{{ cfg_key }}"
{% if plugin._config.get(cfg_key, cfg_default) %}checked{% endif %}>
<label for="plugin-{{ plugin._dir }}-{{ cfg_key }}"
style="margin:0;font-size:0.85rem;color:var(--text);">{{ cfg_key }}</label>
</div>
{% else %}
<div class="form-group" style="margin:0;">
<label style="font-size:0.82rem;">{{ cfg_key }}</label>
<input type="{% if 'password' in cfg_key or 'secret' in cfg_key %}password{% else %}text{% endif %}"
name="plugin.{{ plugin._dir }}.{{ cfg_key }}"
value="{% if 'password' in cfg_key or 'secret' in cfg_key %}{% else %}{{ plugin._config.get(cfg_key, cfg_default) }}{% endif %}"
{% if 'password' in cfg_key or 'secret' in cfg_key %}placeholder="••••••••"{% endif %}>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
<div style="margin-top:1rem;display:flex;align-items:center;gap:1rem;">
<button type="submit" class="btn">Save</button>
<span style="font-size:0.82rem;color:var(--text-muted);">Enable/disable changes take effect after a restart.</span>
</div>
</form>
{% endif %}
{# ── Plugin Catalog ────────────────────────────────────────────────────────── #}
<div style="margin-top:2rem;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<div class="section-title">Plugin Catalog</div>
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
hx-get="/settings/plugins/catalog/?refresh=1"
hx-target="#plugin-catalog"
hx-swap="outerHTML">
Refresh
</button>
</div>
{% set index_url = settings.get('plugins.index_url', '') %}
{% if index_url %}
<div id="plugin-catalog"
hx-get="/settings/plugins/catalog/"
hx-trigger="load"
hx-swap="outerHTML">
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">Loading catalog…</div>
</div>
{% else %}
<div id="plugin-catalog" style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
Configure a Plugin Index URL above to browse available plugins.
</div>
{% endif %}
</div>
{% endblock %}
+9 -3
View File
@@ -3,7 +3,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "fabledscryer"
name = "roundtable"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
@@ -20,17 +20,23 @@ dependencies = [
]
[project.optional-dependencies]
ldap = [
"ldap3>=2.9",
]
snmp = [
"pysnmp-lextudio>=6.2",
]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
]
[project.scripts]
fabledscryer = "fabledscryer.cli:main"
roundtable = "roundtable.cli:main"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.hatch.build.targets.wheel]
packages = ["fabledscryer"]
packages = ["roundtable"]
+39
View File
@@ -0,0 +1,39 @@
__version__ = "0.1.0"
# Compatibility shim: allow `import fabledscryer[.x.y]` to resolve to
# `roundtable[.x.y]`. Lets unmodified plugin repos keep working during the
# rebrand transition. Remove once all plugins migrate to `roundtable`.
import sys as _sys
import importlib as _importlib
import importlib.abc as _abc
import importlib.util as _util
_ALIAS = "fabledscryer"
class _RoundtableAliasLoader(_abc.Loader):
def __init__(self, real_name):
self._real_name = real_name
def create_module(self, spec):
return _importlib.import_module(self._real_name)
def exec_module(self, module):
pass
class _RoundtableAliasFinder(_abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname != _ALIAS and not fullname.startswith(_ALIAS + "."):
return None
real_name = "roundtable" + fullname[len(_ALIAS):]
if _util.find_spec(real_name) is None:
return None
return _util.spec_from_loader(
fullname, _RoundtableAliasLoader(real_name)
)
if not any(isinstance(f, _RoundtableAliasFinder) for f in _sys.meta_path):
_sys.meta_path.insert(0, _RoundtableAliasFinder())
_sys.modules.setdefault(_ALIAS, _sys.modules[__name__])
+333
View File
@@ -0,0 +1,333 @@
from __future__ import annotations
from datetime import datetime, timezone
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
from roundtable.core.audit import log_audit
from sqlalchemy import select
from roundtable.auth.middleware import require_role
from roundtable.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator, MaintenanceWindow
from roundtable.models.hosts import Host
from roundtable.models.metrics import PluginMetric
from roundtable.models.users import UserRole
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
_OPERATORS = [(op.value, op.value) for op in AlertOperator]
# Static catalog: source_module → available metric names
METRIC_CATALOG: dict[str, list[str]] = {
"ping": ["up", "response_time_ms"],
"dns": ["resolved", "ip_changed"],
"traefik": ["request_rate", "error_rate", "latency_p50_ms", "latency_p95_ms",
"latency_p99_ms", "response_bytes_rate", "cert_expiry_days"],
"unifi": ["is_up", "latency_ms", "total_clients"],
"docker": ["cpu_pct", "mem_pct"],
"http": ["is_up", "response_ms"],
"host_agent": [
"cpu_pct", "mem_used_pct", "mem_available_bytes", "swap_used_bytes",
"disk_used_pct_worst", "load_1m", "load_5m", "load_15m", "uptime_secs",
],
# snmp metrics are user-defined OID labels — populated dynamically from plugin_metrics
"snmp": [],
}
# Ordered list for source module picker
_SOURCE_MODULES = list(METRIC_CATALOG.keys())
async def _get_resources(db, source_module: str) -> list[str]:
"""Return sorted distinct resource names for a source module.
For ping/dns, supplements with host names so the list is populated
even before any metrics have been recorded.
"""
result = await db.execute(
select(PluginMetric.resource_name)
.where(PluginMetric.source_module == source_module)
.distinct()
.order_by(PluginMetric.resource_name)
)
resources: set[str] = {r for (r,) in result}
if source_module in ("ping", "dns"):
host_result = await db.execute(select(Host.name).order_by(Host.name))
for (name,) in host_result:
resources.add(name)
return sorted(resources)
@alerts_bp.get("/")
@require_role(UserRole.viewer)
async def list_alerts():
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(AlertState, AlertRule)
.join(AlertRule, AlertState.rule_id == AlertRule.id)
.where(AlertState.state.in_([
AlertStateEnum.firing, AlertStateEnum.acknowledged, AlertStateEnum.pending
]))
.order_by(AlertState.last_transition_at.desc())
)
active = result.all()
rules_result = await db.execute(
select(AlertRule).order_by(AlertRule.name)
)
rules = rules_result.scalars().all()
return await render_template("alerts/list.html", active=active, rules=rules, operators=_OPERATORS)
@alerts_bp.get("/api/rule_fields")
@require_role(UserRole.viewer)
async def rule_fields_api():
source = request.args.get("source_module", "")
current_resource = request.args.get("current_resource", "")
current_metric = request.args.get("current_metric", "")
async with current_app.db_sessionmaker() as db:
resources = await _get_resources(db, source) if source else []
metrics = METRIC_CATALOG.get(source, [])
# Dynamic sources (e.g. snmp) have empty static catalog — query live metric names
if source and not metrics:
async with current_app.db_sessionmaker() as db:
m_result = await db.execute(
select(PluginMetric.metric_name)
.where(PluginMetric.source_module == source)
.distinct()
.order_by(PluginMetric.metric_name)
)
metrics = [r for (r,) in m_result]
return await render_template(
"alerts/_rule_fields.html",
resources=resources,
metrics=metrics,
current_resource=current_resource,
current_metric=current_metric,
)
@alerts_bp.get("/rules/new")
@require_role(UserRole.operator)
async def new_rule():
first_source = _SOURCE_MODULES[0]
async with current_app.db_sessionmaker() as db:
resources = await _get_resources(db, first_source)
return await render_template(
"alerts/rules_form.html",
rule=None,
operators=_OPERATORS,
source_modules=_SOURCE_MODULES,
initial_source=first_source,
resources=resources,
metrics=METRIC_CATALOG.get(first_source, []),
)
@alerts_bp.get("/rules/<rule_id>/edit")
@require_role(UserRole.operator)
async def edit_rule(rule_id: str):
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if rule is None:
return "Not found", 404
resources = await _get_resources(db, rule.source_module)
return await render_template(
"alerts/rules_form.html",
rule=rule,
operators=_OPERATORS,
source_modules=_SOURCE_MODULES,
initial_source=rule.source_module,
resources=resources,
metrics=METRIC_CATALOG.get(rule.source_module, []),
)
@alerts_bp.post("/rules")
@require_role(UserRole.operator)
async def create_rule():
form = await request.form
user_id = session.get("user_id")
now = datetime.now(timezone.utc)
rule = AlertRule(
name=form["name"].strip(),
source_module=form["source_module"].strip(),
resource_name=form["resource_name"].strip(),
metric_name=form["metric_name"].strip(),
operator=AlertOperator(form["operator"]),
threshold=float(form["threshold"]),
consecutive_failures_required=int(form.get("consecutive_failures_required") or 1),
enabled=True,
created_by=user_id,
)
state = AlertState(
rule_id=rule.id,
state=AlertStateEnum.inactive,
last_transition_at=now,
)
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(rule)
await db.flush()
state.rule_id = rule.id
db.add(state)
await log_audit(current_app, user_id, session.get("username", ""), "alert_rule.created",
entity_type="alert_rule", entity_id=rule.name,
detail={"source": rule.source_module, "resource": rule.resource_name,
"metric": rule.metric_name})
return redirect(url_for("alerts.list_alerts"))
@alerts_bp.post("/rules/<rule_id>")
@require_role(UserRole.operator)
async def update_rule(rule_id: str):
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if rule is None:
return "Not found", 404
rule.name = form["name"].strip()
rule.source_module = form["source_module"].strip()
rule.resource_name = form["resource_name"].strip()
rule.metric_name = form["metric_name"].strip()
rule.operator = AlertOperator(form["operator"])
rule.threshold = float(form["threshold"])
rule.consecutive_failures_required = int(form.get("consecutive_failures_required") or 1)
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"alert_rule.updated", entity_type="alert_rule", entity_id=rule.name)
return redirect(url_for("alerts.list_alerts"))
@alerts_bp.post("/rules/<rule_id>/toggle")
@require_role(UserRole.operator)
async def toggle_rule(rule_id: str):
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if rule is None:
return "Not found", 404
rule.enabled = not rule.enabled
action = "alert_rule.enabled" if rule.enabled else "alert_rule.disabled"
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
action, entity_type="alert_rule", entity_id=rule.name)
return redirect(url_for("alerts.list_alerts"))
@alerts_bp.post("/rules/<rule_id>/delete")
@require_role(UserRole.admin)
async def delete_rule(rule_id: str):
rule_name = None
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if rule:
rule_name = rule.name
await db.delete(rule)
if rule_name:
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"alert_rule.deleted", entity_type="alert_rule", entity_id=rule_name)
return redirect(url_for("alerts.list_alerts"))
@alerts_bp.get("/maintenance")
@require_role(UserRole.viewer)
async def list_maintenance():
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(MaintenanceWindow).order_by(MaintenanceWindow.start_at.desc())
)
windows = result.scalars().all()
return await render_template(
"alerts/maintenance.html",
windows=windows,
now=now,
source_modules=_SOURCE_MODULES,
)
@alerts_bp.get("/maintenance/new")
@require_role(UserRole.operator)
async def new_maintenance():
return await render_template(
"alerts/maintenance_form.html",
window=None,
source_modules=_SOURCE_MODULES,
)
@alerts_bp.post("/maintenance")
@require_role(UserRole.operator)
async def create_maintenance():
form = await request.form
user_id = session.get("user_id")
start_at = datetime.fromisoformat(form["start_at"]).replace(tzinfo=timezone.utc)
end_at = datetime.fromisoformat(form["end_at"]).replace(tzinfo=timezone.utc)
scope_source = form.get("scope_source", "").strip() or None
scope_resource = form.get("scope_resource", "").strip() or None
if scope_source is None:
scope_resource = None # can't have resource without source
window = MaintenanceWindow(
name=form["name"].strip(),
start_at=start_at,
end_at=end_at,
scope_source=scope_source,
scope_resource=scope_resource,
created_by=user_id,
)
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(window)
scope_desc = "all" if not scope_source else (
f"{scope_source}/{scope_resource}" if scope_resource else f"{scope_source}/*"
)
await log_audit(current_app, user_id, session.get("username", ""),
"maintenance_window.created", entity_type="maintenance_window",
entity_id=window.name, detail={"scope": scope_desc})
return redirect(url_for("alerts.list_maintenance"))
@alerts_bp.post("/maintenance/<window_id>/delete")
@require_role(UserRole.operator)
async def delete_maintenance(window_id: str):
window_name = None
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(
select(MaintenanceWindow).where(MaintenanceWindow.id == window_id)
)
window = result.scalar_one_or_none()
if window:
window_name = window.name
await db.delete(window)
if window_name:
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"maintenance_window.deleted", entity_type="maintenance_window",
entity_id=window_name)
return redirect(url_for("alerts.list_maintenance"))
@alerts_bp.post("/<rule_id>/acknowledge")
@require_role(UserRole.operator)
async def acknowledge(rule_id: str):
user_id = session.get("user_id")
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(
select(AlertState).where(AlertState.rule_id == rule_id)
)
state = result.scalar_one_or_none()
if state is None:
return "Not found", 404
if state.state != AlertStateEnum.firing:
return "Conflict: alert is not in FIRING state", 409
state.state = AlertStateEnum.acknowledged
state.acknowledged_by = user_id
state.acknowledged_at = now
state.last_transition_at = now
return redirect(url_for("alerts.list_alerts"))
@@ -1,4 +1,4 @@
# fabledscryer/ansible/executor.py
# roundtable/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 fabledscryer.models.ansible import AnsibleRun, AnsibleRunStatus
from roundtable.models.ansible import AnsibleRun, AnsibleRunStatus
values: dict = {"output": db_output}
if final_status is not None:
@@ -1,4 +1,4 @@
# fabledscryer/ansible/routes.py
# roundtable/ansible/routes.py
from __future__ import annotations
import asyncio
import uuid
@@ -11,10 +11,10 @@ from quart import (
)
from sqlalchemy import select, update
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
from roundtable.ansible import executor, sources as src_module
from roundtable.auth.middleware import require_role
from roundtable.models.ansible import AnsibleRun, AnsibleRunStatus
from roundtable.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/fabledscryer/ansible
cache_dir: /var/cache/roundtable/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/fabledscryer/ansible")
cache_dir = ansible_cfg.get("cache_dir", "/var/cache/roundtable/ansible")
result = []
for src in sources:
src_type = src.get("type", "local")
+35 -4
View File
@@ -1,8 +1,8 @@
# fabledscryer/app.py
# roundtable/app.py
from __future__ import annotations
import asyncio
from pathlib import Path
from quart import Quart
from quart import Quart, render_template
from .config import load_bootstrap
from .database import init_db
@@ -49,7 +49,7 @@ def create_app(
if not testing:
from .core.settings import (
load_settings_sync, to_smtp_cfg, to_webhook_cfg,
to_ansible_cfg, to_plugins_cfg,
to_ansible_cfg, to_plugins_cfg, to_oidc_cfg, to_ldap_cfg,
)
_settings = load_settings_sync(app.config["DATABASE_URL"])
app.config.update(
@@ -60,6 +60,8 @@ def create_app(
WEBHOOK=to_webhook_cfg(_settings),
ANSIBLE=to_ansible_cfg(_settings),
PLUGINS=to_plugins_cfg(_settings),
OIDC=to_oidc_cfg(_settings),
LDAP=to_ldap_cfg(_settings),
)
else:
app.config.update(
@@ -70,6 +72,8 @@ def create_app(
WEBHOOK={},
ANSIBLE={},
PLUGINS={},
OIDC={"enabled": False},
LDAP={"enabled": False},
)
# ── 5. Plugin migrations ───────────────────────────────────────────────────
@@ -95,6 +99,7 @@ def create_app(
from .alerts.routes import alerts_bp
from .ansible.routes import ansible_bp
from .settings.routes import settings_bp
from .audit.routes import audit_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
@@ -104,6 +109,7 @@ def create_app(
app.register_blueprint(alerts_bp)
app.register_blueprint(ansible_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(audit_bp)
# ── 8. Build task registry ─────────────────────────────────────────────────
app._task_registry = []
@@ -116,7 +122,13 @@ def create_app(
from .core.plugin_manager import load_plugins
load_plugins(app)
# ── 10. Share-token middleware ─────────────────────────────────────────────
# ── 10. Template context: inject plugin_failures into every response ───────
@app.context_processor
def _inject_plugin_failures():
from .core.plugin_manager import get_plugin_failures
return {"plugin_failures": get_plugin_failures()}
# ── 11. Share-token middleware ─────────────────────────────────────────────
@app.before_request
async def _validate_share_token():
"""If ?s=<token> is present, validate it and set g.share_viewer."""
@@ -149,6 +161,15 @@ def create_app(
async def health():
return {"status": "ok"}
# ── 12. Error handlers ─────────────────────────────────────────────────────
@app.errorhandler(404)
async def not_found(_):
return await render_template("errors/404.html"), 404
@app.errorhandler(500)
async def server_error(_):
return await render_template("errors/500.html"), 500
# ── 11. Startup hook ───────────────────────────────────────────────────────
@app.before_serving
async def startup():
@@ -199,7 +220,17 @@ def _register_core_tasks(app: Quart) -> None:
from .core.cleanup import run_cleanup as _cleanup
await _cleanup(app)
async def run_report_check():
from .core.reports import check_and_send
await check_and_send(app)
app._task_registry.extend([
ScheduledTask(
name="weekly_report_check",
coro_factory=run_report_check,
interval_seconds=3600,
run_on_startup=False,
),
ScheduledTask(
name="ping_monitor",
coro_factory=run_ping_monitors,
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import json
from quart import Blueprint, render_template, request, current_app
from sqlalchemy import select
from roundtable.auth.middleware import require_role
from roundtable.models.audit import AuditEvent
from roundtable.models.users import UserRole
audit_bp = Blueprint("audit", __name__, url_prefix="/audit")
@audit_bp.get("/")
@require_role(UserRole.admin)
async def list_events():
limit = min(int(request.args.get("limit", 200)), 500)
action_filter = request.args.get("action", "").strip()
async with current_app.db_sessionmaker() as db:
stmt = select(AuditEvent).order_by(AuditEvent.timestamp.desc()).limit(limit)
if action_filter:
stmt = select(AuditEvent).where(
AuditEvent.action.like(f"{action_filter}%")
).order_by(AuditEvent.timestamp.desc()).limit(limit)
result = await db.execute(stmt)
events = result.scalars().all()
# Parse detail_json for display
parsed = []
for e in events:
detail = {}
if e.detail_json:
try:
detail = json.loads(e.detail_json)
except (ValueError, TypeError):
detail = {"raw": e.detail_json}
parsed.append({"event": e, "detail": detail})
return await render_template(
"audit/list.html",
events=parsed,
limit=limit,
action_filter=action_filter,
)
+98
View File
@@ -0,0 +1,98 @@
"""roundtable/auth/ldap_auth.py
LDAP authentication helper. Requires the optional `ldap3` package.
Falls back gracefully if ldap3 is not installed.
"""
from __future__ import annotations
import asyncio
import logging
logger = logging.getLogger(__name__)
def _ldap_available() -> bool:
try:
import ldap3 # noqa: F401
return True
except ImportError:
return False
def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | None:
"""Synchronous LDAP auth. Returns user info dict or None on failure.
Called via run_in_executor so it doesn't block the event loop.
"""
import ldap3
host = cfg.get("host", "")
port = int(cfg.get("port", 389))
use_tls = cfg.get("tls", False)
bind_dn = cfg.get("bind_dn", "")
bind_password = cfg.get("bind_password", "")
base_dn = cfg.get("base_dn", "")
user_filter = cfg.get("user_filter", "(uid={username})").format(username=username)
admin_group_dn = cfg.get("admin_group_dn", "")
operator_group_dn = cfg.get("operator_group_dn", "")
attr_username = cfg.get("attr_username", "uid")
attr_email = cfg.get("attr_email", "mail")
tls = ldap3.Tls() if use_tls else None
server = ldap3.Server(host, port=port, tls=tls, get_info=ldap3.ALL)
# Service account bind to search for user DN
try:
svc_conn = ldap3.Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
except Exception as exc:
logger.error("LDAP service bind failed: %s", exc)
return None
try:
svc_conn.search(
search_base=base_dn,
search_filter=user_filter,
attributes=[attr_username, attr_email, "memberOf", "dn"],
)
if not svc_conn.entries:
logger.debug("LDAP user not found: %s", username)
return None
entry = svc_conn.entries[0]
user_dn = str(entry.entry_dn)
ldap_username = str(getattr(entry, attr_username, [username])[0]) if hasattr(entry, attr_username) else username
ldap_email = str(getattr(entry, attr_email, [""])[0]) if hasattr(entry, attr_email) else ""
member_of: list[str] = [str(g) for g in getattr(entry, "memberOf", [])]
except Exception as exc:
logger.error("LDAP search failed: %s", exc)
return None
finally:
svc_conn.unbind()
# Bind as the user to verify password
try:
user_conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True)
user_conn.unbind()
except Exception:
logger.debug("LDAP password verify failed for %s", username)
return None
# Map role from group membership
role = "viewer"
if admin_group_dn and admin_group_dn in member_of:
role = "admin"
elif operator_group_dn and operator_group_dn in member_of:
role = "operator"
return {
"username": ldap_username,
"email": ldap_email,
"role": role,
}
async def ldap_authenticate(cfg: dict, username: str, password: str) -> dict | None:
"""Async wrapper around synchronous LDAP auth. Returns user info or None."""
if not _ldap_available():
logger.warning("ldap3 not installed — LDAP auth unavailable")
return None
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _ldap_authenticate_sync, cfg, username, password)
@@ -1,7 +1,7 @@
from __future__ import annotations
import functools
from quart import session, redirect, url_for, abort, g
from fabledscryer.models.users import UserRole
from roundtable.models.users import UserRole
_ROLE_ORDER = [UserRole.viewer, UserRole.operator, UserRole.admin]
+109
View File
@@ -0,0 +1,109 @@
"""roundtable/auth/oidc.py
OIDC Authorization Code flow helpers using httpx.
No extra dependencies — works with any OIDC provider (Authentik, Keycloak, etc.).
JWT signature verification is intentionally skipped for homelab use;
we trust the HTTPS connection to the discovery-documented token/userinfo endpoints.
"""
from __future__ import annotations
import base64
import hashlib
import json
import logging
import os
import time
import httpx
logger = logging.getLogger(__name__)
# Module-level cache: {discovery_url: (fetched_at, doc)}
_discovery_cache: dict[str, tuple[float, dict]] = {}
_DISCOVERY_TTL = 300 # seconds
async def get_discovery(discovery_url: str) -> dict:
"""Fetch and cache the OIDC provider discovery document."""
now = time.monotonic()
if discovery_url in _discovery_cache:
fetched_at, doc = _discovery_cache[discovery_url]
if now - fetched_at < _DISCOVERY_TTL:
return doc
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(discovery_url)
resp.raise_for_status()
doc = resp.json()
_discovery_cache[discovery_url] = (now, doc)
return doc
def build_authorize_url(doc: dict, client_id: str, redirect_uri: str,
scopes: str, state: str, nonce: str) -> str:
"""Build the IdP authorization redirect URL."""
import urllib.parse
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scopes,
"state": state,
"nonce": nonce,
}
return doc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
async def exchange_code(doc: dict, client_id: str, client_secret: str,
code: str, redirect_uri: str) -> dict:
"""Exchange authorization code for tokens. Returns token response dict."""
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
doc["token_endpoint"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"client_secret": client_secret,
},
)
resp.raise_for_status()
return resp.json()
async def get_userinfo(doc: dict, access_token: str) -> dict:
"""Fetch user info from the IdP's userinfo endpoint."""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
doc["userinfo_endpoint"],
headers={"Authorization": f"Bearer {access_token}"},
)
resp.raise_for_status()
return resp.json()
def decode_id_token_payload(id_token: str) -> dict:
"""Decode ID token payload without verifying signature (homelab only)."""
try:
parts = id_token.split(".")
if len(parts) < 2:
return {}
payload_b64 = parts[1]
# Add padding
payload_b64 += "=" * (4 - len(payload_b64) % 4)
return json.loads(base64.urlsafe_b64decode(payload_b64))
except Exception:
return {}
def map_role(groups: list[str], admin_group: str, operator_group: str) -> str:
"""Map IdP groups to a local role string."""
if admin_group and admin_group in groups:
return "admin"
if operator_group and operator_group in groups:
return "operator"
return "viewer"
def generate_state() -> str:
return base64.urlsafe_b64encode(os.urandom(24)).decode().rstrip("=")
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
import bcrypt
from quart import Blueprint, render_template, request, redirect, url_for, session
from sqlalchemy import select, func
from roundtable.auth.middleware import login_user, logout_user
from roundtable.models.users import User, UserRole
auth_bp = Blueprint("auth", __name__)
async def _provision_external_user(app, username: str, email: str, role_str: str):
"""Create or update a user from an external auth provider (OIDC/LDAP).
External users get a randomised unusable password so local login is blocked.
Their role is updated on every login to reflect current IdP group membership.
"""
import secrets
from roundtable.models.users import UserRole
async with app.db_sessionmaker() as db:
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
new_role = UserRole(role_str)
async with db.begin():
if user is None:
unusable_hash = bcrypt.hashpw(secrets.token_bytes(32), bcrypt.gensalt()).decode()
user = User(username=username, email=email or f"{username}@external",
password_hash=unusable_hash, role=new_role)
db.add(user)
else:
if user.role != new_role:
user.role = new_role
# Re-fetch committed user
async with app.db_sessionmaker() as db:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one()
async def get_user_count(app) -> int:
async with app.db_sessionmaker() as db:
result = await db.execute(select(func.count()).select_from(User))
return result.scalar_one()
async def get_user_by_username(app, username: str) -> User | None:
async with app.db_sessionmaker() as db:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
@auth_bp.get("/login")
async def login():
from quart import current_app
if await get_user_count(current_app) == 0:
return redirect(url_for("auth.setup"))
oidc_cfg = current_app.config.get("OIDC", {})
return await render_template("auth/login.html",
oidc_enabled=oidc_cfg.get("enabled", False))
@auth_bp.post("/login")
async def login_post():
from quart import current_app
from roundtable.core.audit import log_audit
form = await request.form
username = form.get("username", "").strip()
password_str = form.get("password", "")
password = password_str.encode()
# Try local auth first
user = await get_user_by_username(current_app, username)
if user and user.is_active and bcrypt.checkpw(password, user.password_hash.encode()):
login_user(user)
await log_audit(current_app, user.id, user.username, "auth.login",
detail={"method": "local", "role": user.role.value})
return redirect(url_for("dashboard.index"))
# Try LDAP fallback if enabled
ldap_cfg = current_app.config.get("LDAP", {})
if ldap_cfg.get("enabled"):
from roundtable.auth.ldap_auth import ldap_authenticate
ldap_info = await ldap_authenticate(ldap_cfg, username, password_str)
if ldap_info:
user = await _provision_external_user(
current_app, ldap_info["username"], ldap_info["email"], ldap_info["role"]
)
login_user(user)
await log_audit(current_app, user.id, user.username, "auth.login",
detail={"method": "ldap", "role": user.role.value})
return redirect(url_for("dashboard.index"))
oidc_cfg = current_app.config.get("OIDC", {})
return await render_template(
"auth/login.html",
error="Invalid credentials",
oidc_enabled=oidc_cfg.get("enabled", False),
), 400
@auth_bp.get("/login/oidc")
async def login_oidc():
from quart import current_app
from roundtable.auth.oidc import get_discovery, build_authorize_url, generate_state
oidc_cfg = current_app.config.get("OIDC", {})
if not oidc_cfg.get("enabled") or not oidc_cfg.get("discovery_url"):
return redirect(url_for("auth.login"))
try:
doc = await get_discovery(oidc_cfg["discovery_url"])
except Exception as exc:
return await render_template("auth/login.html", error=f"OIDC discovery failed: {exc}",
oidc_enabled=True), 502
state = generate_state()
nonce = generate_state()
session["oidc_state"] = state
session["oidc_nonce"] = nonce
redirect_uri = url_for("auth.login_oidc_callback", _external=True)
url = build_authorize_url(
doc, oidc_cfg["client_id"], redirect_uri,
oidc_cfg.get("scopes", "openid profile email"), state, nonce,
)
return redirect(url)
@auth_bp.get("/login/oidc/callback")
async def login_oidc_callback():
from quart import current_app
from roundtable.auth.oidc import get_discovery, exchange_code, get_userinfo, map_role
from roundtable.core.audit import log_audit
oidc_cfg = current_app.config.get("OIDC", {})
error = request.args.get("error")
if error:
desc = request.args.get("error_description", error)
return await render_template("auth/login.html", error=f"SSO error: {desc}",
oidc_enabled=True), 400
code = request.args.get("code", "")
state = request.args.get("state", "")
if not code or state != session.pop("oidc_state", None):
return await render_template("auth/login.html", error="Invalid SSO state — try again.",
oidc_enabled=True), 400
try:
doc = await get_discovery(oidc_cfg["discovery_url"])
redirect_uri = url_for("auth.login_oidc_callback", _external=True)
tokens = await exchange_code(
doc, oidc_cfg["client_id"], oidc_cfg["client_secret"], code, redirect_uri
)
userinfo = await get_userinfo(doc, tokens["access_token"])
except Exception as exc:
return await render_template("auth/login.html", error=f"SSO login failed: {exc}",
oidc_enabled=True), 502
username_claim = oidc_cfg.get("username_claim", "preferred_username")
email_claim = oidc_cfg.get("email_claim", "email")
groups_claim = oidc_cfg.get("groups_claim", "groups")
username = userinfo.get(username_claim) or userinfo.get("sub", "")
email = userinfo.get(email_claim, "") or f"{username}@oidc"
groups = userinfo.get(groups_claim, [])
if not isinstance(groups, list):
groups = []
role = map_role(groups, oidc_cfg.get("admin_group", ""), oidc_cfg.get("operator_group", ""))
if not username:
return await render_template("auth/login.html",
error="SSO returned no username.",
oidc_enabled=True), 400
user = await _provision_external_user(current_app, username, email, role)
login_user(user)
await log_audit(current_app, user.id, user.username, "auth.login",
detail={"method": "oidc", "role": role})
return redirect(url_for("dashboard.index"))
@auth_bp.get("/logout")
async def logout():
logout_user()
return redirect(url_for("auth.login"))
@auth_bp.get("/setup")
async def setup():
from quart import current_app
if await get_user_count(current_app) > 0:
return redirect(url_for("auth.login"))
return await render_template("auth/setup.html")
@auth_bp.post("/setup")
async def setup_post():
from quart import current_app
from roundtable.core.audit import log_audit
if await get_user_count(current_app) > 0:
return redirect(url_for("auth.login"))
form = await request.form
username = form.get("username", "").strip()
email = form.get("email", "").strip()
password = form.get("password", "").encode()
if not username or not email or not password:
return await render_template("auth/setup.html", error="All fields required"), 400
pw_hash = bcrypt.hashpw(password, bcrypt.gensalt()).decode()
user = User(username=username, email=email, password_hash=pw_hash, role=UserRole.admin)
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(user)
login_user(user)
await log_audit(current_app, user.id, user.username, "auth.setup",
detail={"username": username})
return redirect(url_for("dashboard.index"))
+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 Fabled Scryer server."""
"""Start the Roundtable server."""
app = create_app(config_path=Path(config))
app.run(host=host, port=port, debug=debug)
@@ -1,4 +1,4 @@
# fabledscryer/config.py
# roundtable/config.py
from __future__ import annotations
import logging
import os
@@ -11,6 +11,17 @@ logger = logging.getLogger(__name__)
_SECRET_KEY_FILE = Path("/data/secret.key")
def _env(suffix: str) -> str | None:
"""Read ROUNDTABLE_<suffix>, falling back to FABLEDSCRYER_<suffix>.
Allows existing deployments to keep their old env vars working through
the rebrand transition. Remove the fallback once users have migrated.
"""
return os.environ.get(f"ROUNDTABLE_{suffix}") or os.environ.get(
f"FABLEDSCRYER_{suffix}"
)
def load_bootstrap(config_path: Path | str | None = None) -> dict[str, str]:
"""Return the minimum bootstrap config: database_url and secret_key.
@@ -33,19 +44,19 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, str]:
raw = yaml.safe_load(f) or {}
database_url = (
os.environ.get("FABLEDSCRYER_DATABASE_URL")
or os.environ.get("FABLEDSCRYER_DATABASE__URL")
_env("DATABASE_URL")
or _env("DATABASE__URL")
or raw.get("database", {}).get("url")
)
if not database_url:
raise ValueError(
"Database URL is required. Set FABLEDSCRYER_DATABASE_URL env var "
"Database URL is required. Set ROUNDTABLE_DATABASE_URL env var "
"or add 'database.url' to config.yaml."
)
secret_key = _resolve_secret_key(raw)
plugin_dir = (
os.environ.get("FABLEDSCRYER_PLUGIN_DIR")
_env("PLUGIN_DIR")
or raw.get("plugin_dir", "plugins")
)
@@ -58,7 +69,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("FABLEDSCRYER_SECRET_KEY") or raw.get("secret_key")
from_env = _env("SECRET_KEY") or raw.get("secret_key")
if from_env:
return from_env
@@ -5,17 +5,18 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from fabledscryer.models.alerts import (
from roundtable.models.alerts import (
AlertEvent,
AlertOperator,
AlertRule,
AlertState,
AlertStateEnum,
MaintenanceWindow,
)
from fabledscryer.models.metrics import PluginMetric
from roundtable.models.metrics import PluginMetric
if TYPE_CHECKING:
from quart import Quart
@@ -60,6 +61,26 @@ async def record_metric(
session.add(metric)
await session.flush()
# Check active maintenance windows — skip rule evaluation if suppressed
mw_result = await session.execute(
select(MaintenanceWindow).where(
MaintenanceWindow.start_at <= now,
MaintenanceWindow.end_at >= now,
or_(
MaintenanceWindow.scope_source.is_(None),
and_(
MaintenanceWindow.scope_source == source_module,
or_(
MaintenanceWindow.scope_resource.is_(None),
MaintenanceWindow.scope_resource == resource_name,
),
),
),
).limit(1)
)
if mw_result.scalar_one_or_none() is not None:
return # suppressed by maintenance window
# Load matching enabled rules
result = await session.execute(
select(AlertRule).where(
@@ -205,7 +226,7 @@ async def _dispatch_notification(
event_id: str,
) -> None:
"""Run after the transaction commits. Sends notifications and updates the event row."""
from fabledscryer.core.notifications import dispatch_notifications
from roundtable.core.notifications import dispatch_notifications
alert_data = {
"rule_name": rule.name,
+40
View File
@@ -0,0 +1,40 @@
"""roundtable/core/audit.py
Helpers for writing audit log entries. Each call opens its own DB
session so audit events are committed independently of the calling
route's transaction (audit is only written after the main action
succeeds, by calling this after the main `async with db.begin()` block).
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
async def log_audit(
app,
user_id: str | None,
username: str,
action: str,
entity_type: str | None = None,
entity_id: str | None = None,
detail: dict | None = None,
) -> None:
"""Write one audit event. Never raises — failures are logged and swallowed."""
from roundtable.models.audit import AuditEvent
try:
async with app.db_sessionmaker() as db:
async with db.begin():
db.add(AuditEvent(
user_id=user_id,
username=username or "unknown",
action=action,
entity_type=entity_type,
entity_id=entity_id,
detail_json=json.dumps(detail) if detail else None,
timestamp=datetime.now(timezone.utc),
))
except Exception:
logger.exception("Failed to write audit event action=%r", action)
@@ -5,9 +5,9 @@ from typing import TYPE_CHECKING
from sqlalchemy import delete
from fabledscryer.models.monitors import DnsResult, PingResult
from fabledscryer.models.metrics import PluginMetric
from fabledscryer.models.ansible import AnsibleRun
from roundtable.models.monitors import DnsResult, PingResult
from roundtable.models.metrics import PluginMetric
from roundtable.models.ansible import AnsibleRun
if TYPE_CHECKING:
from quart import Quart
@@ -1,4 +1,4 @@
# fabledscryer/core/migration_runner.py
# roundtable/core/migration_runner.py
from __future__ import annotations
import logging
from pathlib import Path
@@ -33,8 +33,8 @@ async def dispatch_notifications(
async def _send_email(cfg: dict, alert: dict) -> dict:
try:
msg = EmailMessage()
msg["Subject"] = f"[Fabled Scryer] {alert['state']}{alert['rule_name']}"
msg["From"] = cfg.get("username", "fabledscryer@localhost")
msg["Subject"] = f"[Roundtable] {alert['state']}{alert['rule_name']}"
msg["From"] = cfg.get("username", "roundtable@localhost")
msg["To"] = ", ".join(cfg["recipients"])
msg.set_content(
f"Alert: {alert['state']}\n"
@@ -1,4 +1,4 @@
# fabledscryer/core/plugin_index.py
# roundtable/core/plugin_index.py
"""Remote plugin catalog: fetch, parse, and cache the plugin index.yaml."""
from __future__ import annotations
import logging
@@ -8,7 +8,8 @@ from typing import Any
logger = logging.getLogger(__name__)
_CACHE_TTL = 300 # seconds
_cache: tuple[dict, float] | None = None # (raw_data, fetched_at monotonic)
# Per-URL cache: url → (raw_data, fetched_at monotonic)
_url_cache: dict[str, tuple[dict, float]] = {}
class CatalogPlugin:
@@ -34,44 +35,59 @@ class CatalogPlugin:
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
async def _fetch_one(url: str, force: bool = False) -> list[CatalogPlugin]:
"""Fetch a single index URL, using per-URL cache."""
import httpx
import yaml
now = time.monotonic()
if not force and _cache is not None:
raw, fetched_at = _cache
if not force and url in _url_cache:
raw, fetched_at = _url_cache[url]
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 = await client.get(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", [])),
)
_url_cache[url] = (raw, now)
logger.info("Plugin catalog fetched from %s: %d plugin(s)", 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")
logger.exception("Failed to fetch plugin catalog from %s", url)
if url in _url_cache:
raw, _ = _url_cache[url]
logger.warning("Returning stale cached catalog for %s", url)
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
return []
async def fetch_catalog(
index_urls: str | list[str],
force: bool = False,
) -> list[CatalogPlugin]:
"""Fetch and merge plugin catalogs from one or more index URLs.
Results are cached per-URL for _CACHE_TTL seconds. Plugins with the same
name across repos are deduplicated the first occurrence (repo list order)
wins. Pass force=True to bypass all caches.
"""
if isinstance(index_urls, str):
index_urls = [index_urls]
seen: dict[str, CatalogPlugin] = {}
for url in index_urls:
url = url.strip()
if not url:
continue
for plugin in await _fetch_one(url, force=force):
if plugin.name not in seen:
seen[plugin.name] = plugin
return list(seen.values())
def clear_catalog_cache() -> None:
"""Invalidate the in-process catalog cache."""
global _cache
_cache = None
"""Invalidate all in-process catalog caches."""
_url_cache.clear()
@@ -1,4 +1,4 @@
# fabledscryer/core/plugin_manager.py
# roundtable/core/plugin_manager.py
from __future__ import annotations
import hashlib
import importlib
@@ -22,6 +22,48 @@ logger = logging.getLogger(__name__)
# whether to register a blueprint (new plugin) or skip it (already registered).
_LOADED_PLUGINS: set[str] = set()
# Track plugins that failed to load: name → human-readable reason.
_FAILED_PLUGINS: dict[str, str] = {}
def get_plugin_failures() -> dict[str, str]:
"""Return a copy of the failed-plugin registry (name → error message)."""
return dict(_FAILED_PLUGINS)
def _import_plugin(name: str, plugin_path: Path):
"""Load a plugin module by file path, avoiding sys.modules stdlib collisions.
Using importlib.import_module(name) fails for plugins whose names shadow
Python stdlib modules (e.g. the 'http' plugin vs stdlib's 'http' package).
This helper loads from the filesystem path directly and registers the module
under a namespaced key so relative imports within the plugin still work.
"""
import importlib.util
module_key = f"_roundtable_plugin_{name}"
if module_key in sys.modules:
return sys.modules[module_key]
init_file = plugin_path / "__init__.py"
spec = importlib.util.spec_from_file_location(
module_key,
str(init_file),
submodule_search_locations=[str(plugin_path)],
)
if spec is None or spec.loader is None:
raise ImportError(f"Could not create import spec for plugin {name!r}")
module = importlib.util.module_from_spec(spec)
module.__package__ = module_key
sys.modules[module_key] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(module_key, None)
raise
return module
def load_plugins(app: "Quart") -> None:
"""Import all enabled plugins and register their blueprints and scheduled tasks.
@@ -36,7 +78,7 @@ def load_plugins(app: "Quart") -> None:
7. Register blueprint at /plugins/<name>/ if get_blueprint() exists
8. Append get_scheduled_tasks() results to app._task_registry
"""
import fabledscryer
import roundtable
plugin_dir = Path(app.config["PLUGIN_DIR"])
plugins_cfg: dict = app.config["PLUGINS"]
@@ -52,23 +94,29 @@ def load_plugins(app: "Quart") -> None:
plugin_path = plugin_dir / name
if not plugin_path.exists():
_FAILED_PLUGINS[name] = f"Plugin directory not found: {plugin_path}"
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():
_FAILED_PLUGINS[name] = "plugin.yaml not found"
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:
except Exception as exc:
_FAILED_PLUGINS[name] = f"Failed to read plugin.yaml: {exc}"
logger.exception("Plugin %r: failed to read plugin.yaml, skipping", name)
continue
if meta.get("name") != name:
_FAILED_PLUGINS[name] = (
f"plugin.yaml name {meta.get('name')!r} does not match directory name"
)
logger.error(
"Plugin %r: plugin.yaml name=%r does not match directory name, skipping",
name, meta.get("name"),
@@ -78,33 +126,52 @@ def load_plugins(app: "Quart") -> None:
min_ver = meta.get("min_app_version")
if min_ver:
try:
if Version(fabledscryer.__version__) < Version(min_ver):
if Version(roundtable.__version__) < Version(min_ver):
_FAILED_PLUGINS[name] = (
f"Requires roundtable>={min_ver}, running {roundtable.__version__}"
)
logger.error(
"Plugin %r: requires fabledscryer>=%s but running %s, skipping",
name, min_ver, fabledscryer.__version__,
"Plugin %r: requires roundtable>=%s but running %s, skipping",
name, min_ver, roundtable.__version__,
)
continue
except Exception:
except Exception as exc:
_FAILED_PLUGINS[name] = f"Invalid min_app_version {min_ver!r}: {exc}"
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)
# Merge plugin.yaml config defaults with user overrides (non-"enabled" keys).
# If a stored value's type doesn't match the default (e.g. a list default
# was corrupted to a string in the DB), fall back to the yaml default.
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}
merged = {"enabled": True, **defaults}
for k, v in user_overrides.items():
default_v = defaults.get(k)
if default_v is not None and type(v) is not type(default_v):
logger.warning(
"Plugin %r: config key %r has wrong type (%s), using yaml default",
name, k, type(v).__name__,
)
else:
merged[k] = v
plugins_cfg[name] = merged
# Import the plugin package
# Import the plugin package (file-path load avoids stdlib name collisions)
try:
module = importlib.import_module(name)
except ImportError:
module = _import_plugin(name, plugin_path)
except Exception as exc:
_FAILED_PLUGINS[name] = f"Import error: {exc}"
logger.exception("Plugin %r: import failed, skipping", name)
continue
# Validate required exports
if not hasattr(module, "setup"):
_FAILED_PLUGINS[name] = "Missing required export: setup()"
logger.error("Plugin %r: missing required export 'setup', skipping", name)
continue
if not hasattr(module, "get_scheduled_tasks"):
_FAILED_PLUGINS[name] = "Missing required export: get_scheduled_tasks()"
logger.error(
"Plugin %r: missing required export 'get_scheduled_tasks', skipping", name
)
@@ -113,7 +180,8 @@ def load_plugins(app: "Quart") -> None:
# Call setup
try:
module.setup(app)
except Exception:
except Exception as exc:
_FAILED_PLUGINS[name] = f"setup() failed: {exc}"
logger.exception("Plugin %r: setup() raised, skipping", name)
continue
@@ -123,17 +191,22 @@ def load_plugins(app: "Quart") -> None:
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:
except Exception as exc:
_FAILED_PLUGINS[name] = f"Blueprint registration failed: {exc}"
logger.exception("Plugin %r: get_blueprint() raised", name)
continue
# 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:
except Exception as exc:
_FAILED_PLUGINS[name] = f"get_scheduled_tasks() failed: {exc}"
logger.exception("Plugin %r: get_scheduled_tasks() raised", name)
continue
_FAILED_PLUGINS.pop(name, None) # clear any stale failure from a previous reload attempt
_LOADED_PLUGINS.add(name)
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
@@ -194,7 +267,7 @@ async def download_and_install_plugin(
# 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)
# roundtable-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.
@@ -223,12 +296,18 @@ async def download_and_install_plugin(
logger.error("Plugin %r: %s", name, msg)
return False, msg
# Run migrations for the newly installed plugin
# Run migrations for the newly installed plugin.
# Include all plugin migration dirs so Alembic can resolve any previously-stamped
# revisions from other plugins already in alembic_version.
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])
from roundtable.core.migration_runner import (
run_plugin_migrations,
discover_all_plugin_migration_dirs,
)
all_dirs = discover_all_plugin_migration_dirs(plugin_dir)
run_plugin_migrations(app.config["DATABASE_URL"], all_dirs)
except Exception:
logger.exception("Plugin %r: migration failed after install", name)
return False, "Plugin extracted but migrations failed — check logs"
@@ -246,7 +325,7 @@ def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
Returns (success, message).
"""
import fabledscryer
import roundtable
if name in _LOADED_PLUGINS:
return False, "Plugin already loaded — restart required to apply updates"
@@ -273,25 +352,49 @@ def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
min_ver = meta.get("min_app_version")
if min_ver:
try:
if Version(fabledscryer.__version__) < Version(min_ver):
if Version(roundtable.__version__) < Version(min_ver):
return False, (
f"Plugin requires fabledscryer>={min_ver} "
f"but running {fabledscryer.__version__}"
f"Plugin requires roundtable>={min_ver} "
f"but running {roundtable.__version__}"
)
except Exception:
return False, f"Invalid min_app_version: {min_ver!r}"
# Merge config defaults with any stored user config
# Merge config defaults with any stored user config.
# Discard stored values whose type doesn't match the yaml default (corruption guard).
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}
merged = {"enabled": True, **defaults}
for k, v in stored_cfg.items():
if k == "enabled":
continue
default_v = defaults.get(k)
if default_v is not None and type(v) is not type(default_v):
logger.warning("Plugin %r: config key %r has wrong type, using yaml default", name, k)
else:
merged[k] = v
app.config["PLUGINS"][name] = merged
# Import
# Run migrations if the plugin has them (safe to re-run — alembic is idempotent).
# Pass ALL plugin migration dirs so Alembic can resolve any previously-stamped
# revisions from other plugins that are already in alembic_version.
mdir = plugin_path / "migrations"
if mdir.exists():
try:
from roundtable.core.migration_runner import (
run_plugin_migrations,
discover_all_plugin_migration_dirs,
)
all_dirs = discover_all_plugin_migration_dirs(plugin_dir)
run_plugin_migrations(app.config["DATABASE_URL"], all_dirs)
except Exception:
logger.exception("Plugin %r: migration failed during hot-reload", name)
return False, "Migration failed — check logs"
# Import (file-path load avoids stdlib name collisions)
try:
module = importlib.import_module(name)
except ImportError as exc:
module = _import_plugin(name, plugin_path)
except Exception as exc:
return False, f"Import failed: {exc}"
if not hasattr(module, "setup") or not hasattr(module, "get_scheduled_tasks"):
+275
View File
@@ -0,0 +1,275 @@
"""roundtable/core/reports.py
Weekly digest report: uptime, active alerts, alert events, top metrics.
Called by the scheduler (hourly check) or triggered manually from Settings.
"""
from __future__ import annotations
import asyncio
import logging
import smtplib
import ssl
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from sqlalchemy import and_, case, func, select
from roundtable.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertEvent
from roundtable.models.hosts import Host
from roundtable.models.metrics import PluginMetric
from roundtable.models.monitors import PingResult, PingStatus
logger = logging.getLogger(__name__)
_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
async def build_report_data(app) -> dict:
"""Collect all data for the weekly digest."""
now = datetime.now(timezone.utc)
cutoff_7d = now - timedelta(days=7)
cutoff_24h = now - timedelta(hours=24)
async with app.db_sessionmaker() as db:
# ── Uptime summary (7d) ───────────────────────────────────────────────
host_result = await db.execute(
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
)
hosts = host_result.scalars().all()
uptime_rows = await db.execute(
select(
PingResult.host_id,
func.count(PingResult.id).label("total"),
func.sum(case((PingResult.status == PingStatus.up, 1), else_=0)).label("up"),
)
.where(PingResult.probed_at >= cutoff_7d)
.group_by(PingResult.host_id)
)
uptime_map: dict[str, float | None] = {}
for row in uptime_rows:
pct = round(float(row.up) / float(row.total) * 100, 2) if row.total else None
uptime_map[row.host_id] = pct
uptime_summary = []
for h in hosts:
uptime_summary.append({
"name": h.name,
"pct_7d": uptime_map.get(h.id),
})
# ── Active alerts ─────────────────────────────────────────────────────
active_result = await db.execute(
select(AlertState, AlertRule)
.join(AlertRule, AlertState.rule_id == AlertRule.id)
.where(AlertState.state.in_([
AlertStateEnum.firing, AlertStateEnum.acknowledged, AlertStateEnum.pending,
]))
.order_by(AlertState.last_transition_at.desc())
)
active_alerts = [
{
"state": state.state.value.upper(),
"name": rule.name,
"resource": rule.resource_name,
"metric": rule.metric_name,
"operator": rule.operator.value,
"threshold": rule.threshold,
}
for state, rule in active_result.all()
]
# ── Alert event counts (7d) ───────────────────────────────────────────
events_result = await db.execute(
select(
func.count(case((AlertEvent.to_state == "firing", 1), else_=None)).label("fired"),
func.count(case((AlertEvent.to_state == "resolved", 1), else_=None)).label("resolved"),
)
.where(AlertEvent.transitioned_at >= cutoff_7d)
)
event_row = events_result.one()
alert_events = {
"fired": int(event_row.fired or 0),
"resolved": int(event_row.resolved or 0),
}
# ── Top Traefik routers by avg request_rate (last 24h) ────────────────
top_routers_result = await db.execute(
select(
PluginMetric.resource_name,
func.avg(PluginMetric.value).label("avg_rate"),
)
.where(
and_(
PluginMetric.source_module == "traefik",
PluginMetric.metric_name == "request_rate",
PluginMetric.recorded_at >= cutoff_24h,
)
)
.group_by(PluginMetric.resource_name)
.order_by(func.avg(PluginMetric.value).desc())
.limit(10)
)
top_routers = [
{"name": row.resource_name, "avg_rate": float(row.avg_rate)}
for row in top_routers_result
]
# ── Hosts currently down ──────────────────────────────────────────────
latest_ping_subq = (
select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at"))
.group_by(PingResult.host_id)
.subquery()
)
down_result = await db.execute(
select(Host.name)
.join(PingResult, PingResult.host_id == Host.id)
.join(
latest_ping_subq,
and_(
PingResult.host_id == latest_ping_subq.c.host_id,
PingResult.probed_at == latest_ping_subq.c.max_at,
),
)
.where(
Host.ping_enabled.is_(True),
PingResult.status == PingStatus.down,
)
.order_by(Host.name)
)
hosts_down = [row.name for row in down_result]
return {
"generated_at": now,
"uptime_summary": uptime_summary,
"active_alerts": active_alerts,
"alert_events": alert_events,
"top_routers": top_routers,
"hosts_down": hosts_down,
}
def _render_report_text(data: dict) -> str:
now = data["generated_at"]
lines: list[str] = []
lines.append("Roundtable — Weekly Report")
lines.append(f"Generated: {now.strftime('%Y-%m-%d %H:%M UTC')}")
lines.append("")
# ── Hosts currently down ──────────────────────────────────────────────────
down = data["hosts_down"]
if down:
lines.append(f"{len(down)} host(s) currently DOWN: {', '.join(down)}")
lines.append("")
# ── Uptime summary ────────────────────────────────────────────────────────
lines.append("UPTIME SUMMARY (7-day)")
lines.append("" * 40)
for entry in data["uptime_summary"]:
pct = entry["pct_7d"]
pct_str = f"{pct:.2f}%" if pct is not None else "no data"
lines.append(f" {entry['name']:<32} {pct_str}")
if not data["uptime_summary"]:
lines.append(" (no ping-enabled hosts)")
lines.append("")
# ── Active alerts ─────────────────────────────────────────────────────────
alerts = data["active_alerts"]
lines.append(f"ACTIVE ALERTS ({len(alerts)})")
lines.append("" * 40)
if alerts:
for a in alerts:
lines.append(
f" [{a['state']}] {a['name']}{a['resource']}:"
f"{a['metric']} {a['operator']} {a['threshold']}"
)
else:
lines.append(" None — all clear.")
lines.append("")
# ── Alert events this week ────────────────────────────────────────────────
ev = data["alert_events"]
lines.append("ALERT EVENTS THIS WEEK")
lines.append("" * 40)
lines.append(f" Fired: {ev['fired']}")
lines.append(f" Resolved: {ev['resolved']}")
lines.append("")
# ── Top Traefik routers ───────────────────────────────────────────────────
if data["top_routers"]:
lines.append("TOP TRAEFIK ROUTERS (avg req/s, last 24h)")
lines.append("" * 40)
for r in data["top_routers"]:
lines.append(f" {r['name']:<40} {r['avg_rate']:.2f} req/s")
lines.append("")
lines.append("" * 40)
lines.append("Roundtable — https://git.fabledsword.com/bvandeusen/Roundtable")
return "\n".join(lines)
async def send_report(app) -> tuple[bool, str]:
"""Build and email the weekly digest. Returns (success, message)."""
smtp_cfg = app.config.get("SMTP", {})
if not smtp_cfg.get("host") or not smtp_cfg.get("recipients"):
return False, "SMTP not configured — set host and recipients in Settings → Notifications."
try:
data = await build_report_data(app)
except Exception as exc:
logger.exception("Failed to build report data")
return False, f"Data collection failed: {exc}"
body = _render_report_text(data)
date_str = data["generated_at"].strftime("%Y-%m-%d")
subject = f"[Roundtable] Weekly Report — {date_str}"
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = smtp_cfg.get("username", "roundtable@localhost")
msg["To"] = ", ".join(smtp_cfg["recipients"])
msg.set_content(body)
try:
loop = asyncio.get_running_loop()
from roundtable.core.notifications import _smtp_send_sync
await loop.run_in_executor(None, _smtp_send_sync, smtp_cfg, msg)
logger.info("Weekly report sent to %s", smtp_cfg["recipients"])
return True, f"Report sent to {', '.join(smtp_cfg['recipients'])}."
except Exception as exc:
logger.error("Failed to send weekly report: %s", exc)
return False, f"Send failed: {exc}"
async def check_and_send(app) -> None:
"""Hourly scheduler hook: send if day/hour match and not sent recently."""
from roundtable.core.settings import get_setting, set_setting
async with app.db_sessionmaker() as db:
enabled = await get_setting(db, "reports.enabled")
if not enabled:
return
schedule_day = int(await get_setting(db, "reports.schedule_day") or 6)
schedule_hour = int(await get_setting(db, "reports.schedule_hour") or 8)
last_sent_str = await get_setting(db, "reports.last_sent_at") or ""
now = datetime.now(timezone.utc)
if now.weekday() != schedule_day or now.hour != schedule_hour:
return
if last_sent_str:
try:
last_sent = datetime.fromisoformat(last_sent_str)
if (now - last_sent).total_seconds() < 23 * 3600:
return # already sent this window
except ValueError:
pass
ok, msg = await send_report(app)
if ok:
async with app.db_sessionmaker() as db:
async with db.begin():
await set_setting(db, "reports.last_sent_at", now.isoformat())
else:
logger.warning("Weekly report send failed: %s", msg)
@@ -1,15 +1,15 @@
# fabledscryer/core/settings.py
# roundtable/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 fabledscryer.core.settings import load_settings_sync
from roundtable.core.settings import load_settings_sync
settings = load_settings_sync(db_url)
Usage at runtime (inside async handlers):
from fabledscryer.core.settings import get_setting, set_setting
from roundtable.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 fabledscryer.models.settings import AppSetting
from roundtable.models.settings import AppSetting
logger = logging.getLogger(__name__)
@@ -50,7 +50,36 @@ DEFAULTS: dict[str, Any] = {
"ansible.sources": [],
"ping.threshold.good_ms": 50,
"ping.threshold.warn_ms": 200,
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/raw/branch/main/index.yaml",
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/raw/branch/main/index.yaml",
# OIDC single-sign-on
"oidc.enabled": False,
"oidc.discovery_url": "",
"oidc.client_id": "",
"oidc.client_secret": "",
"oidc.scopes": "openid profile email",
"oidc.username_claim": "preferred_username",
"oidc.email_claim": "email",
"oidc.groups_claim": "groups",
"oidc.admin_group": "",
"oidc.operator_group": "",
# LDAP authentication
"ldap.enabled": False,
"ldap.host": "",
"ldap.port": 389,
"ldap.tls": False,
"ldap.bind_dn": "",
"ldap.bind_password": "",
"ldap.base_dn": "",
"ldap.user_filter": "(uid={username})",
"ldap.admin_group_dn": "",
"ldap.operator_group_dn": "",
"ldap.attr_username": "uid",
"ldap.attr_email": "mail",
# Scheduled reports
"reports.enabled": False,
"reports.schedule_day": 6, # 0=Monday … 6=Sunday
"reports.schedule_hour": 8, # UTC hour
"reports.last_sent_at": "",
}
@@ -123,6 +152,14 @@ def to_ansible_cfg(settings: dict[str, Any]) -> dict:
return {"sources": settings.get("ansible.sources", [])}
def to_oidc_cfg(settings: dict[str, Any]) -> dict:
return {k[len("oidc."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("oidc.")}
def to_ldap_cfg(settings: dict[str, Any]) -> dict:
return {k[len("ldap."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("ldap.")}
def to_plugins_cfg(settings: dict[str, Any]) -> dict:
"""Assemble {plugin_name: {...config}} from all plugin.* keys."""
result = {}
@@ -1,4 +1,4 @@
# fabledscryer/core/time_range.py
# roundtable/core/time_range.py
"""Shared time-range utilities for scoping queries and sparkline data."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
+298
View File
@@ -0,0 +1,298 @@
# roundtable/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.
#
# "params" is a list of configurable parameters for the widget. Each param:
# key - form field name (stored in config_json)
# label - human-readable label
# type - "select" (only type currently supported)
# default - default value (int or str determines how it's parsed from the form)
# options - list of {value, label} dicts
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,
"poll": True,
"params": [],
},
"uptime_summary": {
"key": "uptime_summary",
"label": "Uptime / SLA",
"description": "Per-host rolling uptime % for 24h, 7d, and 30d windows",
"hx_url": "/hosts/uptime/widget",
"detail_url": "/hosts/uptime",
"plugin": None,
"poll": False,
"params": [],
},
"dns": {
"key": "dns",
"label": "DNS",
"description": "DNS resolution status for all monitored hosts",
"hx_url": "/dns/rows",
"detail_url": "/dns/",
"plugin": None,
"poll": True,
"params": [],
},
"traefik_routers": {
"key": "traefik_routers",
"label": "Traefik — Routers",
"description": "Top routers by request rate with error highlights and expiring certs",
"hx_url": "/plugins/traefik/widget",
"detail_url": "/plugins/traefik/",
"plugin": "traefik",
"poll": True,
"params": [],
},
"traefik_access_log": {
"key": "traefik_access_log",
"label": "Traefik — Access Log",
"description": "Traffic origin summary — top IPs, countries, and request distribution",
"hx_url": "/plugins/traefik/widget/access_log",
"detail_url": "/plugins/traefik/",
"plugin": "traefik",
"poll": True,
"params": [
{
"key": "ip_filter",
"label": "IP Filter",
"type": "select",
"default": "all",
"options": [
{"value": "all", "label": "All traffic"},
{"value": "internal", "label": "Internal only"},
{"value": "external", "label": "External only"},
],
},
{
"key": "limit",
"label": "Top IPs shown",
"type": "select",
"default": 10,
"options": [
{"value": 10, "label": "10 entries"},
{"value": 20, "label": "20 entries"},
{"value": 50, "label": "50 entries"},
],
},
],
},
"traefik_request_chart": {
"key": "traefik_request_chart",
"label": "Traefik — Request Chart",
"description": "Request rate and error percentage over time (Chart.js)",
"hx_url": "/plugins/traefik/widget/request_chart",
"detail_url": "/plugins/traefik/",
"plugin": "traefik",
"poll": True,
"params": [
{
"key": "hours",
"label": "Time range",
"type": "select",
"default": 6,
"options": [
{"value": 1, "label": "Last 1 hour"},
{"value": 6, "label": "Last 6 hours"},
{"value": 24, "label": "Last 24 hours"},
],
},
],
},
"unifi_overview": {
"key": "unifi_overview",
"label": "UniFi — Overview",
"description": "WAN status, client counts, offline devices, and active alarms",
"hx_url": "/plugins/unifi/widget",
"detail_url": "/plugins/unifi/",
"plugin": "unifi",
"poll": True,
"params": [],
},
"unifi_clients": {
"key": "unifi_clients",
"label": "UniFi — Top Clients",
"description": "Active wireless and wired clients sorted by bandwidth",
"hx_url": "/plugins/unifi/widget/clients",
"detail_url": "/plugins/unifi/",
"plugin": "unifi",
"poll": True,
"params": [
{
"key": "limit",
"label": "Clients shown",
"type": "select",
"default": 5,
"options": [
{"value": 5, "label": "5 clients"},
{"value": 10, "label": "10 clients"},
{"value": 20, "label": "20 clients"},
],
},
],
},
"unifi_devices": {
"key": "unifi_devices",
"label": "UniFi — Devices",
"description": "Network infrastructure devices with online/offline state",
"hx_url": "/plugins/unifi/widget/devices",
"detail_url": "/plugins/unifi/",
"plugin": "unifi",
"poll": True,
"params": [
{
"key": "type_filter",
"label": "Device type",
"type": "select",
"default": "all",
"options": [
{"value": "all", "label": "All devices"},
{"value": "wireless", "label": "Wireless APs"},
{"value": "wired", "label": "Wired only"},
{"value": "offline", "label": "Offline devices"},
],
},
],
},
"docker_containers": {
"key": "docker_containers",
"label": "Docker — Containers",
"description": "Container status overview — running/stopped counts and per-container CPU",
"hx_url": "/plugins/docker/widget",
"detail_url": "/plugins/docker/",
"plugin": "docker",
"poll": True,
"params": [
{
"key": "show_stopped",
"label": "Show stopped",
"type": "select",
"default": "no",
"options": [
{"value": "no", "label": "Running only"},
{"value": "yes", "label": "All containers"},
],
},
],
},
"http_monitors": {
"key": "http_monitors",
"label": "HTTP Monitors",
"description": "Synthetic endpoint checks — up/down status and response times",
"hx_url": "/plugins/http/widget",
"detail_url": "/plugins/http/",
"plugin": "http",
"poll": True,
"params": [
{
"key": "show_down_only",
"label": "Display filter",
"type": "select",
"default": "no",
"options": [
{"value": "no", "label": "All monitors"},
{"value": "yes", "label": "Failing only"},
],
},
{
"key": "limit",
"label": "Max shown",
"type": "select",
"default": 10,
"options": [
{"value": 5, "label": "5 monitors"},
{"value": 10, "label": "10 monitors"},
{"value": 20, "label": "20 monitors"},
],
},
],
},
"docker_resources": {
"key": "docker_resources",
"label": "Docker — Resources",
"description": "CPU and memory usage bars for running containers",
"hx_url": "/plugins/docker/widget/resources",
"detail_url": "/plugins/docker/",
"plugin": "docker",
"poll": True,
"params": [
{
"key": "limit",
"label": "Containers shown",
"type": "select",
"default": 10,
"options": [
{"value": 5, "label": "5 containers"},
{"value": 10, "label": "10 containers"},
{"value": 20, "label": "20 containers"},
],
},
],
},
"snmp_devices": {
"key": "snmp_devices",
"label": "SNMP — Devices",
"description": "Latest OID readings for all configured SNMP devices",
"hx_url": "/plugins/snmp/widget",
"detail_url": "/plugins/snmp/",
"plugin": "snmp",
"poll": True,
"params": [],
},
"host_resources": {
"key": "host_resources",
"label": "Host Agent — Resources",
"description": "Fleet view: CPU, memory, disk, and load per monitored host",
"hx_url": "/plugins/host_agent/widget",
"detail_url": "/plugins/host_agent/settings/",
"plugin": "host_agent",
"poll": True,
"params": [],
},
"host_resource_history": {
"key": "host_resource_history",
"label": "Host Agent — History",
"description": "CPU/memory/disk history for one host",
"hx_url": "/plugins/host_agent/widget/history",
"detail_url": "/plugins/host_agent/settings/",
"plugin": "host_agent",
"poll": True,
"params": [
{
"key": "hours",
"label": "Time range",
"type": "select",
"default": 6,
"options": [
{"value": 1, "label": "Last 1 hour"},
{"value": 6, "label": "Last 6 hours"},
{"value": 24, "label": "Last 24 hours"},
],
},
],
},
}
def register_widget(definition: dict) -> None:
"""Register a widget at runtime (used by external plugins)."""
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
@@ -1,15 +1,17 @@
from __future__ import annotations
import hashlib
import json
import secrets
from datetime import datetime, timezone, timedelta
from urllib.parse import urlencode
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
from roundtable.auth.middleware import require_role, current_user_id
from roundtable.models.users import UserRole
from roundtable.models.hosts import Host
from roundtable.models.monitors import PingResult, DnsResult
from roundtable.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken
from roundtable.core.widgets import get_available_widgets, WIDGET_REGISTRY
dashboard_bp = Blueprint("dashboard", __name__)
@@ -146,7 +148,22 @@ def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]:
continue
if defn["plugin"] and not plugins_cfg.get(defn["plugin"], {}).get("enabled", False):
continue
out.append({"db": w, "defn": defn})
# Build stored config (falls back to empty; defaults live in the registry)
config: dict = {}
if w.config_json:
try:
config = json.loads(w.config_json)
except (ValueError, TypeError):
pass
# Always append wid so widget templates can create unique canvas IDs
url_params = {**config, "wid": w.id}
hx_url = defn["hx_url"] + "?" + urlencode(url_params)
out.append({
"db": w,
"defn": defn,
"hx_url": hx_url,
"title": w.title or defn["label"],
})
return out
@@ -160,13 +177,12 @@ async def _get_panels_data(db, dashboard_id: int) -> tuple:
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]
# Multiple instances of the same widget type are allowed — show all available
resolved = [
{"db": w, "defn": WIDGET_REGISTRY[w.widget_key]}
{"db": w, "defn": WIDGET_REGISTRY[w.widget_key], "title": w.title or WIDGET_REGISTRY[w.widget_key]["label"]}
for w in widgets if w.widget_key in WIDGET_REGISTRY
]
return dash, resolved, addable
return dash, resolved, available
async def _panels_response(dashboard_id: int):
@@ -367,20 +383,36 @@ async def edit(dash_id: int):
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:
defn = WIDGET_REGISTRY.get(widget_key)
if defn is None:
abort(400)
form = await request.form
title = form.get("title", "").strip() or None
# Parse configured params
config: dict = {}
for p in defn.get("params", []):
val = form.get(f"param_{p['key']}")
if val is not None:
if isinstance(p["default"], int):
try:
config[p["key"]] = int(val)
except ValueError:
config[p["key"]] = p["default"]
else:
config[p["key"]] = val
config_json = json.dumps(config) if config else None
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,
))
widgets = await _get_widgets(db, dash_id)
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,
title=title, config_json=config_json,
))
return await _panels_response(dash_id)
@@ -405,28 +437,27 @@ async def remove_widget(dash_id: int, widget_id: int):
return await _panels_response(dash_id)
@dashboard_bp.post("/d/<int:dash_id>/edit/move/<int:widget_id>/<direction>")
@dashboard_bp.post("/d/<int:dash_id>/edit/reorder")
@require_role(UserRole.viewer)
async def move_widget(dash_id: int, widget_id: int, direction: str):
async def reorder_widgets(dash_id: int):
user_id = current_user_id()
user_role = session.get("user_role", "viewer")
if direction not in ("up", "down"):
data = await request.get_json(silent=True)
if not data or not isinstance(data.get("order"), list):
abort(400)
order: list[int] = data["order"]
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)
widgets = await _get_widgets(db, dash_id)
widget_map = {w.id: w for w in widgets}
for pos, wid in enumerate(order):
if wid in widget_map:
widget_map[wid].position = pos
return ("", 204)
# ── Share tokens ──────────────────────────────────────────────────────────────
@@ -1,10 +1,10 @@
from __future__ import annotations
from quart import Blueprint, current_app, render_template
from sqlalchemy import select, func
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
from roundtable.auth.middleware import require_role
from roundtable.models.users import UserRole
from roundtable.models.hosts import Host
from roundtable.models.monitors import DnsResult
dns_bp = Blueprint("dns", __name__, url_prefix="/dns")
@@ -1,14 +1,62 @@
from __future__ import annotations
from quart import Blueprint, render_template, request, redirect, url_for, current_app
from sqlalchemy import select, func
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
from datetime import datetime, timedelta, timezone
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
from sqlalchemy import and_, case, select, func
from roundtable.auth.middleware import require_role
from roundtable.core.audit import log_audit
from roundtable.models.hosts import Host, ProbeType
from roundtable.models.monitors import PingResult, DnsResult, PingStatus
from roundtable.models.users import UserRole
hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts")
async def _compute_uptime(db) -> dict[str, dict]:
"""Return per-host uptime % for 24h, 7d, 30d windows.
Returns {host_id: {"24h": float|None, "7d": float|None, "30d": float|None}}.
"""
now = datetime.now(timezone.utc)
cutoff_30d = now - timedelta(days=30)
cutoff_7d = now - timedelta(days=7)
cutoff_24h = now - timedelta(hours=24)
result = await db.execute(
select(
PingResult.host_id,
# 30d window — all rows in query qualify
func.count(PingResult.id).label("total_30d"),
func.sum(case((PingResult.status == PingStatus.up, 1), else_=0)).label("up_30d"),
# 7d sub-window
func.sum(case((PingResult.probed_at >= cutoff_7d, 1), else_=0)).label("total_7d"),
func.sum(case(
(and_(PingResult.probed_at >= cutoff_7d, PingResult.status == PingStatus.up), 1),
else_=0,
)).label("up_7d"),
# 24h sub-window
func.sum(case((PingResult.probed_at >= cutoff_24h, 1), else_=0)).label("total_24h"),
func.sum(case(
(and_(PingResult.probed_at >= cutoff_24h, PingResult.status == PingStatus.up), 1),
else_=0,
)).label("up_24h"),
)
.where(PingResult.probed_at >= cutoff_30d)
.group_by(PingResult.host_id)
)
def _pct(up, total):
return round(float(up) / float(total) * 100, 2) if total else None
stats: dict[str, dict] = {}
for row in result:
stats[row.host_id] = {
"24h": _pct(row.up_24h, row.total_24h),
"7d": _pct(row.up_7d, row.total_7d),
"30d": _pct(row.up_30d, row.total_30d),
}
return stats
@hosts_bp.get("/")
@require_role(UserRole.viewer)
async def list_hosts():
@@ -45,12 +93,14 @@ async def list_hosts():
)
)
latest_dns = {r.host_id: r for r in dr.scalars()}
uptime = await _compute_uptime(db)
return await render_template(
"hosts/list.html",
hosts=hosts,
latest_pings=latest_pings,
latest_dns=latest_dns,
uptime=uptime,
)
@@ -77,6 +127,9 @@ async def create_host():
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(host)
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"host.created", entity_type="host", entity_id=host.name,
detail={"address": host.address})
return redirect(url_for("hosts.list_hosts"))
@@ -115,10 +168,43 @@ async def update_host(host_id: str):
@hosts_bp.post("/<host_id>/delete")
@require_role(UserRole.admin)
async def delete_host(host_id: str):
host_name = None
async with current_app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(select(Host).where(Host.id == host_id))
host = result.scalar_one_or_none()
if host:
host_name = host.name
await db.delete(host)
if host_name:
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"host.deleted", entity_type="host", entity_id=host_name)
return redirect(url_for("hosts.list_hosts"))
@hosts_bp.get("/uptime")
@require_role(UserRole.viewer)
async def uptime_page():
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all()
uptime = await _compute_uptime(db)
return await render_template("hosts/uptime.html", hosts=hosts, uptime=uptime)
@hosts_bp.get("/uptime/widget")
@require_role(UserRole.viewer)
async def uptime_widget():
share_token = request.args.get("s")
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()
uptime = await _compute_uptime(db)
return await render_template(
"hosts/uptime_widget.html",
hosts=hosts,
uptime=uptime,
share_token=share_token,
)
@@ -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 fabledscryer.models.base import Base
import fabledscryer.models # noqa: F401 — registers all models in metadata
from roundtable.models.base import Base
import roundtable.models # noqa: F401 — registers all models in metadata
config = context.config
if config.config_file_name is not None:
@@ -16,18 +16,20 @@ target_metadata = Base.metadata
def get_url() -> str:
import os, yaml
cfg_path = os.environ.get("FABLEDSCRYER_CONFIG", "config.yaml")
def _env(suffix: str) -> str | None:
return os.environ.get(f"ROUNDTABLE_{suffix}") or os.environ.get(
f"FABLEDSCRYER_{suffix}"
)
cfg_path = _env("CONFIG") or "config.yaml"
try:
with open(cfg_path) as f:
cfg = yaml.safe_load(f) or {}
url = cfg.get("database", {}).get("url", "")
except FileNotFoundError:
url = ""
return (
os.environ.get("FABLEDSCRYER_DATABASE_URL")
or os.environ.get("FABLEDSCRYER_DATABASE__URL")
or url
)
return _env("DATABASE_URL") or _env("DATABASE__URL") or url
def _resolved_url() -> str:
@@ -0,0 +1,26 @@
"""Widget variants — add config_json and title to dashboard_widgets
Revision ID: 0009_widget_variants
Revises: 0008_share_tokens
Create Date: 2026-03-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0009_widget_variants"
down_revision: Union[str, None] = "0008_share_tokens"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("dashboard_widgets",
sa.Column("title", sa.String(128), nullable=True))
op.add_column("dashboard_widgets",
sa.Column("config_json", sa.Text, nullable=True))
def downgrade() -> None:
op.drop_column("dashboard_widgets", "config_json")
op.drop_column("dashboard_widgets", "title")
@@ -0,0 +1,32 @@
"""Maintenance windows for alert suppression
Revision ID: 0010_maintenance_windows
Revises: 0009_widget_variants
Create Date: 2026-03-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0010_maintenance_windows"
down_revision: Union[str, None] = "0009_widget_variants"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"maintenance_windows",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("end_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("scope_source", sa.String(64), nullable=True),
sa.Column("scope_resource", sa.String(255), nullable=True),
sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
op.drop_table("maintenance_windows")
@@ -0,0 +1,35 @@
"""Audit log table
Revision ID: 0011_audit_log
Revises: 0010_maintenance_windows
Create Date: 2026-03-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0011_audit_log"
down_revision: Union[str, None] = "0010_maintenance_windows"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"audit_events",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
sa.Column("user_id", sa.String(36),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("username", sa.String(64), nullable=False, server_default="system"),
sa.Column("action", sa.String(64), nullable=False),
sa.Column("entity_type", sa.String(64), nullable=True),
sa.Column("entity_id", sa.String(255), nullable=True),
sa.Column("detail_json", sa.Text, nullable=True),
)
op.create_index("ix_audit_events_timestamp", "audit_events", ["timestamp"])
def downgrade() -> None:
op.drop_index("ix_audit_events_timestamp", "audit_events")
op.drop_table("audit_events")
@@ -62,6 +62,24 @@ class AlertState(Base):
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class MaintenanceWindow(Base):
__tablename__ = "maintenance_windows"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(128), nullable=False)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Scope — null means "all". scope_resource requires scope_source to be set.
scope_source: Mapped[str | None] = mapped_column(String(64), nullable=True)
scope_resource: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_by: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
class AlertEvent(Base):
__tablename__ = "alert_events"
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class AuditEvent(Base):
__tablename__ = "audit_events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
user_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# Denormalized so display survives user deletion
username: Mapped[str] = mapped_column(String(64), nullable=False, default="system")
action: Mapped[str] = mapped_column(String(64), nullable=False)
entity_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
entity_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -51,3 +51,5 @@ class DashboardWidget(Base):
)
widget_key: Mapped[str] = mapped_column(String(64), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
config_json: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -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 fabledscryer.models.base import Base
from roundtable.models.base import Base
class AppSetting(Base):

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