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
|
# Copy to .env for Docker / local development secrets
|
||||||
# These override values in config.yaml
|
# These override values in config.yaml
|
||||||
|
|
||||||
FABLEDSCRYER_SECRET_KEY=change-me
|
ROUNDTABLE_SECRET_KEY=change-me
|
||||||
FABLEDSCRYER_DATABASE__URL=postgresql+asyncpg://fabledscryer:password@localhost/fabledscryer
|
ROUNDTABLE_DATABASE__URL=postgresql+asyncpg://roundtable:password@localhost/roundtable
|
||||||
FABLEDSCRYER_SMTP__PASSWORD=
|
ROUNDTABLE_SMTP__PASSWORD=
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Planning files
|
# Planning files
|
||||||
docs/superpowers/
|
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
|
# PLUGIN_DIR in config.yaml points here at runtime
|
||||||
/plugins/
|
/plugins/
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -1,7 +1,10 @@
|
|||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
RUN DEBIAN_FRONTEND=noninteractive apt-get update \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Upgrade pip to suppress the version notice
|
# Upgrade pip to suppress the version notice
|
||||||
@@ -10,18 +13,21 @@ RUN pip install --upgrade pip
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
COPY fabledscryer/ fabledscryer/
|
COPY roundtable/ roundtable/
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
COPY alembic.ini .
|
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 \
|
RUN useradd -m -u 1000 app \
|
||||||
&& mkdir -p /data/playbook_cache /data/plugins \
|
&& mkdir -p /data/playbook_cache /data/plugins \
|
||||||
&& chown -R app:app /data /app
|
&& chown -R app:app /data /app
|
||||||
|
|
||||||
USER app
|
|
||||||
|
|
||||||
EXPOSE 5000
|
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
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Set FABLEDSCRYER_DATABASE_URL in .env
|
# Set ROUNDTABLE_DATABASE_URL in .env
|
||||||
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
@@ -41,7 +41,7 @@ pip install -e .
|
|||||||
cp config.example.yaml config.yaml
|
cp config.example.yaml config.yaml
|
||||||
# Edit config.yaml — set database.url at minimum
|
# 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.
|
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 |
|
| 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` |
|
| 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/`.
|
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]
|
[alembic]
|
||||||
script_location = fabledscryer/migrations
|
script_location = roundtable/migrations
|
||||||
prepend_sys_path = .
|
prepend_sys_path = .
|
||||||
version_path_separator = os
|
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.
|
# The only REQUIRED setting is the database URL.
|
||||||
# Set it via env var (recommended for Docker):
|
# 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)
|
# All other settings (SMTP, webhook, ansible, plugins, retention)
|
||||||
# are stored in the database and managed via the Settings UI at /settings/.
|
# 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.
|
# config.yaml is optional. Use it only if you prefer file-based bootstrap.
|
||||||
|
|
||||||
database:
|
database:
|
||||||
url: "postgresql+asyncpg://fabledscryer:password@localhost/fabledscryer"
|
url: "postgresql+asyncpg://roundtable:password@localhost/roundtable"
|
||||||
|
|
||||||
# Optional: override the auto-generated secret key.
|
# Optional: override the auto-generated secret key.
|
||||||
# If not set, a key is auto-generated on first run and saved to /data/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:
|
services:
|
||||||
app:
|
roundtable:
|
||||||
build: .
|
build: .
|
||||||
|
container_name: roundtable
|
||||||
|
image: roundtable:latest
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
volumes:
|
volumes:
|
||||||
- app_data:/data
|
- app_data:/data
|
||||||
- ./plugins:/app/plugins:ro
|
- ./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
|
- /mnt/Data/traefik/log:/var/log/traefik:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
environment:
|
environment:
|
||||||
- FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@db/fabledscryer
|
- ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@db/fabledscryer
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
+13
-13
@@ -1,12 +1,12 @@
|
|||||||
# Architecture
|
# 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
|
## 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 |
|
| Step | What happens |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -32,14 +32,14 @@ All routes are Quart Blueprints registered in `app.py`:
|
|||||||
|
|
||||||
| Prefix | Blueprint | Module |
|
| Prefix | Blueprint | Module |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/auth/` | `auth_bp` | `fabledscryer/auth/routes.py` |
|
| `/auth/` | `auth_bp` | `roundtable/auth/routes.py` |
|
||||||
| `/` | `dashboard_bp` | `fabledscryer/dashboard/routes.py` |
|
| `/` | `dashboard_bp` | `roundtable/dashboard/routes.py` |
|
||||||
| `/hosts/` | `hosts_bp` | `fabledscryer/hosts/routes.py` |
|
| `/hosts/` | `hosts_bp` | `roundtable/hosts/routes.py` |
|
||||||
| `/ping/` | `ping_bp` | `fabledscryer/ping/routes.py` |
|
| `/ping/` | `ping_bp` | `roundtable/ping/routes.py` |
|
||||||
| `/dns/` | `dns_bp` | `fabledscryer/dns/routes.py` |
|
| `/dns/` | `dns_bp` | `roundtable/dns/routes.py` |
|
||||||
| `/alerts/` | `alerts_bp` | `fabledscryer/alerts/routes.py` |
|
| `/alerts/` | `alerts_bp` | `roundtable/alerts/routes.py` |
|
||||||
| `/ansible/` | `ansible_bp` | `fabledscryer/ansible/routes.py` |
|
| `/ansible/` | `ansible_bp` | `roundtable/ansible/routes.py` |
|
||||||
| `/settings/` | `settings_bp` | `fabledscryer/settings/routes.py` |
|
| `/settings/` | `settings_bp` | `roundtable/settings/routes.py` |
|
||||||
| `/plugins/<name>/` | plugin blueprint | `plugins/<name>/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.
|
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
|
## 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
|
- `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
|
- `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:
|
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)
|
- **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:
|
Live-updating widgets use HTMX polling:
|
||||||
```html
|
```html
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ When any monitor or plugin calls `record_metric()`:
|
|||||||
4. If a state transition occurs, an `AlertEvent` row is written
|
4. If a state transition occurs, an `AlertEvent` row is written
|
||||||
5. Notification I/O is deferred outside the transaction via `asyncio.create_task()`
|
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:
|
`record_metric()` must always be called inside an active transaction:
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ Webhook is skipped if `webhook.url` is empty.
|
|||||||
|
|
||||||
## Data Models
|
## Data Models
|
||||||
|
|
||||||
Defined in `fabledscryer/models/alerts.py`:
|
Defined in `roundtable/models/alerts.py`:
|
||||||
|
|
||||||
- **`alert_rules`** — one row per configured rule
|
- **`alert_rules`** — one row per configured rule
|
||||||
- **`alert_states`** — one row per rule, tracks current state and consecutive failure count
|
- **`alert_states`** — one row per rule, tracks current state and consecutive failure count
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Ansible Integration
|
# 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
|
## Data Model
|
||||||
|
|
||||||
`ansible_runs` table (defined in `fabledscryer/models/ansible.py`):
|
`ansible_runs` table (defined in `roundtable/models/ansible.py`):
|
||||||
|
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -137,7 +137,7 @@ Runs older than `data.retention_days` (default 90) are pruned by the `data_clean
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `fabledscryer/ansible/sources.py` | Source discovery, git pull logic |
|
| `roundtable/ansible/sources.py` | Source discovery, git pull logic |
|
||||||
| `fabledscryer/ansible/executor.py` | Subprocess execution and output streaming |
|
| `roundtable/ansible/executor.py` | Subprocess execution and output streaming |
|
||||||
| `fabledscryer/ansible/routes.py` | HTTP routes (browse, trigger, stream, history) |
|
| `roundtable/ansible/routes.py` | HTTP routes (browse, trigger, stream, history) |
|
||||||
| `fabledscryer/models/ansible.py` | `AnsibleRun` model |
|
| `roundtable/models/ansible.py` | `AnsibleRun` model |
|
||||||
|
|||||||
+12
-10
@@ -1,6 +1,6 @@
|
|||||||
# Configuration
|
# 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 |
|
| Key | Env var | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `database.url` | `FABLEDSCRYER_DATABASE_URL` | — | PostgreSQL async URL. **Required.** |
|
| `database.url` | `ROUNDTABLE_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. |
|
| `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` | `FABLEDSCRYER_PLUGIN_DIR` | `plugins` | Path to the plugins directory. |
|
| `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
|
### Minimal config.yaml
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
database:
|
database:
|
||||||
url: "postgresql+asyncpg://user:password@localhost/fabledscryer"
|
url: "postgresql+asyncpg://user:password@localhost/roundtable"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Minimal env-only setup (Docker)
|
### Minimal env-only setup (Docker)
|
||||||
|
|
||||||
```bash
|
```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.
|
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)
|
## App Settings (Database-backed)
|
||||||
@@ -66,7 +68,7 @@ Default webhook template:
|
|||||||
### Reading and Writing Settings in Code
|
### Reading and Writing Settings in Code
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fabledscryer.core.settings import get_setting, set_setting
|
from roundtable.core.settings import get_setting, set_setting
|
||||||
|
|
||||||
# Read
|
# Read
|
||||||
async with current_app.db_sessionmaker() as session:
|
async with current_app.db_sessionmaker() as session:
|
||||||
@@ -82,7 +84,7 @@ async with current_app.db_sessionmaker() as session:
|
|||||||
|
|
||||||
### The DEFAULTS Dict
|
### 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
|
# 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
|
## Ping Monitor
|
||||||
|
|
||||||
**Source:** `fabledscryer/monitors/ping.py`
|
**Source:** `roundtable/monitors/ping.py`
|
||||||
**Scheduler task:** `ping_monitor` in `fabledscryer/app.py`
|
**Scheduler task:** `ping_monitor` in `roundtable/app.py`
|
||||||
**Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup
|
**Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
@@ -29,7 +29,7 @@ Each probe writes a `PingResult` row and calls `record_metric()`:
|
|||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
`ping_results` table (defined in `fabledscryer/models/monitors.py`):
|
`ping_results` table (defined in `roundtable/models/monitors.py`):
|
||||||
|
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -51,8 +51,8 @@ Old rows are pruned by the `data_cleanup` task (default: 90 days).
|
|||||||
|
|
||||||
## DNS Monitor
|
## DNS Monitor
|
||||||
|
|
||||||
**Source:** `fabledscryer/monitors/dns.py`
|
**Source:** `roundtable/monitors/dns.py`
|
||||||
**Scheduler task:** `dns_monitor` in `fabledscryer/app.py`
|
**Scheduler task:** `dns_monitor` in `roundtable/app.py`
|
||||||
**Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup
|
**Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
@@ -70,7 +70,7 @@ Each check writes a `DnsResult` row and calls `record_metric()`:
|
|||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
`dns_results` table (defined in `fabledscryer/models/monitors.py`):
|
`dns_results` table (defined in `roundtable/models/monitors.py`):
|
||||||
|
|
||||||
| Column | Type | Description |
|
| 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.
|
# It is fetched by the app's Settings → Plugins → Plugin Catalog UI.
|
||||||
#
|
#
|
||||||
# Fabled Scryer reads this file from:
|
# Roundtable reads this file from:
|
||||||
# https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/raw/branch/main/index.yaml
|
# 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
|
# 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).
|
# live immediately for anyone whose app fetches the catalog (cache TTL: 5 min).
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
#
|
#
|
||||||
# Download URL conventions:
|
# Download URL conventions:
|
||||||
# Gitea release assets (recommended):
|
# 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.
|
# 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).
|
# Source archive tarballs work too but release assets are preferred (smaller, plugin-only).
|
||||||
|
|
||||||
@@ -34,15 +34,46 @@ updated: "2026-03-22"
|
|||||||
|
|
||||||
plugins:
|
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
|
- name: traefik
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
description: "Traefik reverse proxy metrics and access log integration"
|
description: "Traefik reverse proxy metrics and access log integration"
|
||||||
author: "FabledScryer"
|
author: "Roundtable"
|
||||||
license: "MIT"
|
license: "MIT"
|
||||||
min_app_version: "0.1.0"
|
min_app_version: "0.1.0"
|
||||||
repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins"
|
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
|
||||||
homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/traefik"
|
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/traefik"
|
||||||
download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/traefik-v1.0.0/traefik.zip"
|
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
|
checksum_sha256: "" # fill in after running: sha256sum traefik.zip
|
||||||
tags:
|
tags:
|
||||||
- proxy
|
- proxy
|
||||||
@@ -52,29 +83,31 @@ plugins:
|
|||||||
- name: unifi
|
- name: unifi
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
description: "UniFi Network controller integration — WAN health, devices, clients, DPI"
|
description: "UniFi Network controller integration — WAN health, devices, clients, DPI"
|
||||||
author: "FabledScryer"
|
author: "Roundtable"
|
||||||
license: "MIT"
|
license: "MIT"
|
||||||
min_app_version: "0.1.0"
|
min_app_version: "0.1.0"
|
||||||
repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins"
|
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
|
||||||
homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/unifi"
|
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/unifi"
|
||||||
download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/unifi-v1.0.0/unifi.zip"
|
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/unifi-v1.0.0/unifi.zip"
|
||||||
checksum_sha256: ""
|
checksum_sha256: ""
|
||||||
tags:
|
tags:
|
||||||
- network
|
- network
|
||||||
- unifi
|
- unifi
|
||||||
- ubiquiti
|
- ubiquiti
|
||||||
|
|
||||||
- name: ups
|
- name: host_agent
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
description: "UPS monitoring via NUT (Network UPS Tools) with Ansible shutdown automation"
|
description: "Remote Linux host resource monitoring via a lightweight Python push agent"
|
||||||
author: "FabledScryer"
|
author: "Roundtable"
|
||||||
license: "MIT"
|
license: "MIT"
|
||||||
min_app_version: "0.1.0"
|
min_app_version: "0.1.0"
|
||||||
repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins"
|
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
|
||||||
homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/ups"
|
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/host_agent"
|
||||||
download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/ups-v1.0.0/ups.zip"
|
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/host_agent-v1.0.0/host_agent.zip"
|
||||||
checksum_sha256: ""
|
checksum_sha256: ""
|
||||||
tags:
|
tags:
|
||||||
- ups
|
- host
|
||||||
- power
|
- monitoring
|
||||||
- nut
|
- cpu
|
||||||
|
- memory
|
||||||
|
- storage
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Plugin System Overview
|
# 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
|
## 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:
|
For each plugin listed as `enabled: true` in the `PLUGINS` config:
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ description: "Does a thing"
|
|||||||
|
|
||||||
# Optional
|
# Optional
|
||||||
author: "Your Name"
|
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
|
# Default config — merged with user overrides at runtime
|
||||||
# Access at runtime via: app.config["PLUGINS"]["myplugin"]["my_setting"]
|
# 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.
|
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
|
```python
|
||||||
from fabledscryer.core.scheduler import ScheduledTask
|
from roundtable.core.scheduler import ScheduledTask
|
||||||
|
|
||||||
def get_scheduled_tasks():
|
def get_scheduled_tasks():
|
||||||
app = _app
|
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
|
1. Adding a `GET /widget` route to its blueprint that returns an HTMX HTML fragment
|
||||||
2. The dashboard template polling that endpoint with HTMX
|
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)
|
## 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
|
```python
|
||||||
# plugins/myplugin/models.py
|
# plugins/myplugin/models.py
|
||||||
@@ -55,7 +55,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import String, Float, DateTime
|
from sqlalchemy import String, Float, DateTime
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from fabledscryer.models.base import Base
|
from roundtable.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
class MyPluginMetric(Base):
|
class MyPluginMetric(Base):
|
||||||
@@ -79,7 +79,7 @@ Generate the initial migration:
|
|||||||
# From the project root
|
# From the project root
|
||||||
alembic --config alembic.ini revision \
|
alembic --config alembic.ini revision \
|
||||||
--autogenerate \
|
--autogenerate \
|
||||||
--head=fabledscryer@head \
|
--head=roundtable@head \
|
||||||
--branch-label=myplugin \
|
--branch-label=myplugin \
|
||||||
-m "myplugin initial"
|
-m "myplugin initial"
|
||||||
```
|
```
|
||||||
@@ -106,8 +106,8 @@ Keep task logic in a separate file so `__init__.py` stays clean.
|
|||||||
# plugins/myplugin/scheduler.py
|
# plugins/myplugin/scheduler.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from fabledscryer.core.scheduler import ScheduledTask
|
from roundtable.core.scheduler import ScheduledTask
|
||||||
from fabledscryer.core.alerts import record_metric
|
from roundtable.core.alerts import record_metric
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -174,8 +174,8 @@ async def _fetch_value(url: str) -> float:
|
|||||||
```python
|
```python
|
||||||
# plugins/myplugin/routes.py
|
# plugins/myplugin/routes.py
|
||||||
from quart import Blueprint, current_app, render_template
|
from quart import Blueprint, current_app, render_template
|
||||||
from fabledscryer.auth.middleware import require_role
|
from roundtable.auth.middleware import require_role
|
||||||
from fabledscryer.models.users import UserRole
|
from roundtable.models.users import UserRole
|
||||||
from .models import MyPluginMetric
|
from .models import MyPluginMetric
|
||||||
|
|
||||||
myplugin_bp = Blueprint("myplugin", __name__, template_folder="templates")
|
myplugin_bp = Blueprint("myplugin", __name__, template_folder="templates")
|
||||||
@@ -215,7 +215,7 @@ Templates live in `plugins/myplugin/templates/myplugin/` (the extra nesting avoi
|
|||||||
```html
|
```html
|
||||||
{# plugins/myplugin/templates/myplugin/index.html #}
|
{# plugins/myplugin/templates/myplugin/index.html #}
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}My Plugin — Fabled Scryer{% endblock %}
|
{% block title %}My Plugin — Roundtable{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-title">My Plugin</div>
|
<div class="page-title">My Plugin</div>
|
||||||
{% for row in rows %}
|
{% 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.
|
`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
|
```python
|
||||||
from fabledscryer.core.alerts import record_metric
|
from roundtable.core.alerts import record_metric
|
||||||
|
|
||||||
# Must be inside an active transaction
|
# Must be inside an active transaction
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
@@ -302,11 +302,11 @@ async with session.begin():
|
|||||||
|
|
||||||
## Auth in Routes
|
## Auth in Routes
|
||||||
|
|
||||||
Use the `@require_role` decorator from `fabledscryer.auth.middleware`:
|
Use the `@require_role` decorator from `roundtable.auth.middleware`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fabledscryer.auth.middleware import require_role
|
from roundtable.auth.middleware import require_role
|
||||||
from fabledscryer.models.users import UserRole
|
from roundtable.models.users import UserRole
|
||||||
|
|
||||||
@myplugin_bp.get("/admin-only")
|
@myplugin_bp.get("/admin-only")
|
||||||
@require_role(UserRole.admin)
|
@require_role(UserRole.admin)
|
||||||
@@ -325,13 +325,13 @@ Role hierarchy: `admin > operator > viewer`. Requiring `viewer` grants access to
|
|||||||
|
|
||||||
## Publishing to the Catalog
|
## 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
|
### Repo layout
|
||||||
|
|
||||||
```
|
```
|
||||||
fabledscryer-plugins/
|
roundtable-plugins/
|
||||||
├── index.yaml ← catalog index — the only file Fabled Scryer fetches
|
├── index.yaml ← catalog index — the only file Roundtable fetches
|
||||||
├── myplugin/
|
├── myplugin/
|
||||||
│ ├── plugin.yaml
|
│ ├── plugin.yaml
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
@@ -381,7 +381,7 @@ myplugin.zip
|
|||||||
Generate the zip and its checksum:
|
Generate the zip and its checksum:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd fabledscryer-plugins
|
cd roundtable-plugins
|
||||||
zip -r myplugin.zip myplugin/
|
zip -r myplugin.zip myplugin/
|
||||||
sha256sum myplugin.zip # paste this into index.yaml checksum_sha256
|
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 |
|
| What | File | Function / Class |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| App factory | `fabledscryer/app.py` | `create_app()` |
|
| App factory | `roundtable/app.py` | `create_app()` |
|
||||||
| CLI entry point | `fabledscryer/cli.py` | `main()` |
|
| CLI entry point | `roundtable/cli.py` | `main()` |
|
||||||
| Bootstrap config loading | `fabledscryer/config.py` | `load_bootstrap()` |
|
| Bootstrap config loading | `roundtable/config.py` | `load_bootstrap()` |
|
||||||
| Secret key resolution | `fabledscryer/config.py` | `_resolve_secret_key()` |
|
| Secret key resolution | `roundtable/config.py` | `_resolve_secret_key()` |
|
||||||
| DB engine init | `fabledscryer/database.py` | `init_db()` |
|
| DB engine init | `roundtable/database.py` | `init_db()` |
|
||||||
| Core migrations | `fabledscryer/core/migration_runner.py` | `run_core_migrations()` |
|
| Core migrations | `roundtable/core/migration_runner.py` | `run_core_migrations()` |
|
||||||
| Plugin migrations | `fabledscryer/core/migration_runner.py` | `run_plugin_migrations()` |
|
| Plugin migrations | `roundtable/core/migration_runner.py` | `run_plugin_migrations()` |
|
||||||
| Core task registration | `fabledscryer/app.py` | `_register_core_tasks()` |
|
| Core task registration | `roundtable/app.py` | `_register_core_tasks()` |
|
||||||
| Scheduler loop | `fabledscryer/core/scheduler.py` | `start_scheduler()` |
|
| 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 |
|
| What | File | Function |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| All defaults | `fabledscryer/core/settings.py` | `DEFAULTS` dict |
|
| All defaults | `roundtable/core/settings.py` | `DEFAULTS` dict |
|
||||||
| Read a setting | `fabledscryer/core/settings.py` | `get_setting(session, key)` |
|
| Read a setting | `roundtable/core/settings.py` | `get_setting(session, key)` |
|
||||||
| Write a setting | `fabledscryer/core/settings.py` | `set_setting(session, key, value)` |
|
| Write a setting | `roundtable/core/settings.py` | `set_setting(session, key, value)` |
|
||||||
| Read all settings | `fabledscryer/core/settings.py` | `get_all_settings(session)` |
|
| Read all settings | `roundtable/core/settings.py` | `get_all_settings(session)` |
|
||||||
| Sync load at startup | `fabledscryer/core/settings.py` | `load_settings_sync(db_url)` |
|
| Sync load at startup | `roundtable/core/settings.py` | `load_settings_sync(db_url)` |
|
||||||
| Extract SMTP dict | `fabledscryer/core/settings.py` | `to_smtp_cfg(settings)` |
|
| Extract SMTP dict | `roundtable/core/settings.py` | `to_smtp_cfg(settings)` |
|
||||||
| Extract webhook dict | `fabledscryer/core/settings.py` | `to_webhook_cfg(settings)` |
|
| Extract webhook dict | `roundtable/core/settings.py` | `to_webhook_cfg(settings)` |
|
||||||
| Extract Ansible dict | `fabledscryer/core/settings.py` | `to_ansible_cfg(settings)` |
|
| Extract Ansible dict | `roundtable/core/settings.py` | `to_ansible_cfg(settings)` |
|
||||||
| Extract plugins dict | `fabledscryer/core/settings.py` | `to_plugins_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 |
|
| What | File | Function |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Plugin loading | `fabledscryer/core/plugin_manager.py` | `load_plugins(app)` |
|
| Plugin loading | `roundtable/core/plugin_manager.py` | `load_plugins(app)` |
|
||||||
| ScheduledTask dataclass | `fabledscryer/core/scheduler.py` | `ScheduledTask` |
|
| ScheduledTask dataclass | `roundtable/core/scheduler.py` | `ScheduledTask` |
|
||||||
| Task runner | `fabledscryer/core/scheduler.py` | `start_scheduler(tasks)` |
|
| 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 |
|
| What | File | Function |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Write metric + evaluate alerts | `fabledscryer/core/alerts.py` | `record_metric(session, source_module, resource_name, metric_name, value)` |
|
| Write metric + evaluate alerts | `roundtable/core/alerts.py` | `record_metric(session, source_module, resource_name, metric_name, value)` |
|
||||||
| Init alert pipeline | `fabledscryer/core/alerts.py` | `init_alerts(app)` |
|
| Init alert pipeline | `roundtable/core/alerts.py` | `init_alerts(app)` |
|
||||||
| Rule evaluation | `fabledscryer/core/alerts.py` | `_evaluate_rule()` (internal) |
|
| Rule evaluation | `roundtable/core/alerts.py` | `_evaluate_rule()` (internal) |
|
||||||
| Notification dispatch | `fabledscryer/core/alerts.py` | `_dispatch_notification()` (internal) |
|
| Notification dispatch | `roundtable/core/alerts.py` | `_dispatch_notification()` (internal) |
|
||||||
| Email + webhook send | `fabledscryer/core/notifications.py` | `dispatch_notifications()` |
|
| 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 |
|
| What | File | Function |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Ping a host | `fabledscryer/monitors/ping.py` | `ping_check(host, session)` |
|
| Ping a host | `roundtable/monitors/ping.py` | `ping_check(host, session)` |
|
||||||
| DNS check a host | `fabledscryer/monitors/dns.py` | `dns_check(host, session)` |
|
| DNS check a host | `roundtable/monitors/dns.py` | `dns_check(host, session)` |
|
||||||
| Data cleanup | `fabledscryer/core/cleanup.py` | `run_cleanup(app)` |
|
| 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 |
|
| What | File | Function / Class |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Role-based access decorator | `fabledscryer/auth/middleware.py` | `@require_role(UserRole.X)` |
|
| Role-based access decorator | `roundtable/auth/middleware.py` | `@require_role(UserRole.X)` |
|
||||||
| Login / session handling | `fabledscryer/auth/middleware.py` | `login_user()`, `logout_user()` |
|
| Login / session handling | `roundtable/auth/middleware.py` | `login_user()`, `logout_user()` |
|
||||||
| User count (for first-run) | `fabledscryer/auth/middleware.py` | `get_user_count(app)` |
|
| 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 |
|
| Model | File | Table |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Host` | `fabledscryer/models/hosts.py` | `hosts` |
|
| `Host` | `roundtable/models/hosts.py` | `hosts` |
|
||||||
| `PingResult` | `fabledscryer/models/monitors.py` | `ping_results` |
|
| `PingResult` | `roundtable/models/monitors.py` | `ping_results` |
|
||||||
| `DnsResult` | `fabledscryer/models/monitors.py` | `dns_results` |
|
| `DnsResult` | `roundtable/models/monitors.py` | `dns_results` |
|
||||||
| `AlertRule` | `fabledscryer/models/alerts.py` | `alert_rules` |
|
| `AlertRule` | `roundtable/models/alerts.py` | `alert_rules` |
|
||||||
| `AlertState` | `fabledscryer/models/alerts.py` | `alert_states` |
|
| `AlertState` | `roundtable/models/alerts.py` | `alert_states` |
|
||||||
| `AlertEvent` | `fabledscryer/models/alerts.py` | `alert_events` |
|
| `AlertEvent` | `roundtable/models/alerts.py` | `alert_events` |
|
||||||
| `PluginMetric` | `fabledscryer/models/metrics.py` | `plugin_metrics` |
|
| `PluginMetric` | `roundtable/models/metrics.py` | `plugin_metrics` |
|
||||||
| `AnsibleRun` | `fabledscryer/models/ansible.py` | `ansible_runs` |
|
| `AnsibleRun` | `roundtable/models/ansible.py` | `ansible_runs` |
|
||||||
| `User` | `fabledscryer/models/users.py` | `users` |
|
| `User` | `roundtable/models/users.py` | `users` |
|
||||||
| `AppSetting` | `fabledscryer/models/settings.py` | `app_settings` |
|
| `AppSetting` | `roundtable/models/settings.py` | `app_settings` |
|
||||||
| `TraefikMetric` | `plugins/traefik/models.py` | `traefik_metrics` |
|
| `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 |
|
| URL pattern | Blueprint | File |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/` (dashboard) | `dashboard_bp` | `fabledscryer/dashboard/routes.py` |
|
| `/` (dashboard) | `dashboard_bp` | `roundtable/dashboard/routes.py` |
|
||||||
| `/auth/login`, `/auth/logout` | `auth_bp` | `fabledscryer/auth/routes.py` |
|
| `/auth/login`, `/auth/logout` | `auth_bp` | `roundtable/auth/routes.py` |
|
||||||
| `/hosts/` | `hosts_bp` | `fabledscryer/hosts/routes.py` |
|
| `/hosts/` | `hosts_bp` | `roundtable/hosts/routes.py` |
|
||||||
| `/ping/`, `/ping/rows`, `/ping/settings` | `ping_bp` | `fabledscryer/ping/routes.py` |
|
| `/ping/`, `/ping/rows`, `/ping/settings` | `ping_bp` | `roundtable/ping/routes.py` |
|
||||||
| `/dns/`, `/dns/rows` | `dns_bp` | `fabledscryer/dns/routes.py` |
|
| `/dns/`, `/dns/rows` | `dns_bp` | `roundtable/dns/routes.py` |
|
||||||
| `/alerts/` | `alerts_bp` | `fabledscryer/alerts/routes.py` |
|
| `/alerts/` | `alerts_bp` | `roundtable/alerts/routes.py` |
|
||||||
| `/ansible/` | `ansible_bp` | `fabledscryer/ansible/routes.py` |
|
| `/ansible/` | `ansible_bp` | `roundtable/ansible/routes.py` |
|
||||||
| `/settings/` | `settings_bp` | `fabledscryer/settings/routes.py` |
|
| `/settings/` | `settings_bp` | `roundtable/settings/routes.py` |
|
||||||
| `/plugins/traefik/`, `/plugins/traefik/widget` | `traefik_bp` | `plugins/traefik/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 |
|
| Template | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `fabledscryer/templates/base.html` | Layout, navigation, full CSS design system |
|
| `roundtable/templates/base.html` | Layout, navigation, full CSS design system |
|
||||||
| `fabledscryer/templates/dashboard/index.html` | Dashboard with stat strip and widget grid |
|
| `roundtable/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/) |
|
| `roundtable/templates/ping/rows.html` | HTMX fragment: ping pill rows (shared by dashboard and /ping/) |
|
||||||
| `fabledscryer/templates/ping/index.html` | Full /ping/ page |
|
| `roundtable/templates/ping/index.html` | Full /ping/ page |
|
||||||
| `fabledscryer/templates/dns/rows.html` | HTMX fragment: DNS status rows |
|
| `roundtable/templates/dns/rows.html` | HTMX fragment: DNS status rows |
|
||||||
| `fabledscryer/templates/dns/index.html` | Full /dns/ page |
|
| `roundtable/templates/dns/index.html` | Full /dns/ page |
|
||||||
| `plugins/traefik/templates/traefik/widget.html` | HTMX fragment: Traefik dashboard widget |
|
| `plugins/traefik/templates/traefik/widget.html` | HTMX fragment: Traefik dashboard widget |
|
||||||
| `plugins/traefik/templates/traefik/index.html` | Full Traefik detail page |
|
| `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 |
|
| 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 |
|
| `plugins/traefik/migrations/versions/` | `traefik_metrics` table |
|
||||||
| `alembic.ini` | Alembic config; `version_locations` lists all migration directories |
|
| `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"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "fabledscryer"
|
name = "roundtable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -20,17 +20,23 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
ldap = [
|
||||||
|
"ldap3>=2.9",
|
||||||
|
]
|
||||||
|
snmp = [
|
||||||
|
"pysnmp-lextudio>=6.2",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"pytest-asyncio>=0.23",
|
"pytest-asyncio>=0.23",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
fabledscryer = "fabledscryer.cli:main"
|
roundtable = "roundtable.cli:main"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[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
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
@@ -77,7 +77,7 @@ async def start_run(
|
|||||||
async def _flush(final_status: str | None = None) -> None:
|
async def _flush(final_status: str | None = None) -> None:
|
||||||
nonlocal lines_since_flush, last_flush_at
|
nonlocal lines_since_flush, last_flush_at
|
||||||
from sqlalchemy import update
|
from sqlalchemy import update
|
||||||
from fabledscryer.models.ansible import AnsibleRun, AnsibleRunStatus
|
from roundtable.models.ansible import AnsibleRun, AnsibleRunStatus
|
||||||
|
|
||||||
values: dict = {"output": db_output}
|
values: dict = {"output": db_output}
|
||||||
if final_status is not None:
|
if final_status is not None:
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# fabledscryer/ansible/routes.py
|
# roundtable/ansible/routes.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
@@ -11,10 +11,10 @@ from quart import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
from fabledscryer.ansible import executor, sources as src_module
|
from roundtable.ansible import executor, sources as src_module
|
||||||
from fabledscryer.auth.middleware import require_role
|
from roundtable.auth.middleware import require_role
|
||||||
from fabledscryer.models.ansible import AnsibleRun, AnsibleRunStatus
|
from roundtable.models.ansible import AnsibleRun, AnsibleRunStatus
|
||||||
from fabledscryer.models.users import UserRole
|
from roundtable.models.users import UserRole
|
||||||
|
|
||||||
ansible_bp = Blueprint("ansible", __name__, url_prefix="/ansible")
|
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::
|
Config structure in config.yaml::
|
||||||
|
|
||||||
ansible:
|
ansible:
|
||||||
cache_dir: /var/cache/fabledscryer/ansible
|
cache_dir: /var/cache/roundtable/ansible
|
||||||
sources:
|
sources:
|
||||||
- name: my-playbooks
|
- name: my-playbooks
|
||||||
type: local
|
type: local
|
||||||
@@ -29,7 +29,7 @@ def get_sources(ansible_cfg: dict) -> list[dict]:
|
|||||||
pull_interval_seconds: 3600
|
pull_interval_seconds: 3600
|
||||||
"""
|
"""
|
||||||
sources = ansible_cfg.get("sources", [])
|
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 = []
|
result = []
|
||||||
for src in sources:
|
for src in sources:
|
||||||
src_type = src.get("type", "local")
|
src_type = src.get("type", "local")
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# fabledscryer/app.py
|
# roundtable/app.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from quart import Quart
|
from quart import Quart, render_template
|
||||||
from .config import load_bootstrap
|
from .config import load_bootstrap
|
||||||
from .database import init_db
|
from .database import init_db
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ def create_app(
|
|||||||
if not testing:
|
if not testing:
|
||||||
from .core.settings import (
|
from .core.settings import (
|
||||||
load_settings_sync, to_smtp_cfg, to_webhook_cfg,
|
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"])
|
_settings = load_settings_sync(app.config["DATABASE_URL"])
|
||||||
app.config.update(
|
app.config.update(
|
||||||
@@ -60,6 +60,8 @@ def create_app(
|
|||||||
WEBHOOK=to_webhook_cfg(_settings),
|
WEBHOOK=to_webhook_cfg(_settings),
|
||||||
ANSIBLE=to_ansible_cfg(_settings),
|
ANSIBLE=to_ansible_cfg(_settings),
|
||||||
PLUGINS=to_plugins_cfg(_settings),
|
PLUGINS=to_plugins_cfg(_settings),
|
||||||
|
OIDC=to_oidc_cfg(_settings),
|
||||||
|
LDAP=to_ldap_cfg(_settings),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
app.config.update(
|
app.config.update(
|
||||||
@@ -70,6 +72,8 @@ def create_app(
|
|||||||
WEBHOOK={},
|
WEBHOOK={},
|
||||||
ANSIBLE={},
|
ANSIBLE={},
|
||||||
PLUGINS={},
|
PLUGINS={},
|
||||||
|
OIDC={"enabled": False},
|
||||||
|
LDAP={"enabled": False},
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── 5. Plugin migrations ───────────────────────────────────────────────────
|
# ── 5. Plugin migrations ───────────────────────────────────────────────────
|
||||||
@@ -95,6 +99,7 @@ def create_app(
|
|||||||
from .alerts.routes import alerts_bp
|
from .alerts.routes import alerts_bp
|
||||||
from .ansible.routes import ansible_bp
|
from .ansible.routes import ansible_bp
|
||||||
from .settings.routes import settings_bp
|
from .settings.routes import settings_bp
|
||||||
|
from .audit.routes import audit_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
@@ -104,6 +109,7 @@ def create_app(
|
|||||||
app.register_blueprint(alerts_bp)
|
app.register_blueprint(alerts_bp)
|
||||||
app.register_blueprint(ansible_bp)
|
app.register_blueprint(ansible_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
|
app.register_blueprint(audit_bp)
|
||||||
|
|
||||||
# ── 8. Build task registry ─────────────────────────────────────────────────
|
# ── 8. Build task registry ─────────────────────────────────────────────────
|
||||||
app._task_registry = []
|
app._task_registry = []
|
||||||
@@ -116,7 +122,13 @@ def create_app(
|
|||||||
from .core.plugin_manager import load_plugins
|
from .core.plugin_manager import load_plugins
|
||||||
load_plugins(app)
|
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
|
@app.before_request
|
||||||
async def _validate_share_token():
|
async def _validate_share_token():
|
||||||
"""If ?s=<token> is present, validate it and set g.share_viewer."""
|
"""If ?s=<token> is present, validate it and set g.share_viewer."""
|
||||||
@@ -149,6 +161,15 @@ def create_app(
|
|||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
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 ───────────────────────────────────────────────────────
|
# ── 11. Startup hook ───────────────────────────────────────────────────────
|
||||||
@app.before_serving
|
@app.before_serving
|
||||||
async def startup():
|
async def startup():
|
||||||
@@ -199,7 +220,17 @@ def _register_core_tasks(app: Quart) -> None:
|
|||||||
from .core.cleanup import run_cleanup as _cleanup
|
from .core.cleanup import run_cleanup as _cleanup
|
||||||
await _cleanup(app)
|
await _cleanup(app)
|
||||||
|
|
||||||
|
async def run_report_check():
|
||||||
|
from .core.reports import check_and_send
|
||||||
|
await check_and_send(app)
|
||||||
|
|
||||||
app._task_registry.extend([
|
app._task_registry.extend([
|
||||||
|
ScheduledTask(
|
||||||
|
name="weekly_report_check",
|
||||||
|
coro_factory=run_report_check,
|
||||||
|
interval_seconds=3600,
|
||||||
|
run_on_startup=False,
|
||||||
|
),
|
||||||
ScheduledTask(
|
ScheduledTask(
|
||||||
name="ping_monitor",
|
name="ping_monitor",
|
||||||
coro_factory=run_ping_monitors,
|
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
|
from __future__ import annotations
|
||||||
import functools
|
import functools
|
||||||
from quart import session, redirect, url_for, abort, g
|
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]
|
_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("--port", default=5000, type=int)
|
||||||
@click.option("--debug", is_flag=True, default=False)
|
@click.option("--debug", is_flag=True, default=False)
|
||||||
def main(config: str, host: str, port: int, debug: bool) -> None:
|
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 = create_app(config_path=Path(config))
|
||||||
app.run(host=host, port=port, debug=debug)
|
app.run(host=host, port=port, debug=debug)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# fabledscryer/config.py
|
# roundtable/config.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -11,6 +11,17 @@ logger = logging.getLogger(__name__)
|
|||||||
_SECRET_KEY_FILE = Path("/data/secret.key")
|
_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]:
|
def load_bootstrap(config_path: Path | str | None = None) -> dict[str, str]:
|
||||||
"""Return the minimum bootstrap config: database_url and secret_key.
|
"""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 {}
|
raw = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
database_url = (
|
database_url = (
|
||||||
os.environ.get("FABLEDSCRYER_DATABASE_URL")
|
_env("DATABASE_URL")
|
||||||
or os.environ.get("FABLEDSCRYER_DATABASE__URL")
|
or _env("DATABASE__URL")
|
||||||
or raw.get("database", {}).get("url")
|
or raw.get("database", {}).get("url")
|
||||||
)
|
)
|
||||||
if not database_url:
|
if not database_url:
|
||||||
raise ValueError(
|
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."
|
"or add 'database.url' to config.yaml."
|
||||||
)
|
)
|
||||||
|
|
||||||
secret_key = _resolve_secret_key(raw)
|
secret_key = _resolve_secret_key(raw)
|
||||||
plugin_dir = (
|
plugin_dir = (
|
||||||
os.environ.get("FABLEDSCRYER_PLUGIN_DIR")
|
_env("PLUGIN_DIR")
|
||||||
or raw.get("plugin_dir", "plugins")
|
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:
|
def _resolve_secret_key(raw: dict) -> str:
|
||||||
"""Resolve secret_key: env var → file → auto-generate."""
|
"""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:
|
if from_env:
|
||||||
return from_env
|
return from_env
|
||||||
|
|
||||||
@@ -5,17 +5,18 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from fabledscryer.models.alerts import (
|
from roundtable.models.alerts import (
|
||||||
AlertEvent,
|
AlertEvent,
|
||||||
AlertOperator,
|
AlertOperator,
|
||||||
AlertRule,
|
AlertRule,
|
||||||
AlertState,
|
AlertState,
|
||||||
AlertStateEnum,
|
AlertStateEnum,
|
||||||
|
MaintenanceWindow,
|
||||||
)
|
)
|
||||||
from fabledscryer.models.metrics import PluginMetric
|
from roundtable.models.metrics import PluginMetric
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
@@ -60,6 +61,26 @@ async def record_metric(
|
|||||||
session.add(metric)
|
session.add(metric)
|
||||||
await session.flush()
|
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
|
# Load matching enabled rules
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(AlertRule).where(
|
select(AlertRule).where(
|
||||||
@@ -205,7 +226,7 @@ async def _dispatch_notification(
|
|||||||
event_id: str,
|
event_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run after the transaction commits. Sends notifications and updates the event row."""
|
"""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 = {
|
alert_data = {
|
||||||
"rule_name": rule.name,
|
"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 sqlalchemy import delete
|
||||||
|
|
||||||
from fabledscryer.models.monitors import DnsResult, PingResult
|
from roundtable.models.monitors import DnsResult, PingResult
|
||||||
from fabledscryer.models.metrics import PluginMetric
|
from roundtable.models.metrics import PluginMetric
|
||||||
from fabledscryer.models.ansible import AnsibleRun
|
from roundtable.models.ansible import AnsibleRun
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# fabledscryer/core/migration_runner.py
|
# roundtable/core/migration_runner.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -33,8 +33,8 @@ async def dispatch_notifications(
|
|||||||
async def _send_email(cfg: dict, alert: dict) -> dict:
|
async def _send_email(cfg: dict, alert: dict) -> dict:
|
||||||
try:
|
try:
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
msg["Subject"] = f"[Fabled Scryer] {alert['state']} — {alert['rule_name']}"
|
msg["Subject"] = f"[Roundtable] {alert['state']} — {alert['rule_name']}"
|
||||||
msg["From"] = cfg.get("username", "fabledscryer@localhost")
|
msg["From"] = cfg.get("username", "roundtable@localhost")
|
||||||
msg["To"] = ", ".join(cfg["recipients"])
|
msg["To"] = ", ".join(cfg["recipients"])
|
||||||
msg.set_content(
|
msg.set_content(
|
||||||
f"Alert: {alert['state']}\n"
|
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."""
|
"""Remote plugin catalog: fetch, parse, and cache the plugin index.yaml."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
@@ -8,7 +8,8 @@ from typing import Any
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_CACHE_TTL = 300 # seconds
|
_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:
|
class CatalogPlugin:
|
||||||
@@ -34,44 +35,59 @@ class CatalogPlugin:
|
|||||||
self.tags: list[str] = data.get("tags", [])
|
self.tags: list[str] = data.get("tags", [])
|
||||||
|
|
||||||
|
|
||||||
async def fetch_catalog(index_url: str, force: bool = False) -> list[CatalogPlugin]:
|
async def _fetch_one(url: str, force: bool = False) -> list[CatalogPlugin]:
|
||||||
"""Fetch and parse the remote plugin catalog.
|
"""Fetch a single index URL, using per-URL cache."""
|
||||||
|
|
||||||
Results are cached in-process for _CACHE_TTL seconds. Pass force=True to
|
|
||||||
bypass the cache. On network failure, stale cached data is returned if
|
|
||||||
available; otherwise an empty list is returned.
|
|
||||||
"""
|
|
||||||
global _cache
|
|
||||||
import httpx
|
import httpx
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if not force and _cache is not None:
|
if not force and url in _url_cache:
|
||||||
raw, fetched_at = _cache
|
raw, fetched_at = _url_cache[url]
|
||||||
if now - fetched_at < _CACHE_TTL:
|
if now - fetched_at < _CACHE_TTL:
|
||||||
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
|
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
resp = await client.get(index_url)
|
resp = await client.get(url)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
raw = yaml.safe_load(resp.text) or {}
|
raw = yaml.safe_load(resp.text) or {}
|
||||||
_cache = (raw, now)
|
_url_cache[url] = (raw, now)
|
||||||
logger.info(
|
logger.info("Plugin catalog fetched from %s: %d plugin(s)", url, len(raw.get("plugins", [])))
|
||||||
"Plugin catalog fetched from %s: %d plugin(s)",
|
|
||||||
index_url, len(raw.get("plugins", [])),
|
|
||||||
)
|
|
||||||
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
|
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to fetch plugin catalog from %s", index_url)
|
logger.exception("Failed to fetch plugin catalog from %s", url)
|
||||||
if _cache is not None:
|
if url in _url_cache:
|
||||||
raw, _ = _cache
|
raw, _ = _url_cache[url]
|
||||||
logger.warning("Returning stale cached catalog")
|
logger.warning("Returning stale cached catalog for %s", url)
|
||||||
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
|
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
|
||||||
return []
|
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:
|
def clear_catalog_cache() -> None:
|
||||||
"""Invalidate the in-process catalog cache."""
|
"""Invalidate all in-process catalog caches."""
|
||||||
global _cache
|
_url_cache.clear()
|
||||||
_cache = None
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# fabledscryer/core/plugin_manager.py
|
# roundtable/core/plugin_manager.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import hashlib
|
import hashlib
|
||||||
import importlib
|
import importlib
|
||||||
@@ -22,6 +22,48 @@ logger = logging.getLogger(__name__)
|
|||||||
# whether to register a blueprint (new plugin) or skip it (already registered).
|
# whether to register a blueprint (new plugin) or skip it (already registered).
|
||||||
_LOADED_PLUGINS: set[str] = set()
|
_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:
|
def load_plugins(app: "Quart") -> None:
|
||||||
"""Import all enabled plugins and register their blueprints and scheduled tasks.
|
"""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
|
7. Register blueprint at /plugins/<name>/ if get_blueprint() exists
|
||||||
8. Append get_scheduled_tasks() results to app._task_registry
|
8. Append get_scheduled_tasks() results to app._task_registry
|
||||||
"""
|
"""
|
||||||
import fabledscryer
|
import roundtable
|
||||||
|
|
||||||
plugin_dir = Path(app.config["PLUGIN_DIR"])
|
plugin_dir = Path(app.config["PLUGIN_DIR"])
|
||||||
plugins_cfg: dict = app.config["PLUGINS"]
|
plugins_cfg: dict = app.config["PLUGINS"]
|
||||||
@@ -52,23 +94,29 @@ def load_plugins(app: "Quart") -> None:
|
|||||||
|
|
||||||
plugin_path = plugin_dir / name
|
plugin_path = plugin_dir / name
|
||||||
if not plugin_path.exists():
|
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)
|
logger.error("Plugin %r: directory %s not found, skipping", name, plugin_path)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Load and validate plugin.yaml
|
# Load and validate plugin.yaml
|
||||||
yaml_path = plugin_path / "plugin.yaml"
|
yaml_path = plugin_path / "plugin.yaml"
|
||||||
if not yaml_path.exists():
|
if not yaml_path.exists():
|
||||||
|
_FAILED_PLUGINS[name] = "plugin.yaml not found"
|
||||||
logger.error("Plugin %r: plugin.yaml not found, skipping", name)
|
logger.error("Plugin %r: plugin.yaml not found, skipping", name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with yaml_path.open() as f:
|
with yaml_path.open() as f:
|
||||||
meta = yaml.safe_load(f) or {}
|
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)
|
logger.exception("Plugin %r: failed to read plugin.yaml, skipping", name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if meta.get("name") != name:
|
if meta.get("name") != name:
|
||||||
|
_FAILED_PLUGINS[name] = (
|
||||||
|
f"plugin.yaml name {meta.get('name')!r} does not match directory name"
|
||||||
|
)
|
||||||
logger.error(
|
logger.error(
|
||||||
"Plugin %r: plugin.yaml name=%r does not match directory name, skipping",
|
"Plugin %r: plugin.yaml name=%r does not match directory name, skipping",
|
||||||
name, meta.get("name"),
|
name, meta.get("name"),
|
||||||
@@ -78,33 +126,52 @@ def load_plugins(app: "Quart") -> None:
|
|||||||
min_ver = meta.get("min_app_version")
|
min_ver = meta.get("min_app_version")
|
||||||
if min_ver:
|
if min_ver:
|
||||||
try:
|
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(
|
logger.error(
|
||||||
"Plugin %r: requires fabledscryer>=%s but running %s, skipping",
|
"Plugin %r: requires roundtable>=%s but running %s, skipping",
|
||||||
name, min_ver, fabledscryer.__version__,
|
name, min_ver, roundtable.__version__,
|
||||||
)
|
)
|
||||||
continue
|
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)
|
logger.exception("Plugin %r: invalid min_app_version %r, skipping", name, min_ver)
|
||||||
continue
|
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", {})
|
defaults = meta.get("config", {})
|
||||||
user_overrides = {k: v for k, v in cfg.items() if k != "enabled"}
|
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:
|
try:
|
||||||
module = importlib.import_module(name)
|
module = _import_plugin(name, plugin_path)
|
||||||
except ImportError:
|
except Exception as exc:
|
||||||
|
_FAILED_PLUGINS[name] = f"Import error: {exc}"
|
||||||
logger.exception("Plugin %r: import failed, skipping", name)
|
logger.exception("Plugin %r: import failed, skipping", name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Validate required exports
|
# Validate required exports
|
||||||
if not hasattr(module, "setup"):
|
if not hasattr(module, "setup"):
|
||||||
|
_FAILED_PLUGINS[name] = "Missing required export: setup()"
|
||||||
logger.error("Plugin %r: missing required export 'setup', skipping", name)
|
logger.error("Plugin %r: missing required export 'setup', skipping", name)
|
||||||
continue
|
continue
|
||||||
if not hasattr(module, "get_scheduled_tasks"):
|
if not hasattr(module, "get_scheduled_tasks"):
|
||||||
|
_FAILED_PLUGINS[name] = "Missing required export: get_scheduled_tasks()"
|
||||||
logger.error(
|
logger.error(
|
||||||
"Plugin %r: missing required export 'get_scheduled_tasks', skipping", name
|
"Plugin %r: missing required export 'get_scheduled_tasks', skipping", name
|
||||||
)
|
)
|
||||||
@@ -113,7 +180,8 @@ def load_plugins(app: "Quart") -> None:
|
|||||||
# Call setup
|
# Call setup
|
||||||
try:
|
try:
|
||||||
module.setup(app)
|
module.setup(app)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
_FAILED_PLUGINS[name] = f"setup() failed: {exc}"
|
||||||
logger.exception("Plugin %r: setup() raised, skipping", name)
|
logger.exception("Plugin %r: setup() raised, skipping", name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -123,17 +191,22 @@ def load_plugins(app: "Quart") -> None:
|
|||||||
bp = module.get_blueprint()
|
bp = module.get_blueprint()
|
||||||
app.register_blueprint(bp, url_prefix=f"/plugins/{name}")
|
app.register_blueprint(bp, url_prefix=f"/plugins/{name}")
|
||||||
logger.info("Plugin %r: blueprint registered at /plugins/%s/", name, 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)
|
logger.exception("Plugin %r: get_blueprint() raised", name)
|
||||||
|
continue
|
||||||
|
|
||||||
# Register scheduled tasks
|
# Register scheduled tasks
|
||||||
try:
|
try:
|
||||||
tasks = module.get_scheduled_tasks()
|
tasks = module.get_scheduled_tasks()
|
||||||
app._task_registry.extend(tasks)
|
app._task_registry.extend(tasks)
|
||||||
logger.info("Plugin %r: registered %d scheduled task(s)", name, len(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)
|
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)
|
_LOADED_PLUGINS.add(name)
|
||||||
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
|
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:
|
# Detect a single top-level directory to strip. Handles:
|
||||||
# name/ (clean plugin zip)
|
# name/ (clean plugin zip)
|
||||||
# name-v1.0.0/ (GitHub release tag archive)
|
# 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
|
# Strategy: if there is exactly one top-level item and it is a
|
||||||
# directory whose name starts with the plugin name (case-insensitive),
|
# directory whose name starts with the plugin name (case-insensitive),
|
||||||
# strip it. Otherwise extract flat into dest.
|
# strip it. Otherwise extract flat into dest.
|
||||||
@@ -223,12 +296,18 @@ async def download_and_install_plugin(
|
|||||||
logger.error("Plugin %r: %s", name, msg)
|
logger.error("Plugin %r: %s", name, msg)
|
||||||
return False, 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:
|
try:
|
||||||
mdir = plugin_dir / name / "migrations"
|
mdir = plugin_dir / name / "migrations"
|
||||||
if mdir.exists():
|
if mdir.exists():
|
||||||
from fabledscryer.core.migration_runner import run_plugin_migrations
|
from roundtable.core.migration_runner import (
|
||||||
run_plugin_migrations(app.config["DATABASE_URL"], [mdir])
|
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:
|
except Exception:
|
||||||
logger.exception("Plugin %r: migration failed after install", name)
|
logger.exception("Plugin %r: migration failed after install", name)
|
||||||
return False, "Plugin extracted but migrations failed — check logs"
|
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).
|
Returns (success, message).
|
||||||
"""
|
"""
|
||||||
import fabledscryer
|
import roundtable
|
||||||
|
|
||||||
if name in _LOADED_PLUGINS:
|
if name in _LOADED_PLUGINS:
|
||||||
return False, "Plugin already loaded — restart required to apply updates"
|
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")
|
min_ver = meta.get("min_app_version")
|
||||||
if min_ver:
|
if min_ver:
|
||||||
try:
|
try:
|
||||||
if Version(fabledscryer.__version__) < Version(min_ver):
|
if Version(roundtable.__version__) < Version(min_ver):
|
||||||
return False, (
|
return False, (
|
||||||
f"Plugin requires fabledscryer>={min_ver} "
|
f"Plugin requires roundtable>={min_ver} "
|
||||||
f"but running {fabledscryer.__version__}"
|
f"but running {roundtable.__version__}"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False, f"Invalid min_app_version: {min_ver!r}"
|
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", {})
|
defaults = meta.get("config", {})
|
||||||
stored_cfg = app.config.get("PLUGINS", {}).get(name, {})
|
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}
|
||||||
merged = {"enabled": True, **defaults, **user_overrides}
|
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
|
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:
|
try:
|
||||||
module = importlib.import_module(name)
|
module = _import_plugin(name, plugin_path)
|
||||||
except ImportError as exc:
|
except Exception as exc:
|
||||||
return False, f"Import failed: {exc}"
|
return False, f"Import failed: {exc}"
|
||||||
|
|
||||||
if not hasattr(module, "setup") or not hasattr(module, "get_scheduled_tasks"):
|
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.
|
"""DB-backed application settings.
|
||||||
|
|
||||||
Keys use dotted notation (e.g. "smtp.host").
|
Keys use dotted notation (e.g. "smtp.host").
|
||||||
Values are JSON-encoded in the DB.
|
Values are JSON-encoded in the DB.
|
||||||
|
|
||||||
Usage in create_app() (before event loop):
|
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)
|
settings = load_settings_sync(db_url)
|
||||||
|
|
||||||
Usage at runtime (inside async handlers):
|
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:
|
async with app.db_sessionmaker() as session:
|
||||||
value = await get_setting(session, "smtp.host")
|
value = await get_setting(session, "smtp.host")
|
||||||
await set_setting(session, "smtp.host", "mail.example.com")
|
await set_setting(session, "smtp.host", "mail.example.com")
|
||||||
@@ -24,7 +24,7 @@ from typing import Any
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from fabledscryer.models.settings import AppSetting
|
from roundtable.models.settings import AppSetting
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -50,7 +50,36 @@ DEFAULTS: dict[str, Any] = {
|
|||||||
"ansible.sources": [],
|
"ansible.sources": [],
|
||||||
"ping.threshold.good_ms": 50,
|
"ping.threshold.good_ms": 50,
|
||||||
"ping.threshold.warn_ms": 200,
|
"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", [])}
|
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:
|
def to_plugins_cfg(settings: dict[str, Any]) -> dict:
|
||||||
"""Assemble {plugin_name: {...config}} from all plugin.* keys."""
|
"""Assemble {plugin_name: {...config}} from all plugin.* keys."""
|
||||||
result = {}
|
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."""
|
"""Shared time-range utilities for scoping queries and sparkline data."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from datetime import datetime, timedelta, timezone
|
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
|
from __future__ import annotations
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from urllib.parse import urlencode
|
||||||
from quart import Blueprint, render_template, current_app, abort, redirect, request, session
|
from quart import Blueprint, render_template, current_app, abort, redirect, request, session
|
||||||
from sqlalchemy import select, func, update
|
from sqlalchemy import select, func, update
|
||||||
from fabledscryer.auth.middleware import require_role, current_user_id
|
from roundtable.auth.middleware import require_role, current_user_id
|
||||||
from fabledscryer.models.users import UserRole
|
from roundtable.models.users import UserRole
|
||||||
from fabledscryer.models.hosts import Host
|
from roundtable.models.hosts import Host
|
||||||
from fabledscryer.models.monitors import PingResult, DnsResult
|
from roundtable.models.monitors import PingResult, DnsResult
|
||||||
from fabledscryer.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken
|
from roundtable.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken
|
||||||
from fabledscryer.core.widgets import get_available_widgets, WIDGET_REGISTRY
|
from roundtable.core.widgets import get_available_widgets, WIDGET_REGISTRY
|
||||||
|
|
||||||
dashboard_bp = Blueprint("dashboard", __name__)
|
dashboard_bp = Blueprint("dashboard", __name__)
|
||||||
|
|
||||||
@@ -146,7 +148,22 @@ def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]:
|
|||||||
continue
|
continue
|
||||||
if defn["plugin"] and not plugins_cfg.get(defn["plugin"], {}).get("enabled", False):
|
if defn["plugin"] and not plugins_cfg.get(defn["plugin"], {}).get("enabled", False):
|
||||||
continue
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -160,13 +177,12 @@ async def _get_panels_data(db, dashboard_id: int) -> tuple:
|
|||||||
widgets = await _get_widgets(db, dashboard_id)
|
widgets = await _get_widgets(db, dashboard_id)
|
||||||
plugins_cfg = current_app.config.get("PLUGINS", {})
|
plugins_cfg = current_app.config.get("PLUGINS", {})
|
||||||
available = get_available_widgets(plugins_cfg)
|
available = get_available_widgets(plugins_cfg)
|
||||||
placed_keys = {w.widget_key for w in widgets}
|
# Multiple instances of the same widget type are allowed — show all available
|
||||||
addable = [w for w in available if w["key"] not in placed_keys]
|
|
||||||
resolved = [
|
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
|
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):
|
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):
|
async def add_widget(dash_id: int, widget_key: str):
|
||||||
user_id = current_user_id()
|
user_id = current_user_id()
|
||||||
user_role = session.get("user_role", "viewer")
|
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)
|
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 current_app.db_sessionmaker() as db:
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||||
dash = result.scalar_one_or_none()
|
dash = result.scalar_one_or_none()
|
||||||
if dash is None or not _can_edit(dash, user_id, user_role):
|
if dash is None or not _can_edit(dash, user_id, user_role):
|
||||||
abort(403)
|
abort(403)
|
||||||
widgets = await _get_widgets(db, dash_id)
|
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
|
||||||
next_pos = max((w.position for w in widgets), default=-1) + 1
|
db.add(DashboardWidget(
|
||||||
db.add(DashboardWidget(
|
dashboard_id=dash_id, widget_key=widget_key, position=next_pos,
|
||||||
dashboard_id=dash_id, widget_key=widget_key, position=next_pos,
|
title=title, config_json=config_json,
|
||||||
))
|
))
|
||||||
return await _panels_response(dash_id)
|
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)
|
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)
|
@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_id = current_user_id()
|
||||||
user_role = session.get("user_role", "viewer")
|
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)
|
abort(400)
|
||||||
|
order: list[int] = data["order"]
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id))
|
||||||
dash = result.scalar_one_or_none()
|
dash = result.scalar_one_or_none()
|
||||||
if dash is None or not _can_edit(dash, user_id, user_role):
|
if dash is None or not _can_edit(dash, user_id, user_role):
|
||||||
abort(403)
|
abort(403)
|
||||||
widgets = await _get_widgets(db, dash_id)
|
widgets = await _get_widgets(db, dash_id)
|
||||||
idx = next((i for i, w in enumerate(widgets) if w.id == widget_id), None)
|
widget_map = {w.id: w for w in widgets}
|
||||||
if idx is not None:
|
for pos, wid in enumerate(order):
|
||||||
swap_idx = (idx - 1) if direction == "up" else (idx + 1)
|
if wid in widget_map:
|
||||||
if 0 <= swap_idx < len(widgets):
|
widget_map[wid].position = pos
|
||||||
widgets[idx].position, widgets[swap_idx].position = (
|
return ("", 204)
|
||||||
widgets[swap_idx].position, widgets[idx].position
|
|
||||||
)
|
|
||||||
return await _panels_response(dash_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Share tokens ──────────────────────────────────────────────────────────────
|
# ── Share tokens ──────────────────────────────────────────────────────────────
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from quart import Blueprint, current_app, render_template
|
from quart import Blueprint, current_app, render_template
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from fabledscryer.auth.middleware import require_role
|
from roundtable.auth.middleware import require_role
|
||||||
from fabledscryer.models.users import UserRole
|
from roundtable.models.users import UserRole
|
||||||
from fabledscryer.models.hosts import Host
|
from roundtable.models.hosts import Host
|
||||||
from fabledscryer.models.monitors import DnsResult
|
from roundtable.models.monitors import DnsResult
|
||||||
|
|
||||||
dns_bp = Blueprint("dns", __name__, url_prefix="/dns")
|
dns_bp = Blueprint("dns", __name__, url_prefix="/dns")
|
||||||
|
|
||||||
@@ -1,14 +1,62 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from quart import Blueprint, render_template, request, redirect, url_for, current_app
|
from datetime import datetime, timedelta, timezone
|
||||||
from sqlalchemy import select, func
|
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
|
||||||
from fabledscryer.auth.middleware import require_role
|
from sqlalchemy import and_, case, select, func
|
||||||
from fabledscryer.models.hosts import Host, ProbeType
|
from roundtable.auth.middleware import require_role
|
||||||
from fabledscryer.models.monitors import PingResult, DnsResult
|
from roundtable.core.audit import log_audit
|
||||||
from fabledscryer.models.users import UserRole
|
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")
|
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("/")
|
@hosts_bp.get("/")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def list_hosts():
|
async def list_hosts():
|
||||||
@@ -45,12 +93,14 @@ async def list_hosts():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
latest_dns = {r.host_id: r for r in dr.scalars()}
|
latest_dns = {r.host_id: r for r in dr.scalars()}
|
||||||
|
uptime = await _compute_uptime(db)
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"hosts/list.html",
|
"hosts/list.html",
|
||||||
hosts=hosts,
|
hosts=hosts,
|
||||||
latest_pings=latest_pings,
|
latest_pings=latest_pings,
|
||||||
latest_dns=latest_dns,
|
latest_dns=latest_dns,
|
||||||
|
uptime=uptime,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +127,9 @@ async def create_host():
|
|||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
db.add(host)
|
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"))
|
return redirect(url_for("hosts.list_hosts"))
|
||||||
|
|
||||||
|
|
||||||
@@ -115,10 +168,43 @@ async def update_host(host_id: str):
|
|||||||
@hosts_bp.post("/<host_id>/delete")
|
@hosts_bp.post("/<host_id>/delete")
|
||||||
@require_role(UserRole.admin)
|
@require_role(UserRole.admin)
|
||||||
async def delete_host(host_id: str):
|
async def delete_host(host_id: str):
|
||||||
|
host_name = None
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
result = await db.execute(select(Host).where(Host.id == host_id))
|
result = await db.execute(select(Host).where(Host.id == host_id))
|
||||||
host = result.scalar_one_or_none()
|
host = result.scalar_one_or_none()
|
||||||
if host:
|
if host:
|
||||||
|
host_name = host.name
|
||||||
await db.delete(host)
|
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"))
|
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.engine import Connection
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from fabledscryer.models.base import Base
|
from roundtable.models.base import Base
|
||||||
import fabledscryer.models # noqa: F401 — registers all models in metadata
|
import roundtable.models # noqa: F401 — registers all models in metadata
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
@@ -16,18 +16,20 @@ target_metadata = Base.metadata
|
|||||||
|
|
||||||
def get_url() -> str:
|
def get_url() -> str:
|
||||||
import os, yaml
|
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:
|
try:
|
||||||
with open(cfg_path) as f:
|
with open(cfg_path) as f:
|
||||||
cfg = yaml.safe_load(f) or {}
|
cfg = yaml.safe_load(f) or {}
|
||||||
url = cfg.get("database", {}).get("url", "")
|
url = cfg.get("database", {}).get("url", "")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
url = ""
|
url = ""
|
||||||
return (
|
return _env("DATABASE_URL") or _env("DATABASE__URL") or url
|
||||||
os.environ.get("FABLEDSCRYER_DATABASE_URL")
|
|
||||||
or os.environ.get("FABLEDSCRYER_DATABASE__URL")
|
|
||||||
or url
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolved_url() -> str:
|
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)
|
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):
|
class AlertEvent(Base):
|
||||||
__tablename__ = "alert_events"
|
__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 __future__ import annotations
|
||||||
from datetime import datetime, timezone
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
from .base import Base
|
from .base import Base
|
||||||
|
|
||||||
@@ -51,3 +51,5 @@ class DashboardWidget(Base):
|
|||||||
)
|
)
|
||||||
widget_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
widget_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
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 datetime import datetime, timezone
|
||||||
from sqlalchemy import DateTime, String, Text
|
from sqlalchemy import DateTime, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from fabledscryer.models.base import Base
|
from roundtable.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
class AppSetting(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