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:
+3
-3
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
[alembic]
|
||||
script_location = fabledscryer/migrations
|
||||
script_location = roundtable/migrations
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
|
||||
+3
-3
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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 % (0–100 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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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 2–4 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 13–35) 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 9–11) 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 188–213) 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 68–71) 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 42–46) 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 266–369)** — 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 1–5; 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.
|
||||
@@ -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 +0,0 @@
|
||||
__version__ = "0.1.0"
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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 +0,0 @@
|
||||
# fabledscryer/settings/__init__.py
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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__])
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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]
|
||||
|
||||
@@ -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("=")
|
||||
@@ -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"))
|
||||
@@ -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,
|
||||
@@ -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"):
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user