diff --git a/.env.example b/.env.example index 6932cf4..16af250 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Copy to .env for Docker / local development secrets # These override values in config.yaml -FABLEDSCRYER_SECRET_KEY=change-me -FABLEDSCRYER_DATABASE__URL=postgresql+asyncpg://fabledscryer:password@localhost/fabledscryer -FABLEDSCRYER_SMTP__PASSWORD= +ROUNDTABLE_SECRET_KEY=change-me +ROUNDTABLE_DATABASE__URL=postgresql+asyncpg://roundtable:password@localhost/roundtable +ROUNDTABLE_SMTP__PASSWORD= diff --git a/.gitignore b/.gitignore index 5d7aecb..a0290f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Planning files docs/superpowers/ -# Plugin directory — managed as a separate repo (bvandeusen/fabledscryer-plugins) +# Plugin directory — managed as a separate repo (bvandeusen/roundtable-plugins) # PLUGIN_DIR in config.yaml points here at runtime /plugins/ diff --git a/Dockerfile b/Dockerfile index 488a646..6f08158 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,10 @@ FROM python:3.13-slim RUN DEBIAN_FRONTEND=noninteractive apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iputils-ping \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + iputils-ping \ + # gosu — minimal privilege-drop tool used by entrypoint.sh + gosu \ && rm -rf /var/lib/apt/lists/* # Upgrade pip to suppress the version notice @@ -10,18 +13,21 @@ RUN pip install --upgrade pip WORKDIR /app COPY pyproject.toml . -COPY fabledscryer/ fabledscryer/ +COPY roundtable/ roundtable/ RUN pip install --no-cache-dir . COPY alembic.ini . +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh -# Runtime directories — owned by app user +# Runtime directories — owned by app user. The container starts as root +# so the entrypoint can fix up /data permissions if needed, then drops to +# 'app' (uid 1000) via gosu before launching roundtable. RUN useradd -m -u 1000 app \ && mkdir -p /data/playbook_cache /data/plugins \ && chown -R app:app /data /app -USER app - EXPOSE 5000 -CMD ["fabledscryer", "--host", "0.0.0.0", "--port", "5000"] +ENTRYPOINT ["/entrypoint.sh"] +CMD ["roundtable", "--host", "0.0.0.0", "--port", "5000"] diff --git a/README.md b/README.md index aecae15..9e14986 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Fabled Scryer +# Roundtable -A self-hosted network monitoring and infrastructure management hub for home servers. Fabled Scryer gives you a single pane of glass over your hosts, services, and automation — with live-updating dashboards, alerting, and Ansible integration. +A self-hosted network monitoring and infrastructure management hub for home servers. Roundtable gives you a single pane of glass over your hosts, services, and automation — with live-updating dashboards, alerting, and Ansible integration. --- @@ -21,7 +21,7 @@ No JavaScript framework. No build step. No external workers or message brokers. ```bash cp .env.example .env -# Set FABLEDSCRYER_DATABASE_URL in .env +# Set ROUNDTABLE_DATABASE_URL in .env docker compose up -d ``` @@ -41,7 +41,7 @@ pip install -e . cp config.example.yaml config.yaml # Edit config.yaml — set database.url at minimum -fabledscryer --host 0.0.0.0 --port 5000 +roundtable --host 0.0.0.0 --port 5000 ``` ICMP ping requires `CAP_NET_RAW` or the `setuid` ping binary. TCP mode (the default) needs no elevated privileges. @@ -54,7 +54,7 @@ Only two things must be set to run the app: | What | How | |---|---| -| Database URL | `FABLEDSCRYER_DATABASE_URL` env var or `database.url` in `config.yaml` | +| Database URL | `ROUNDTABLE_DATABASE_URL` env var or `database.url` in `config.yaml` | | Secret key | Auto-generated on first run and saved to `/data/secret.key` | Everything else — SMTP, webhooks, monitor intervals, Ansible sources, plugin settings — is configured through the web UI at `/settings/`. diff --git a/alembic.ini b/alembic.ini index c9214c6..4d7fa0d 100644 --- a/alembic.ini +++ b/alembic.ini @@ -1,5 +1,5 @@ [alembic] -script_location = fabledscryer/migrations +script_location = roundtable/migrations prepend_sys_path = . version_path_separator = os diff --git a/config.example.yaml b/config.example.yaml index 30db988..6bace6a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,9 +1,9 @@ -# Fabled Scryer — Bootstrap Configuration Example +# Roundtable — Bootstrap Configuration Example # # The only REQUIRED setting is the database URL. # Set it via env var (recommended for Docker): # -# FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://user:pass@host/db +# ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://user:pass@host/db # # All other settings (SMTP, webhook, ansible, plugins, retention) # are stored in the database and managed via the Settings UI at /settings/. @@ -11,7 +11,7 @@ # config.yaml is optional. Use it only if you prefer file-based bootstrap. database: - url: "postgresql+asyncpg://fabledscryer:password@localhost/fabledscryer" + url: "postgresql+asyncpg://roundtable:password@localhost/roundtable" # Optional: override the auto-generated secret key. # If not set, a key is auto-generated on first run and saved to /data/secret.key. diff --git a/docker-compose.yml b/docker-compose.yml index 0149817..382ec10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,17 @@ services: - app: + roundtable: build: . + container_name: roundtable + image: roundtable:latest ports: - "5000:5000" volumes: - app_data:/data - ./plugins:/app/plugins:ro - # Uncomment to enable Traefik access log ingestion (set access_log.enabled: true in plugin config): - /mnt/Data/traefik/log:/var/log/traefik:ro + - /var/run/docker.sock:/var/run/docker.sock:ro environment: - - FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@db/fabledscryer + - ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@db/fabledscryer depends_on: db: condition: service_healthy diff --git a/docs/architecture.md b/docs/architecture.md index e2aebbf..3da5bb2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,12 +1,12 @@ # Architecture -Fabled Scryer is a single Quart (async Python) process. There are no separate worker processes, no message brokers, and no build pipeline for the frontend. All monitoring, scheduling, and request handling runs on a single asyncio event loop. +Roundtable is a single Quart (async Python) process. There are no separate worker processes, no message brokers, and no build pipeline for the frontend. All monitoring, scheduling, and request handling runs on a single asyncio event loop. --- ## Startup Sequence -`create_app()` in `fabledscryer/app.py` runs these steps synchronously before the event loop starts: +`create_app()` in `roundtable/app.py` runs these steps synchronously before the event loop starts: | Step | What happens | |---|---| @@ -32,14 +32,14 @@ All routes are Quart Blueprints registered in `app.py`: | Prefix | Blueprint | Module | |---|---|---| -| `/auth/` | `auth_bp` | `fabledscryer/auth/routes.py` | -| `/` | `dashboard_bp` | `fabledscryer/dashboard/routes.py` | -| `/hosts/` | `hosts_bp` | `fabledscryer/hosts/routes.py` | -| `/ping/` | `ping_bp` | `fabledscryer/ping/routes.py` | -| `/dns/` | `dns_bp` | `fabledscryer/dns/routes.py` | -| `/alerts/` | `alerts_bp` | `fabledscryer/alerts/routes.py` | -| `/ansible/` | `ansible_bp` | `fabledscryer/ansible/routes.py` | -| `/settings/` | `settings_bp` | `fabledscryer/settings/routes.py` | +| `/auth/` | `auth_bp` | `roundtable/auth/routes.py` | +| `/` | `dashboard_bp` | `roundtable/dashboard/routes.py` | +| `/hosts/` | `hosts_bp` | `roundtable/hosts/routes.py` | +| `/ping/` | `ping_bp` | `roundtable/ping/routes.py` | +| `/dns/` | `dns_bp` | `roundtable/dns/routes.py` | +| `/alerts/` | `alerts_bp` | `roundtable/alerts/routes.py` | +| `/ansible/` | `ansible_bp` | `roundtable/ansible/routes.py` | +| `/settings/` | `settings_bp` | `roundtable/settings/routes.py` | | `/plugins//` | plugin blueprint | `plugins//routes.py` | Plugin blueprints are mounted automatically by `load_plugins()` using the plugin directory name as the URL prefix. @@ -48,7 +48,7 @@ Plugin blueprints are mounted automatically by `load_plugins()` using the plugin ## Scheduler -`fabledscryer/core/scheduler.py` exports two things: +`roundtable/core/scheduler.py` exports two things: - `ScheduledTask` dataclass — holds a `name`, `coro_factory` (zero-argument callable returning a coroutine), `interval_seconds`, and optional `run_on_startup` flag - `start_scheduler(tasks)` — async function that loops every second, calling `asyncio.create_task()` for each task whose interval has elapsed @@ -93,9 +93,9 @@ This means the only file you must touch to get the app running is the database U No JavaScript framework, no build step. The frontend is: -- **Jinja2 templates** rendered server-side (`fabledscryer/templates/`) +- **Jinja2 templates** rendered server-side (`roundtable/templates/`) - **HTMX** for live-updating fragments (dashboard widgets, ping pills, DNS status) -- A single CSS design system in `fabledscryer/templates/base.html` using CSS custom properties +- A single CSS design system in `roundtable/templates/base.html` using CSS custom properties Live-updating widgets use HTMX polling: ```html diff --git a/docs/core/alerting.md b/docs/core/alerting.md index 9e20fb5..6d9eef8 100644 --- a/docs/core/alerting.md +++ b/docs/core/alerting.md @@ -14,7 +14,7 @@ When any monitor or plugin calls `record_metric()`: 4. If a state transition occurs, an `AlertEvent` row is written 5. Notification I/O is deferred outside the transaction via `asyncio.create_task()` -**Key function:** `fabledscryer/core/alerts.py` → `record_metric(session, source_module, resource_name, metric_name, value)` +**Key function:** `roundtable/core/alerts.py` → `record_metric(session, source_module, resource_name, metric_name, value)` `record_metric()` must always be called inside an active transaction: @@ -130,7 +130,7 @@ Webhook is skipped if `webhook.url` is empty. ## Data Models -Defined in `fabledscryer/models/alerts.py`: +Defined in `roundtable/models/alerts.py`: - **`alert_rules`** — one row per configured rule - **`alert_states`** — one row per rule, tracks current state and consecutive failure count diff --git a/docs/core/ansible.md b/docs/core/ansible.md index 012ce8d..a0667df 100644 --- a/docs/core/ansible.md +++ b/docs/core/ansible.md @@ -1,6 +1,6 @@ # Ansible Integration -Fabled Scryer can browse, trigger, and stream output from Ansible playbooks directly from the web UI. Runs execute as asyncio tasks inside the same process — no Celery, no external workers. +Roundtable can browse, trigger, and stream output from Ansible playbooks directly from the web UI. Runs execute as asyncio tasks inside the same process — no Celery, no external workers. --- @@ -115,7 +115,7 @@ Output stored in the DB is capped at 1 MB. If truncated, `[output truncated]` is ## Data Model -`ansible_runs` table (defined in `fabledscryer/models/ansible.py`): +`ansible_runs` table (defined in `roundtable/models/ansible.py`): | Column | Type | Description | |---|---|---| @@ -137,7 +137,7 @@ Runs older than `data.retention_days` (default 90) are pruned by the `data_clean | File | Purpose | |---|---| -| `fabledscryer/ansible/sources.py` | Source discovery, git pull logic | -| `fabledscryer/ansible/executor.py` | Subprocess execution and output streaming | -| `fabledscryer/ansible/routes.py` | HTTP routes (browse, trigger, stream, history) | -| `fabledscryer/models/ansible.py` | `AnsibleRun` model | +| `roundtable/ansible/sources.py` | Source discovery, git pull logic | +| `roundtable/ansible/executor.py` | Subprocess execution and output streaming | +| `roundtable/ansible/routes.py` | HTTP routes (browse, trigger, stream, history) | +| `roundtable/models/ansible.py` | `AnsibleRun` model | diff --git a/docs/core/configuration.md b/docs/core/configuration.md index acbebb3..3ab5a17 100644 --- a/docs/core/configuration.md +++ b/docs/core/configuration.md @@ -1,6 +1,6 @@ # Configuration -Fabled Scryer uses a two-layer configuration system. Only the bare minimum needed to boot lives in files or environment variables. Everything else is stored in the database and managed through the Settings UI. +Roundtable uses a two-layer configuration system. Only the bare minimum needed to boot lives in files or environment variables. Everything else is stored in the database and managed through the Settings UI. --- @@ -10,29 +10,31 @@ These three values are read at startup from `config.yaml` and/or environment var | Key | Env var | Default | Description | |---|---|---|---| -| `database.url` | `FABLEDSCRYER_DATABASE_URL` | — | PostgreSQL async URL. **Required.** | -| `secret_key` | `FABLEDSCRYER_SECRET_KEY` | auto-generated | Flask/Quart session signing key. Auto-generated and saved to `/data/secret.key` if not set. | -| `plugin_dir` | `FABLEDSCRYER_PLUGIN_DIR` | `plugins` | Path to the plugins directory. | +| `database.url` | `ROUNDTABLE_DATABASE_URL` | — | PostgreSQL async URL. **Required.** | +| `secret_key` | `ROUNDTABLE_SECRET_KEY` | auto-generated | Flask/Quart session signing key. Auto-generated and saved to `/data/secret.key` if not set. | +| `plugin_dir` | `ROUNDTABLE_PLUGIN_DIR` | `plugins` | Path to the plugins directory. | -**Resolution order for `database_url`:** env var `FABLEDSCRYER_DATABASE_URL` → env var `FABLEDSCRYER_DATABASE__URL` (legacy double-underscore) → `database.url` in `config.yaml`. +**Resolution order for `database_url`:** env var `ROUNDTABLE_DATABASE_URL` → env var `ROUNDTABLE_DATABASE__URL` (legacy double-underscore) → `database.url` in `config.yaml`. -**Resolution order for `secret_key`:** env var `FABLEDSCRYER_SECRET_KEY` → `secret_key` in `config.yaml` → `/data/secret.key` file → auto-generate and write to `/data/secret.key`. +**Resolution order for `secret_key`:** env var `ROUNDTABLE_SECRET_KEY` → `secret_key` in `config.yaml` → `/data/secret.key` file → auto-generate and write to `/data/secret.key`. ### Minimal config.yaml ```yaml database: - url: "postgresql+asyncpg://user:password@localhost/fabledscryer" + url: "postgresql+asyncpg://user:password@localhost/roundtable" ``` ### Minimal env-only setup (Docker) ```bash -FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://user:password@db/fabledscryer +ROUNDTABLE_DATABASE_URL=postgresql+asyncpg://user:password@db/roundtable ``` A `.env` file is loaded automatically if present. +> **Legacy env vars:** `FABLEDSCRYER_*` env vars are still accepted as a fallback for existing deployments. They will be removed in a future release — migrate to `ROUNDTABLE_*` when convenient. + --- ## App Settings (Database-backed) @@ -66,7 +68,7 @@ Default webhook template: ### Reading and Writing Settings in Code ```python -from fabledscryer.core.settings import get_setting, set_setting +from roundtable.core.settings import get_setting, set_setting # Read async with current_app.db_sessionmaker() as session: @@ -82,7 +84,7 @@ async with current_app.db_sessionmaker() as session: ### The DEFAULTS Dict -`fabledscryer/core/settings.py` contains the `DEFAULTS` dict — the canonical list of all recognised settings and their default values. Add new settings here to make them recognised by `get_all_settings()` and the settings UI. +`roundtable/core/settings.py` contains the `DEFAULTS` dict — the canonical list of all recognised settings and their default values. Add new settings here to make them recognised by `get_all_settings()` and the settings UI. --- diff --git a/docs/core/monitors.md b/docs/core/monitors.md index f69ed4e..0accd47 100644 --- a/docs/core/monitors.md +++ b/docs/core/monitors.md @@ -1,13 +1,13 @@ # Core Monitors -Fabled Scryer ships two built-in monitors: Ping and DNS. Both run as asyncio scheduled tasks on the same event loop as the web server, with no separate processes. +Roundtable ships two built-in monitors: Ping and DNS. Both run as asyncio scheduled tasks on the same event loop as the web server, with no separate processes. --- ## Ping Monitor -**Source:** `fabledscryer/monitors/ping.py` -**Scheduler task:** `ping_monitor` in `fabledscryer/app.py` +**Source:** `roundtable/monitors/ping.py` +**Scheduler task:** `ping_monitor` in `roundtable/app.py` **Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup ### How It Works @@ -29,7 +29,7 @@ Each probe writes a `PingResult` row and calls `record_metric()`: ### Data Model -`ping_results` table (defined in `fabledscryer/models/monitors.py`): +`ping_results` table (defined in `roundtable/models/monitors.py`): | Column | Type | Description | |---|---|---| @@ -51,8 +51,8 @@ Old rows are pruned by the `data_cleanup` task (default: 90 days). ## DNS Monitor -**Source:** `fabledscryer/monitors/dns.py` -**Scheduler task:** `dns_monitor` in `fabledscryer/app.py` +**Source:** `roundtable/monitors/dns.py` +**Scheduler task:** `dns_monitor` in `roundtable/app.py` **Interval:** `monitors.poll_interval_seconds` (default 60s), runs on startup ### How It Works @@ -70,7 +70,7 @@ Each check writes a `DnsResult` row and calls `record_metric()`: ### Data Model -`dns_results` table (defined in `fabledscryer/models/monitors.py`): +`dns_results` table (defined in `roundtable/models/monitors.py`): | Column | Type | Description | |---|---|---| diff --git a/docs/plugins/host-agent-design.md b/docs/plugins/host-agent-design.md new file mode 100644 index 0000000..49c1fed --- /dev/null +++ b/docs/plugins/host-agent-design.md @@ -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 │ 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 //` | 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//rotate-token` | admin | Generates a new token, invalidates the old one, returns new install one-liner. | +| `POST /settings//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` | `` | `sample.cpu_pct` as-is | +| `mem_used_pct` | `` | `100 * (total - available) / total` | +| `mem_available_bytes` | `` | `sample.mem.available_bytes` | +| `swap_used_bytes` | `` | `sample.mem.swap_used_bytes` | +| `load_1m` | `` | `sample.load["1m"]` | +| `load_5m` | `` | `sample.load["5m"]` | +| `load_15m` | `` | `sample.load["15m"]` | +| `uptime_secs` | `` | `sample.uptime_secs` | +| `disk_used_pct` | `:` | `100 * used / total` per mount | +| `disk_used_bytes` | `:` | raw per mount | +| `disk_total_bytes` | `:` | raw per mount | +| `disk_used_pct_worst` | `` | `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" < "$UNIT_FILE" </`: + +- 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. diff --git a/docs/plugins/host-agent-plan.md b/docs/plugins/host-agent-plan.md new file mode 100644 index 0000000..e426d1b --- /dev/null +++ b/docs/plugins/host-agent-plan.md @@ -0,0 +1,2661 @@ +# Host Agent Plugin 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. + +**Goal:** Ship a `host_agent` Roundtable plugin plus a stdlib-only Python push agent that reports CPU/memory/storage/load/uptime from remote Linux hosts to Roundtable. + +**Architecture:** Plugin lives under `plugins/host_agent/` (mirrors `plugins/http/`). It owns a private `host_agent_registrations` table, writes time-series data into the core `PluginMetric` bus, serves its own agent source at `GET /plugins/host_agent/agent.py`, and renders a per-host install script at `GET /plugins/host_agent/install.sh`. The agent is a single ~300-line Python script with a 30s collect→POST loop, in-memory ring buffer, exponential backoff, and systemd unit installed by a curl one-liner. + +**Tech Stack:** Quart Blueprint, SQLAlchemy (async), Alembic (plugin migration dir), Jinja2 for `install.sh.j2`, pytest + pytest_asyncio. Agent: Python 3.8+ stdlib only (`urllib.request`, `json`, `os`, `shutil`, `time`, `socket`, `signal`, `secrets` not needed — token is server-side). + +**Spec:** `docs/plugins/host-agent-design.md` + +--- + +## Execution note — path 1 (no DB-backed tests) + +Mid-execution discovery: the Roundtable test harness runs `create_app(testing=True)`, which mocks `db_sessionmaker` and skips all migrations. There is no existing DB-backed test fixture in this codebase, and no existing plugin ships with tests. The first execution attempt tried to invent a SQLite-backed override fixture, which cascaded into modifying a production migration (`0007_dashboard_ownership.py`) to remove an inline FK — unacceptable. That commit was reverted. + +**Revised test strategy:** test the agent exhaustively (everything that lives in `plugins/host_agent/agent.py` — pure Python, stdlib only, trivially unit-testable). Test the server-side metric-expansion function as a pure function that takes a sample dict + host name and returns a list of `(metric_name, resource_name, value)` tuples. Everything else — ingest route, install route, settings routes, widgets, detail page, scheduler — is verified manually against the dev server. + +**Under this strategy:** +- Task 1 drops the migration smoke test. Scaffolding files are written on disk and committed to the nested `plugins/` repo at the end (Task 15). +- Tasks 2–5 are unchanged (agent unit tests). +- Task 6 becomes "ingest route with a pure-function metric expander" — the pure function is unit-tested, the route itself isn't. +- Task 7 (ingest error paths) folds into manual verification. +- Task 8 gets a Jinja-render unit test for the install script (no DB). +- Tasks 9–11 ship with no automated tests; manual verification only. +- Task 12 tests `find_stale_registrations` only if it can be factored to operate on a passed-in list of (host_name, last_seen_at) tuples. +- Task 13 becomes a manual end-to-end checklist (curl against the real dev server). + +Where code blocks in tasks below reference DB-backed tests, treat them as guidance for manual verification instead of as test code. The agent and metric-expander tests are the only automated coverage. + +--- + +## File Structure + +**New files (plugin):** + +- `plugins/host_agent/__init__.py` — plugin entry: `setup(app)`, `get_scheduled_tasks()`, `get_blueprint()`. +- `plugins/host_agent/plugin.yaml` — metadata + default config. +- `plugins/host_agent/models.py` — `HostAgentRegistration` SQLAlchemy model. +- `plugins/host_agent/routes.py` — Quart blueprint: ingest, install.sh, agent.py, detail, settings, widgets. +- `plugins/host_agent/scheduler.py` — stale-agent marker task. +- `plugins/host_agent/agent.py` — the Python agent script, served to targets. +- `plugins/host_agent/migrations/__init__.py` +- `plugins/host_agent/migrations/env.py` — alembic env (copy of http plugin's, but imports `roundtable.models.base`). +- `plugins/host_agent/migrations/versions/__init__.py` +- `plugins/host_agent/migrations/versions/host_agent_001_initial.py` — creates `host_agent_registrations`. +- `plugins/host_agent/templates/install.sh.j2` — Jinja install script template. +- `plugins/host_agent/templates/settings_list.html` — plugin settings page. +- `plugins/host_agent/templates/detail.html` — per-host detail page. +- `plugins/host_agent/templates/widget_table.html` — fleet table widget partial. +- `plugins/host_agent/templates/widget_history.html` — history chart widget partial. + +**New files (tests):** + +- `tests/plugins/host_agent/__init__.py` +- `tests/plugins/host_agent/conftest.py` — plugin-local fixtures (fake /proc dir, registered host helper). +- `tests/plugins/host_agent/test_agent_collectors.py` +- `tests/plugins/host_agent/test_agent_config.py` +- `tests/plugins/host_agent/test_agent_ring_buffer.py` +- `tests/plugins/host_agent/test_agent_build_payload.py` +- `tests/plugins/host_agent/test_ingest_route.py` +- `tests/plugins/host_agent/test_install_route.py` +- `tests/plugins/host_agent/test_settings_routes.py` +- `tests/plugins/host_agent/test_scheduler.py` +- `tests/plugins/host_agent/test_migration.py` + +**Modified files (core, small edits):** + +- `roundtable/alerts/routes.py` — add `"host_agent"` entry to `METRIC_CATALOG`. +- `roundtable/core/widgets.py` — add `host_resources` and `host_resource_history` entries. +- `docs/plugins/index.yaml.example` — add catalog entry. + +--- + +## Task 1: Plugin scaffolding + migration + +**Files:** +- Create: `plugins/host_agent/__init__.py` +- Create: `plugins/host_agent/plugin.yaml` +- Create: `plugins/host_agent/models.py` +- Create: `plugins/host_agent/routes.py` +- Create: `plugins/host_agent/migrations/__init__.py` +- Create: `plugins/host_agent/migrations/env.py` +- Create: `plugins/host_agent/migrations/versions/__init__.py` +- Create: `plugins/host_agent/migrations/versions/host_agent_001_initial.py` +- Create: `tests/plugins/host_agent/__init__.py` +- Create: `tests/plugins/host_agent/test_migration.py` + +- [ ] **Step 1: Write the migration smoke test** + +```python +# tests/plugins/host_agent/test_migration.py +"""Smoke test: plugin migration upgrades and downgrades cleanly.""" +import pytest +from sqlalchemy import inspect, text +from sqlalchemy.ext.asyncio import create_async_engine + +pytestmark = pytest.mark.asyncio + + +async def test_host_agent_migration_creates_table(app): + # The app fixture runs all plugin migrations on startup. + from roundtable.core.db import get_engine + engine = get_engine() + async with engine.connect() as conn: + def _check(sync_conn): + insp = inspect(sync_conn) + assert "host_agent_registrations" in insp.get_table_names() + cols = {c["name"] for c in insp.get_columns("host_agent_registrations")} + assert {"id", "host_id", "token_hash", "last_seen_at", + "agent_version", "kernel", "distro", "arch", + "created_at", "updated_at"} <= cols + await conn.run_sync(_check) +``` + +- [ ] **Step 2: Run it — expect failure** + +Run: `pytest tests/plugins/host_agent/test_migration.py -v` +Expected: FAIL (plugin not loaded, table missing). + +- [ ] **Step 3: Create `plugins/host_agent/plugin.yaml`** + +```yaml +# plugins/host_agent/plugin.yaml +name: host_agent +version: "1.0.0" +description: "Remote Linux host resource monitoring via a lightweight Python push agent (CPU, memory, storage, load, uptime)" +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/host_agent" +tags: + - host + - monitoring + - cpu + - memory + - storage + +config: + stale_after_seconds: 180 + default_interval_seconds: 30 +``` + +- [ ] **Step 4: Create `plugins/host_agent/__init__.py`** + +```python +# plugins/host_agent/__init__.py +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from quart import Quart + +_app: "Quart | None" = None + + +def setup(app: "Quart") -> None: + global _app + _app = app + + +def get_scheduled_tasks() -> list: + from .scheduler import make_stale_task + return [make_stale_task(_app)] + + +def get_blueprint(): + from .routes import host_agent_bp + return host_agent_bp +``` + +- [ ] **Step 5: Create `plugins/host_agent/models.py`** + +```python +# plugins/host_agent/models.py +from __future__ import annotations +import uuid +from datetime import datetime, timezone +from sqlalchemy import Column, String, DateTime, ForeignKey +from roundtable.models.base import Base + + +def _uuid() -> str: + return str(uuid.uuid4()) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class HostAgentRegistration(Base): + __tablename__ = "host_agent_registrations" + + id = Column(String(36), primary_key=True, default=_uuid) + host_id = Column(String(36), ForeignKey("hosts.id", ondelete="CASCADE"), + unique=True, nullable=False) + token_hash = Column(String(64), nullable=False, unique=True, index=True) + token_created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow) + agent_version = Column(String(32), nullable=True) + kernel = Column(String(128), nullable=True) + distro = Column(String(128), nullable=True) + arch = Column(String(32), nullable=True) + last_seen_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow) + updated_at = Column(DateTime(timezone=True), nullable=False, + default=_utcnow, onupdate=_utcnow) +``` + +- [ ] **Step 6: Create the migration file** + +```python +# plugins/host_agent/migrations/versions/host_agent_001_initial.py +"""host_agent plugin initial tables + +Revision ID: host_agent_001_initial +Revises: (none — branch via depends_on) +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "host_agent_001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = "host_agent" +depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",) + + +def upgrade() -> None: + op.create_table( + "host_agent_registrations", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("host_id", sa.String(36), + sa.ForeignKey("hosts.id", ondelete="CASCADE"), + nullable=False, unique=True), + sa.Column("token_hash", sa.String(64), nullable=False, unique=True, index=True), + sa.Column("token_created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("agent_version", sa.String(32), nullable=True), + sa.Column("kernel", sa.String(128), nullable=True), + sa.Column("distro", sa.String(128), nullable=True), + sa.Column("arch", sa.String(32), nullable=True), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("host_agent_registrations") +``` + +- [ ] **Step 7: Create `plugins/host_agent/migrations/env.py`** + +```python +# plugins/host_agent/migrations/env.py +"""Alembic env.py for the host_agent plugin.""" +import asyncio +import os +import sys +from pathlib import Path +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from alembic import context + +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + +from roundtable.models.base import Base +import roundtable.models # noqa: F401 +from plugins.host_agent.models import HostAgentRegistration # noqa: F401 + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def _get_url() -> str: + import yaml + cfg_path = os.environ.get("ROUNDTABLE_CONFIG", "config.yaml") + try: + with open(cfg_path) as f: + cfg = yaml.safe_load(f) or {} + url = cfg.get("database", {}).get("url", "") + except FileNotFoundError: + url = "" + return os.environ.get("ROUNDTABLE_DATABASE__URL", url) + + +def run_migrations_offline() -> None: + context.configure( + url=_get_url(), target_metadata=target_metadata, + literal_binds=True, dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + cfg = config.get_section(config.config_ini_section, {}) + cfg["sqlalchemy.url"] = _get_url() + connectable = async_engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() +``` + +- [ ] **Step 8: Create empty `__init__.py` files** + +```python +# plugins/host_agent/migrations/__init__.py +``` + +```python +# plugins/host_agent/migrations/versions/__init__.py +``` + +```python +# tests/plugins/host_agent/__init__.py +``` + +- [ ] **Step 9: Create a minimal `routes.py` stub so the loader can import** + +```python +# plugins/host_agent/routes.py +from __future__ import annotations +from quart import Blueprint + +host_agent_bp = Blueprint("host_agent", __name__, template_folder="templates") +``` + +- [ ] **Step 10: Create a minimal `scheduler.py` stub** + +```python +# plugins/host_agent/scheduler.py +from __future__ import annotations + + +def make_stale_task(app): + async def _task(): + return None + return {"name": "host_agent.mark_stale", "interval_seconds": 60, "func": _task} +``` + +- [ ] **Step 11: Enable the plugin in test config** + +Check `tests/conftest.py` for the `config_file` fixture. Add the plugin to the fixture's test config: + +```python +# tests/conftest.py — extend the config_file fixture body +f.write_text(textwrap.dedent("""\ + secret_key: test-secret-key-32-chars-minimum!! + database: + url: postgresql+asyncpg://test:test@localhost/test + plugins: + host_agent: + enabled: true +""")) +``` + +(If the existing fixture already has a `plugins:` key, append `host_agent` under it instead. Read the file before editing.) + +- [ ] **Step 12: Run the migration test** + +Run: `pytest tests/plugins/host_agent/test_migration.py -v` +Expected: PASS — the plugin loader picks up the new plugin, runs the migration, and the table exists. + +- [ ] **Step 13: Commit** + +```bash +git add plugins/host_agent tests/plugins/host_agent tests/conftest.py +git commit -m "feat(host_agent): plugin scaffolding and initial migration" +``` + +--- + +## Task 2: Agent config parser + +**Files:** +- Create: `plugins/host_agent/agent.py` (partial — config parser only) +- Create: `tests/plugins/host_agent/test_agent_config.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/plugins/host_agent/test_agent_config.py +"""Unit tests for the agent's homegrown config parser.""" +import pytest +from plugins.host_agent.agent import read_config, ConfigError + + +def test_parses_flat_key_value(tmp_path): + p = tmp_path / "agent.conf" + p.write_text( + "url = https://roundtable.example\n" + "token = abc123\n" + "interval_seconds = 45\n" + ) + cfg = read_config(str(p)) + assert cfg["url"] == "https://roundtable.example" + assert cfg["token"] == "abc123" + assert cfg["interval_seconds"] == 45 + + +def test_ignores_blank_lines_and_comments(tmp_path): + p = tmp_path / "agent.conf" + p.write_text( + "# top comment\n" + "\n" + "url = https://x\n" + " # indented comment\n" + "token = t\n" + ) + cfg = read_config(str(p)) + assert cfg == {"url": "https://x", "token": "t"} + + +def test_parses_mounts_as_list(tmp_path): + p = tmp_path / "agent.conf" + p.write_text("url = x\ntoken = y\nmounts = /, /mnt/data, /srv\n") + cfg = read_config(str(p)) + assert cfg["mounts"] == ["/", "/mnt/data", "/srv"] + + +def test_missing_required_fields_raises(tmp_path): + p = tmp_path / "agent.conf" + p.write_text("url = https://x\n") + with pytest.raises(ConfigError, match="token"): + read_config(str(p)) + + +def test_malformed_line_raises(tmp_path): + p = tmp_path / "agent.conf" + p.write_text("url https://x\ntoken = y\n") + with pytest.raises(ConfigError): + read_config(str(p)) +``` + +- [ ] **Step 2: Run — expect import error** + +Run: `pytest tests/plugins/host_agent/test_agent_config.py -v` +Expected: FAIL (`read_config` does not exist). + +- [ ] **Step 3: Implement the parser** + +```python +# plugins/host_agent/agent.py +"""Roundtable host agent — pushes resource metrics to a Roundtable instance. + +Python 3.8+ stdlib only. Target ~300 lines. Served to targets at +GET /plugins/host_agent/agent.py. +""" +from __future__ import annotations + +import json +import os +import shutil +import signal +import socket +import sys +import time +import urllib.error +import urllib.request +from collections import deque +from datetime import datetime, timezone + +AGENT_VERSION = "1.0.0" + + +class ConfigError(Exception): + pass + + +REQUIRED_KEYS = ("url", "token") +INT_KEYS = ("interval_seconds",) +LIST_KEYS = ("mounts",) + + +def read_config(path: str) -> dict: + """Parse a flat `key = value` config file.""" + cfg: dict = {} + try: + with open(path, "r", encoding="utf-8") as f: + for lineno, raw in enumerate(f, 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + raise ConfigError(f"{path}:{lineno}: expected 'key = value'") + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + if key in INT_KEYS: + try: + cfg[key] = int(value) + except ValueError: + raise ConfigError(f"{path}:{lineno}: {key} must be int") + elif key in LIST_KEYS: + cfg[key] = [v.strip() for v in value.split(",") if v.strip()] + else: + cfg[key] = value + except FileNotFoundError: + raise ConfigError(f"{path}: not found") + + missing = [k for k in REQUIRED_KEYS if k not in cfg] + if missing: + raise ConfigError(f"{path}: missing required key(s): {', '.join(missing)}") + + cfg.setdefault("interval_seconds", 30) + return cfg +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `pytest tests/plugins/host_agent/test_agent_config.py -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add plugins/host_agent/agent.py tests/plugins/host_agent/test_agent_config.py +git commit -m "feat(host_agent): agent config parser" +``` + +--- + +## Task 3: Agent collectors + +**Files:** +- Modify: `plugins/host_agent/agent.py` — append collector functions. +- Create: `tests/plugins/host_agent/test_agent_collectors.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/plugins/host_agent/test_agent_collectors.py +"""Unit tests for per-resource collectors, driven by fixture /proc files.""" +from unittest.mock import patch +import pytest +from plugins.host_agent import agent as a + + +PROC_STAT_SAMPLE_1 = "cpu 100 0 50 850 0 0 0 0 0 0\n" +PROC_STAT_SAMPLE_2 = "cpu 200 0 100 1700 0 0 0 0 0 0\n" + +PROC_MEMINFO = ( + "MemTotal: 16000000 kB\n" + "MemFree: 2000000 kB\n" + "MemAvailable: 6000000 kB\n" + "SwapTotal: 4000000 kB\n" + "SwapFree: 3500000 kB\n" +) + +PROC_LOADAVG = "0.42 0.55 0.61 1/234 5678\n" + +PROC_UPTIME = "482934.12 1000000.00\n" + +OS_RELEASE = 'PRETTY_NAME="Ubuntu 24.04 LTS"\nNAME="Ubuntu"\n' + + +def test_collect_memory_uses_available(tmp_path): + p = tmp_path / "meminfo" + p.write_text(PROC_MEMINFO) + with patch.object(a, "MEMINFO_PATH", str(p)): + mem = a.collect_memory() + # used = total - available + assert mem["total_bytes"] == 16000000 * 1024 + assert mem["available_bytes"] == 6000000 * 1024 + assert mem["used_bytes"] == (16000000 - 6000000) * 1024 + assert mem["swap_used_bytes"] == (4000000 - 3500000) * 1024 + + +def test_collect_load(tmp_path): + p = tmp_path / "loadavg" + p.write_text(PROC_LOADAVG) + with patch.object(a, "LOADAVG_PATH", str(p)): + load = a.collect_load() + assert load == {"1m": 0.42, "5m": 0.55, "15m": 0.61} + + +def test_collect_uptime(tmp_path): + p = tmp_path / "uptime" + p.write_text(PROC_UPTIME) + with patch.object(a, "UPTIME_PATH", str(p)): + assert a.collect_uptime() == 482934 + + +def test_collect_cpu_reads_twice(tmp_path): + p = tmp_path / "stat" + reads = iter([PROC_STAT_SAMPLE_1, PROC_STAT_SAMPLE_2]) + + def _read(_path): + return next(reads) + + with patch.object(a, "STAT_PATH", str(p)), \ + patch.object(a, "_read_file", _read), \ + patch.object(a, "time") as mock_time: + mock_time.sleep = lambda _s: None + pct = a.collect_cpu(sample_window=0.0) + # totals: 1000 → 2000 (delta 1000). idle: 850 → 1700 (delta 850). + # busy = delta_total - delta_idle = 150. pct = 15.0. + assert pct == pytest.approx(15.0, abs=0.1) + + +def test_collect_storage(tmp_path): + fake = {"/": (1000, 400, 600), "/mnt/data": (2000, 500, 1500)} + + def _fake_usage(path): + total, used, free = fake[path] + class R: pass + r = R() + r.total = total + r.used = used + r.free = free + return r + + with patch.object(a.shutil, "disk_usage", side_effect=_fake_usage): + out = a.collect_storage(["/", "/mnt/data"]) + assert out == [ + {"mount": "/", "total_bytes": 1000, "used_bytes": 400}, + {"mount": "/mnt/data", "total_bytes": 2000, "used_bytes": 500}, + ] + + +def test_collect_metadata(tmp_path): + p = tmp_path / "os-release" + p.write_text(OS_RELEASE) + + class Uname: + release = "6.8.0-45-generic" + machine = "x86_64" + + with patch.object(a, "OS_RELEASE_PATH", str(p)), \ + patch.object(a.os, "uname", return_value=Uname()): + meta = a.collect_metadata() + assert meta["kernel"] == "6.8.0-45-generic" + assert meta["arch"] == "x86_64" + assert "Ubuntu 24.04" in meta["distro"] +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `pytest tests/plugins/host_agent/test_agent_collectors.py -v` +Expected: FAIL (collectors not defined). + +- [ ] **Step 3: Append collectors to `plugins/host_agent/agent.py`** + +```python +# ─── collectors ────────────────────────────────────────────────────────────── + +STAT_PATH = "/proc/stat" +MEMINFO_PATH = "/proc/meminfo" +LOADAVG_PATH = "/proc/loadavg" +UPTIME_PATH = "/proc/uptime" +OS_RELEASE_PATH = "/etc/os-release" + + +def _read_file(path: str) -> str: + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +def _parse_cpu_line(stat_text: str) -> tuple[int, int]: + """Return (total_jiffies, idle_jiffies) for the aggregate cpu line.""" + for line in stat_text.splitlines(): + if line.startswith("cpu "): + parts = line.split() + fields = [int(x) for x in parts[1:]] + idle = fields[3] + (fields[4] if len(fields) > 4 else 0) # idle + iowait + total = sum(fields) + return total, idle + raise RuntimeError("no aggregate cpu line in /proc/stat") + + +def collect_cpu(sample_window: float = 0.2) -> float: + """Return CPU utilization % over sample_window seconds.""" + t1, i1 = _parse_cpu_line(_read_file(STAT_PATH)) + time.sleep(sample_window) + t2, i2 = _parse_cpu_line(_read_file(STAT_PATH)) + dt = t2 - t1 + di = i2 - i1 + if dt <= 0: + return 0.0 + return round(100.0 * (dt - di) / dt, 2) + + +def collect_memory() -> dict: + info: dict[str, int] = {} + for line in _read_file(MEMINFO_PATH).splitlines(): + if ":" not in line: + continue + key, _, rest = line.partition(":") + parts = rest.strip().split() + if not parts: + continue + try: + value_kb = int(parts[0]) + except ValueError: + continue + info[key] = value_kb * 1024 # bytes + total = info.get("MemTotal", 0) + available = info.get("MemAvailable", 0) + swap_total = info.get("SwapTotal", 0) + swap_free = info.get("SwapFree", 0) + return { + "total_bytes": total, + "used_bytes": max(total - available, 0), + "available_bytes": available, + "swap_used_bytes": max(swap_total - swap_free, 0), + } + + +def collect_storage(mounts: list[str]) -> list[dict]: + out: list[dict] = [] + for m in mounts: + try: + u = shutil.disk_usage(m) + except (FileNotFoundError, PermissionError): + continue + out.append({"mount": m, "total_bytes": u.total, "used_bytes": u.used}) + return out + + +def collect_load() -> dict: + parts = _read_file(LOADAVG_PATH).split() + return {"1m": float(parts[0]), "5m": float(parts[1]), "15m": float(parts[2])} + + +def collect_uptime() -> int: + return int(float(_read_file(UPTIME_PATH).split()[0])) + + +def collect_metadata() -> dict: + u = os.uname() + distro = "unknown" + try: + for line in _read_file(OS_RELEASE_PATH).splitlines(): + if line.startswith("PRETTY_NAME="): + distro = line.split("=", 1)[1].strip().strip('"') + break + except (OSError, FileNotFoundError): + pass + return {"kernel": u.release, "distro": distro, "arch": u.machine} + + +def default_mounts() -> list[str]: + """Return real mount points from /proc/mounts, skipping pseudo filesystems.""" + skip_types = {"tmpfs", "devtmpfs", "proc", "sysfs", "cgroup", "cgroup2", + "overlay", "squashfs", "ramfs", "devpts", "mqueue", "pstore", + "securityfs", "debugfs", "tracefs", "hugetlbfs", "fusectl", + "configfs", "bpf", "autofs", "efivarfs", "binfmt_misc"} + mounts: list[str] = [] + try: + with open("/proc/mounts", "r") as f: + for line in f: + parts = line.split() + if len(parts) < 3: + continue + if parts[2] in skip_types: + continue + mounts.append(parts[1]) + except OSError: + return ["/"] + return mounts or ["/"] +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `pytest tests/plugins/host_agent/test_agent_collectors.py -v` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add plugins/host_agent/agent.py tests/plugins/host_agent/test_agent_collectors.py +git commit -m "feat(host_agent): agent resource collectors" +``` + +--- + +## Task 4: Agent payload builder + ring buffer + +**Files:** +- Modify: `plugins/host_agent/agent.py` — add `RingBuffer`, `build_payload()`. +- Create: `tests/plugins/host_agent/test_agent_ring_buffer.py` +- Create: `tests/plugins/host_agent/test_agent_build_payload.py` + +- [ ] **Step 1: Write failing ring buffer test** + +```python +# tests/plugins/host_agent/test_agent_ring_buffer.py +from plugins.host_agent.agent import RingBuffer + + +def test_ring_buffer_preserves_order(): + rb = RingBuffer(maxlen=3) + rb.push(1); rb.push(2); rb.push(3) + assert list(rb.drain()) == [1, 2, 3] + assert len(rb) == 0 + + +def test_ring_buffer_drops_oldest_when_full(): + rb = RingBuffer(maxlen=3) + for v in (1, 2, 3, 4, 5): + rb.push(v) + assert list(rb.drain()) == [3, 4, 5] + + +def test_ring_buffer_drain_is_atomic(): + rb = RingBuffer(maxlen=5) + rb.push("a"); rb.push("b") + out = list(rb.drain()) + rb.push("c") + assert out == ["a", "b"] + assert list(rb.drain()) == ["c"] +``` + +- [ ] **Step 2: Write failing build_payload test** + +```python +# tests/plugins/host_agent/test_agent_build_payload.py +from unittest.mock import patch +from plugins.host_agent import agent as a + + +def test_build_payload_shape(): + fake_sample = { + "ts": "2026-04-14T00:00:00+00:00", + "cpu_pct": 12.5, + "mem": {"total_bytes": 100, "used_bytes": 40, + "available_bytes": 60, "swap_used_bytes": 0}, + "load": {"1m": 0.1, "5m": 0.2, "15m": 0.3}, + "uptime_secs": 1234, + "storage": [{"mount": "/", "total_bytes": 1000, "used_bytes": 400}], + } + metadata = {"kernel": "6.8", "distro": "Ubuntu", "arch": "x86_64"} + + with patch.object(a, "collect_cpu", return_value=12.5), \ + patch.object(a, "collect_memory", return_value=fake_sample["mem"]), \ + patch.object(a, "collect_load", return_value=fake_sample["load"]), \ + patch.object(a, "collect_uptime", return_value=1234), \ + patch.object(a, "collect_storage", return_value=fake_sample["storage"]): + sample = a.build_sample(mounts=["/"]) + + payload = a.build_payload( + samples=[sample], + hostname="myhost", + metadata=metadata, + ) + assert payload["agent_version"] == a.AGENT_VERSION + assert payload["hostname"] == "myhost" + assert payload["metadata"] == metadata + assert len(payload["samples"]) == 1 + s = payload["samples"][0] + assert s["cpu_pct"] == 12.5 + assert s["mem"]["used_bytes"] == 40 + assert s["load"]["1m"] == 0.1 + assert s["storage"][0]["mount"] == "/" + assert "ts" in s +``` + +- [ ] **Step 3: Run — expect failures** + +Run: `pytest tests/plugins/host_agent/test_agent_ring_buffer.py tests/plugins/host_agent/test_agent_build_payload.py -v` +Expected: FAIL. + +- [ ] **Step 4: Append to `plugins/host_agent/agent.py`** + +```python +# ─── ring buffer ───────────────────────────────────────────────────────────── + + +class RingBuffer: + def __init__(self, maxlen: int = 20) -> None: + self._dq: deque = deque(maxlen=maxlen) + + def push(self, item) -> None: + self._dq.append(item) + + def drain(self) -> list: + out = list(self._dq) + self._dq.clear() + return out + + def __len__(self) -> int: + return len(self._dq) + + +# ─── payload ───────────────────────────────────────────────────────────────── + + +def build_sample(mounts: list[str]) -> dict: + """Collect one full sample. Partial samples allowed if a collector fails.""" + sample: dict = {"ts": datetime.now(timezone.utc).isoformat()} + try: + sample["cpu_pct"] = collect_cpu() + except Exception: + sample["cpu_pct"] = None + try: + sample["mem"] = collect_memory() + except Exception: + sample["mem"] = None + try: + sample["load"] = collect_load() + except Exception: + sample["load"] = None + try: + sample["uptime_secs"] = collect_uptime() + except Exception: + sample["uptime_secs"] = None + try: + sample["storage"] = collect_storage(mounts) + except Exception: + sample["storage"] = [] + return sample + + +def build_payload(samples: list[dict], hostname: str, metadata: dict) -> dict: + return { + "agent_version": AGENT_VERSION, + "hostname": hostname, + "metadata": metadata, + "samples": samples, + } +``` + +- [ ] **Step 5: Run — expect pass** + +Run: `pytest tests/plugins/host_agent/test_agent_ring_buffer.py tests/plugins/host_agent/test_agent_build_payload.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add plugins/host_agent/agent.py tests/plugins/host_agent/test_agent_ring_buffer.py tests/plugins/host_agent/test_agent_build_payload.py +git commit -m "feat(host_agent): agent ring buffer and payload builder" +``` + +--- + +## Task 5: Agent POST + backoff + main loop + +**Files:** +- Modify: `plugins/host_agent/agent.py` — add `post_payload`, backoff, `main_loop`, `__main__` guard. + +The main loop does not get a direct unit test — it's thin glue over already-tested pieces and a real sleep loop. We test `post_payload` and the backoff schedule in isolation instead. + +- [ ] **Step 1: Add test for post_payload and backoff schedule** + +```python +# Append to tests/plugins/host_agent/test_agent_build_payload.py +import urllib.error +from unittest.mock import MagicMock, patch + + +def test_post_payload_success(): + from plugins.host_agent import agent as a + captured = {} + + class FakeResp: + status = 200 + def __enter__(self): return self + def __exit__(self, *a): return False + def read(self): return b'{"ok":true}' + + def fake_urlopen(req, timeout=10): + captured["url"] = req.full_url + captured["headers"] = dict(req.header_items()) + captured["body"] = req.data + return FakeResp() + + with patch.object(a.urllib.request, "urlopen", side_effect=fake_urlopen): + ok, status = a.post_payload("https://x", "tok", {"hello": "world"}) + assert ok is True + assert status == 200 + assert captured["url"] == "https://x/plugins/host_agent/ingest" + assert captured["headers"]["Authorization"] == "Bearer tok" + + +def test_post_payload_http_error_returns_status(): + from plugins.host_agent import agent as a + + def raise_401(req, timeout=10): + raise urllib.error.HTTPError(req.full_url, 401, "unauthorized", {}, None) + + with patch.object(a.urllib.request, "urlopen", side_effect=raise_401): + ok, status = a.post_payload("https://x", "tok", {}) + assert ok is False + assert status == 401 + + +def test_post_payload_network_error_returns_none_status(): + from plugins.host_agent import agent as a + + def raise_url(req, timeout=10): + raise urllib.error.URLError("conn refused") + + with patch.object(a.urllib.request, "urlopen", side_effect=raise_url): + ok, status = a.post_payload("https://x", "tok", {}) + assert ok is False + assert status is None + + +def test_backoff_schedule(): + from plugins.host_agent.agent import next_backoff + assert next_backoff(0) == 30 + assert next_backoff(30) == 60 + assert next_backoff(60) == 120 + assert next_backoff(120) == 240 + assert next_backoff(240) == 300 # capped + assert next_backoff(300) == 300 +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `pytest tests/plugins/host_agent/test_agent_build_payload.py -v` +Expected: FAIL on the new tests (`post_payload`, `next_backoff` missing). + +- [ ] **Step 3: Append to `plugins/host_agent/agent.py`** + +```python +# ─── POST + backoff ────────────────────────────────────────────────────────── + + +BACKOFF_CAP = 300 + + +def next_backoff(current: int) -> int: + if current <= 0: + return 30 + return min(current * 2, BACKOFF_CAP) + + +def post_payload(url: str, token: str, payload: dict) -> tuple[bool, int | None]: + body = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url.rstrip("/") + "/plugins/host_agent/ingest", + data=body, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + "User-Agent": f"roundtable-host-agent/{AGENT_VERSION}", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return (200 <= resp.status < 300, resp.status) + except urllib.error.HTTPError as e: + return (False, e.code) + except (urllib.error.URLError, TimeoutError, socket.timeout, OSError): + return (False, None) + + +# ─── main loop ─────────────────────────────────────────────────────────────── + + +_reload_requested = False +_shutdown_requested = False + + +def _handle_hup(_signum, _frame): + global _reload_requested + _reload_requested = True + + +def _handle_term(_signum, _frame): + global _shutdown_requested + _shutdown_requested = True + + +def _log(level: str, msg: str) -> None: + sys.stderr.write(f"[{level}] {msg}\n") + sys.stderr.flush() + + +def main_loop(conf_path: str) -> int: + global _reload_requested, _shutdown_requested + signal.signal(signal.SIGHUP, _handle_hup) + signal.signal(signal.SIGTERM, _handle_term) + + try: + cfg = read_config(conf_path) + except ConfigError as e: + _log("ERROR", str(e)) + return 2 + + metadata = collect_metadata() + hostname = cfg.get("hostname") or socket.gethostname() + mounts = cfg.get("mounts") or default_mounts() + buffer = RingBuffer(maxlen=20) + backoff = 0 + + _log("INFO", f"roundtable-host-agent {AGENT_VERSION} starting " + f"(url={cfg['url']}, interval={cfg['interval_seconds']}s)") + + while not _shutdown_requested: + if _reload_requested: + try: + cfg = read_config(conf_path) + mounts = cfg.get("mounts") or default_mounts() + _log("INFO", "config reloaded") + except ConfigError as e: + _log("ERROR", f"reload failed: {e}") + _reload_requested = False + + sample = build_sample(mounts) + buffered = buffer.drain() + payload = build_payload( + samples=buffered + [sample], + hostname=hostname, + metadata=metadata, + ) + ok, status = post_payload(cfg["url"], cfg["token"], payload) + + if ok: + if buffered: + _log("INFO", f"flushed {len(buffered)} buffered samples") + backoff = 0 + sleep_for = cfg["interval_seconds"] + else: + if status == 400: + _log("ERROR", "server rejected payload (400) — dropping sample") + # Don't re-buffer — broken payload will just be rejected again. + elif status == 401: + _log("ERROR", "token rejected (401) — check config + UI") + buffer.push(sample) + else: + _log("WARN", f"POST failed (status={status}); buffering") + # Re-buffer what we drained plus the new sample. + for s in buffered: + buffer.push(s) + buffer.push(sample) + backoff = next_backoff(backoff) + sleep_for = backoff + + # Interruptible sleep + slept = 0.0 + while slept < sleep_for and not _shutdown_requested and not _reload_requested: + time.sleep(min(1.0, sleep_for - slept)) + slept += 1.0 + + _log("INFO", "SIGTERM — flushing and exiting") + # Best-effort final flush. + final = buffer.drain() + if final: + post_payload(cfg["url"], cfg["token"], + build_payload(final, hostname, metadata)) + return 0 + + +if __name__ == "__main__": + conf = os.environ.get("ROUNDTABLE_AGENT_CONFIG", "/etc/roundtable-agent.conf") + sys.exit(main_loop(conf)) +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `pytest tests/plugins/host_agent -v` +Expected: PASS across all agent unit tests. + +- [ ] **Step 5: Line-count sanity check** + +Run: `wc -l plugins/host_agent/agent.py` +Expected: under ~350 lines. If significantly over, review for over-scoping. + +- [ ] **Step 6: Commit** + +```bash +git add plugins/host_agent/agent.py tests/plugins/host_agent/test_agent_build_payload.py +git commit -m "feat(host_agent): agent POST, backoff, and main loop" +``` + +--- + +## Task 6: Ingest route (happy path) + +**Files:** +- Modify: `plugins/host_agent/routes.py` +- Create: `tests/plugins/host_agent/conftest.py` +- Create: `tests/plugins/host_agent/test_ingest_route.py` + +- [ ] **Step 1: Write plugin-local test fixtures** + +```python +# tests/plugins/host_agent/conftest.py +"""Fixtures for host_agent plugin tests.""" +import hashlib +import pytest_asyncio + +from roundtable.models.hosts import Host +from roundtable.core.db import get_session +from plugins.host_agent.models import HostAgentRegistration + + +@pytest_asyncio.fixture +async def registered_host(app): + """Create a Host + HostAgentRegistration with a known raw token.""" + raw_token = "test-token-abcdef" + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + + async with get_session() as session: + host = Host(name="testhost", address="10.0.0.1") + session.add(host) + await session.flush() + reg = HostAgentRegistration( + host_id=host.id, + token_hash=token_hash, + ) + session.add(reg) + await session.commit() + return {"host": host, "registration": reg, "token": raw_token} +``` + +> **Note:** Verify `Host.__init__` accepts `name=` and `address=` kwargs before relying on this. If the real Host model requires more fields (e.g., `probe_type`), pass them here. Read `roundtable/models/hosts.py` first to confirm. + +- [ ] **Step 2: Write failing ingest test** + +```python +# tests/plugins/host_agent/test_ingest_route.py +import json +from datetime import datetime, timezone +import pytest +from sqlalchemy import select + +from roundtable.models.metrics import PluginMetric +from roundtable.core.db import get_session +from plugins.host_agent.models import HostAgentRegistration + +pytestmark = pytest.mark.asyncio + + +def _sample() -> dict: + return { + "ts": datetime.now(timezone.utc).isoformat(), + "cpu_pct": 12.5, + "mem": { + "total_bytes": 16_000_000_000, + "used_bytes": 8_000_000_000, + "available_bytes": 8_000_000_000, + "swap_used_bytes": 0, + }, + "load": {"1m": 0.1, "5m": 0.2, "15m": 0.3}, + "uptime_secs": 1234, + "storage": [ + {"mount": "/", "total_bytes": 1000, "used_bytes": 400}, + {"mount": "/mnt/data", "total_bytes": 2000, "used_bytes": 1800}, + ], + } + + +def _payload() -> dict: + return { + "agent_version": "1.0.0", + "hostname": "testhost", + "metadata": {"kernel": "6.8", "distro": "Ubuntu 24.04", "arch": "x86_64"}, + "samples": [_sample()], + } + + +async def test_ingest_happy_path_writes_metrics(client, registered_host): + resp = await client.post( + "/plugins/host_agent/ingest", + headers={"Authorization": f"Bearer {registered_host['token']}", + "Content-Type": "application/json"}, + data=json.dumps(_payload()), + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["ok"] is True + assert body["samples_accepted"] == 1 + + async with get_session() as session: + rows = (await session.execute( + select(PluginMetric).where(PluginMetric.source_module == "host_agent") + )).scalars().all() + metric_names = {r.metric_name for r in rows} + assert "cpu_pct" in metric_names + assert "mem_used_pct" in metric_names + assert "mem_available_bytes" in metric_names + assert "disk_used_pct_worst" in metric_names + assert "load_1m" in metric_names + + # Per-mount rows use ":" resource_name. + per_mount = [r for r in rows if r.metric_name == "disk_used_pct"] + assert {r.resource_name for r in per_mount} == {"testhost:/", "testhost:/mnt/data"} + # Worst mount is /mnt/data at 90%. + worst = [r for r in rows if r.metric_name == "disk_used_pct_worst"] + assert len(worst) == 1 + assert worst[0].value == pytest.approx(90.0, abs=0.1) + + +async def test_ingest_updates_registration(client, registered_host): + resp = await client.post( + "/plugins/host_agent/ingest", + headers={"Authorization": f"Bearer {registered_host['token']}"}, + data=json.dumps(_payload()), + ) + assert resp.status_code == 200 + + async with get_session() as session: + reg = (await session.execute( + select(HostAgentRegistration).where( + HostAgentRegistration.host_id == registered_host["host"].id) + )).scalar_one() + assert reg.last_seen_at is not None + assert reg.agent_version == "1.0.0" + assert reg.kernel == "6.8" + assert reg.distro == "Ubuntu 24.04" + assert reg.arch == "x86_64" +``` + +- [ ] **Step 3: Run — expect failure** + +Run: `pytest tests/plugins/host_agent/test_ingest_route.py -v` +Expected: FAIL (route returns 404). + +- [ ] **Step 4: Implement ingest route** + +Replace `plugins/host_agent/routes.py`: + +```python +# plugins/host_agent/routes.py +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any + +from quart import Blueprint, jsonify, request +from sqlalchemy import select + +from roundtable.core.db import get_session +from roundtable.models.hosts import Host +from roundtable.models.metrics import PluginMetric +from .models import HostAgentRegistration + +host_agent_bp = Blueprint("host_agent", __name__, template_folder="templates") + +SOURCE_MODULE = "host_agent" + + +def _hash_token(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _parse_ts(ts: str) -> datetime: + # Python's fromisoformat in 3.11+ handles trailing Z; be lenient anyway. + if ts.endswith("Z"): + ts = ts[:-1] + "+00:00" + return datetime.fromisoformat(ts) + + +def _expand_sample_to_metrics( + sample: dict, host_name: str, recorded_at: datetime +) -> list[PluginMetric]: + rows: list[PluginMetric] = [] + + def row(metric: str, resource: str, value: float) -> None: + rows.append(PluginMetric( + source_module=SOURCE_MODULE, + resource_name=resource, + metric_name=metric, + value=float(value), + recorded_at=recorded_at, + )) + + if sample.get("cpu_pct") is not None: + row("cpu_pct", host_name, sample["cpu_pct"]) + + mem = sample.get("mem") or {} + if mem.get("total_bytes"): + total = mem["total_bytes"] + available = mem.get("available_bytes", 0) + used_pct = 100.0 * (total - available) / total if total else 0.0 + row("mem_used_pct", host_name, used_pct) + row("mem_available_bytes", host_name, available) + row("swap_used_bytes", host_name, mem.get("swap_used_bytes", 0)) + + load = sample.get("load") or {} + for key in ("1m", "5m", "15m"): + if key in load: + row(f"load_{key}", host_name, load[key]) + + if sample.get("uptime_secs") is not None: + row("uptime_secs", host_name, sample["uptime_secs"]) + + worst_pct = 0.0 + for disk in sample.get("storage") or []: + total = disk.get("total_bytes", 0) + used = disk.get("used_bytes", 0) + pct = 100.0 * used / total if total else 0.0 + mount_res = f"{host_name}:{disk['mount']}" + row("disk_used_pct", mount_res, pct) + row("disk_used_bytes", mount_res, used) + row("disk_total_bytes", mount_res, total) + if pct > worst_pct: + worst_pct = pct + if sample.get("storage"): + row("disk_used_pct_worst", host_name, worst_pct) + + return rows + + +def _error(status: int, code: str, detail: str | None = None) -> tuple[Any, int]: + body: dict = {"ok": False, "error": code} + if detail: + body["detail"] = detail + return jsonify(body), status + + +@host_agent_bp.post("/ingest") +async def ingest(): + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return _error(401, "invalid_token") + raw = auth[len("Bearer "):].strip() + if not raw: + return _error(401, "invalid_token") + + try: + payload = await request.get_json(force=True) + except Exception: + return _error(400, "malformed_payload", "invalid JSON") + if not isinstance(payload, dict) or "samples" not in payload: + return _error(400, "malformed_payload", "missing 'samples'") + + samples = payload.get("samples") or [] + if not isinstance(samples, list) or not samples: + return _error(400, "malformed_payload", "'samples' must be a non-empty list") + + token_hash = _hash_token(raw) + async with get_session() as session: + reg = (await session.execute( + select(HostAgentRegistration).where( + HostAgentRegistration.token_hash == token_hash) + )).scalar_one_or_none() + if reg is None: + return _error(401, "invalid_token") + host = (await session.execute( + select(Host).where(Host.id == reg.host_id) + )).scalar_one_or_none() + if host is None: + return _error(401, "invalid_token") + + accepted = 0 + latest_ts: datetime | None = None + for sample in samples: + try: + recorded_at = _parse_ts(sample["ts"]) + except (KeyError, ValueError): + continue + metrics = _expand_sample_to_metrics(sample, host.name, recorded_at) + for m in metrics: + session.add(m) + accepted += 1 + if latest_ts is None or recorded_at > latest_ts: + latest_ts = recorded_at + + if accepted == 0: + await session.rollback() + return _error(400, "malformed_payload", "no valid samples") + + md = payload.get("metadata") or {} + changed = False + if latest_ts and (reg.last_seen_at is None or latest_ts > reg.last_seen_at): + reg.last_seen_at = latest_ts + changed = True + version = payload.get("agent_version") + if version and reg.agent_version != version: + reg.agent_version = version + changed = True + for field in ("kernel", "distro", "arch"): + if field in md and getattr(reg, field) != md[field]: + setattr(reg, field, md[field]) + changed = True + if changed: + reg.updated_at = datetime.now(timezone.utc) + + # Clock-skew warning — accept but log. + if latest_ts: + skew = abs((datetime.now(timezone.utc) - latest_ts).total_seconds()) + if skew > 300: + from quart import current_app + current_app.logger.warning( + "host_agent ingest: clock skew %.0fs for host=%s", skew, host.name) + + await session.commit() + + return jsonify({"ok": True, "samples_accepted": accepted}), 200 +``` + +- [ ] **Step 5: Run — expect pass** + +Run: `pytest tests/plugins/host_agent/test_ingest_route.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add plugins/host_agent/routes.py tests/plugins/host_agent/conftest.py tests/plugins/host_agent/test_ingest_route.py +git commit -m "feat(host_agent): ingest endpoint with token auth and metric expansion" +``` + +--- + +## Task 7: Ingest error paths (token/malformed) + +**Files:** +- Modify: `tests/plugins/host_agent/test_ingest_route.py` + +- [ ] **Step 1: Add failing tests for error paths** + +```python +# Append to tests/plugins/host_agent/test_ingest_route.py + +async def test_ingest_rejects_missing_auth(client): + resp = await client.post( + "/plugins/host_agent/ingest", + data=json.dumps(_payload()), + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 401 + body = await resp.get_json() + assert body["error"] == "invalid_token" + + +async def test_ingest_rejects_unknown_token(client): + resp = await client.post( + "/plugins/host_agent/ingest", + headers={"Authorization": "Bearer no-such-token"}, + data=json.dumps(_payload()), + ) + assert resp.status_code == 401 + + +async def test_ingest_rejects_malformed_body(client, registered_host): + resp = await client.post( + "/plugins/host_agent/ingest", + headers={"Authorization": f"Bearer {registered_host['token']}"}, + data="not json", + ) + assert resp.status_code == 400 + + +async def test_ingest_rejects_missing_samples(client, registered_host): + resp = await client.post( + "/plugins/host_agent/ingest", + headers={"Authorization": f"Bearer {registered_host['token']}"}, + data=json.dumps({"agent_version": "1.0.0"}), + ) + assert resp.status_code == 400 + + +async def test_ingest_partial_sample_without_cpu(client, registered_host): + payload = _payload() + payload["samples"][0]["cpu_pct"] = None + resp = await client.post( + "/plugins/host_agent/ingest", + headers={"Authorization": f"Bearer {registered_host['token']}"}, + data=json.dumps(payload), + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["samples_accepted"] == 1 +``` + +- [ ] **Step 2: Run** + +Run: `pytest tests/plugins/host_agent/test_ingest_route.py -v` +Expected: PASS (error handling is already implemented in Task 6). + +- [ ] **Step 3: Commit** + +```bash +git add tests/plugins/host_agent/test_ingest_route.py +git commit -m "test(host_agent): ingest error path coverage" +``` + +--- + +## Task 8: install.sh and agent.py serving routes + +**Files:** +- Create: `plugins/host_agent/templates/install.sh.j2` +- Modify: `plugins/host_agent/routes.py` +- Create: `tests/plugins/host_agent/test_install_route.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/plugins/host_agent/test_install_route.py +import pytest + +pytestmark = pytest.mark.asyncio + + +async def test_install_sh_requires_valid_token(client): + resp = await client.get("/plugins/host_agent/install.sh?token=bogus") + assert resp.status_code == 401 + + +async def test_install_sh_renders_with_token(client, registered_host): + resp = await client.get( + f"/plugins/host_agent/install.sh?token={registered_host['token']}") + assert resp.status_code == 200 + assert resp.content_type.startswith("text/plain") + text = (await resp.get_data()).decode() + assert "roundtable-agent" in text + assert registered_host["token"] in text + assert "systemctl enable --now" in text + assert "NoNewPrivileges=yes" in text + assert "--uninstall" in text + + +async def test_agent_py_serves_script(client): + resp = await client.get("/plugins/host_agent/agent.py") + assert resp.status_code == 200 + assert resp.content_type.startswith("text/x-python") + text = (await resp.get_data()).decode() + assert "def main_loop" in text + assert "AGENT_VERSION" in text +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `pytest tests/plugins/host_agent/test_install_route.py -v` +Expected: FAIL (routes missing). + +- [ ] **Step 3: Create `plugins/host_agent/templates/install.sh.j2`** + +Paste the full install script from the spec (§ Install script → Rendered script structure, `docs/plugins/host-agent-design.md:327-409`). Substitute the Jinja variables `{{ url }}`, `{{ token }}`, `{{ agent_version }}`, `{{ host_name }}`, `{{ host_address }}`. Do NOT edit the script structure — the spec is the source of truth. + +- [ ] **Step 4: Add routes to `plugins/host_agent/routes.py`** + +Append imports: + +```python +from pathlib import Path +from quart import render_template, Response, current_app +``` + +Append routes: + +```python +AGENT_SOURCE_PATH = Path(__file__).parent / "agent.py" + + +@host_agent_bp.get("/install.sh") +async def install_script(): + token = request.args.get("token", "") + if not token: + return _error(401, "invalid_token") + async with get_session() as session: + reg = (await session.execute( + select(HostAgentRegistration).where( + HostAgentRegistration.token_hash == _hash_token(token)) + )).scalar_one_or_none() + if reg is None: + return _error(401, "invalid_token") + host = (await session.execute( + select(Host).where(Host.id == reg.host_id) + )).scalar_one_or_none() + if host is None: + return _error(401, "invalid_token") + + # Build the public URL the agent should POST to. + url = request.host_url.rstrip("/") + + rendered = await render_template( + "install.sh.j2", + url=url, + token=token, + agent_version=_agent_version(), + host_name=host.name, + host_address=host.address, + ) + return Response(rendered, mimetype="text/plain") + + +@host_agent_bp.get("/agent.py") +async def agent_source(): + try: + body = AGENT_SOURCE_PATH.read_text(encoding="utf-8") + except OSError: + return _error(500, "agent_missing") + return Response(body, mimetype="text/x-python") + + +def _agent_version() -> str: + # Lightweight parse of agent.py for AGENT_VERSION. Avoids importing the + # agent module (which has signal-handler and main-loop side effects). + try: + for line in AGENT_SOURCE_PATH.read_text().splitlines(): + if line.startswith("AGENT_VERSION"): + return line.split("=", 1)[1].strip().strip('"').strip("'") + except OSError: + pass + return "unknown" +``` + +- [ ] **Step 5: Run — expect pass** + +Run: `pytest tests/plugins/host_agent/test_install_route.py -v` +Expected: PASS. + +- [ ] **Step 6: Shellcheck the rendered install script** + +Run: +```bash +python -c " +from jinja2 import Environment, FileSystemLoader +env = Environment(loader=FileSystemLoader('plugins/host_agent/templates')) +print(env.get_template('install.sh.j2').render( + url='https://r.example', token='tok', agent_version='1.0.0', + host_name='test', host_address='10.0.0.1')) +" > /tmp/install.sh +sh -n /tmp/install.sh +command -v shellcheck >/dev/null && shellcheck /tmp/install.sh || echo "shellcheck not installed (optional)" +``` +Expected: `sh -n` clean. shellcheck warnings optional. + +- [ ] **Step 7: Commit** + +```bash +git add plugins/host_agent/routes.py plugins/host_agent/templates/install.sh.j2 tests/plugins/host_agent/test_install_route.py +git commit -m "feat(host_agent): install.sh and agent.py serving routes" +``` + +--- + +## Task 9: Settings routes (add, rotate, delete) + +**Files:** +- Modify: `plugins/host_agent/routes.py` +- Create: `plugins/host_agent/templates/settings_list.html` +- Create: `tests/plugins/host_agent/test_settings_routes.py` + +Auth note: the existing Roundtable admin decorator pattern needs to be used here. Read `roundtable/settings/routes.py` (or similar) to find the decorator name — likely something like `@require_admin` or a session check. Use whatever the rest of the codebase uses. **Do not invent a new auth mechanism.** + +- [ ] **Step 1: Find the admin auth decorator** + +Run: `grep -rn "require_admin\|@admin_required\|def admin" roundtable/settings/ roundtable/core/auth.py 2>/dev/null | head -20` +Note the decorator name and import path for use in Step 3. + +- [ ] **Step 2: Write failing test** + +```python +# tests/plugins/host_agent/test_settings_routes.py +import pytest +from sqlalchemy import select + +from roundtable.core.db import get_session +from roundtable.models.hosts import Host +from plugins.host_agent.models import HostAgentRegistration + +pytestmark = pytest.mark.asyncio + + +async def test_add_host_creates_registration_and_returns_install_url(admin_client): + resp = await admin_client.post( + "/plugins/host_agent/settings/add-host", + form={"name": "newhost", "address": "10.0.0.99"}, + ) + assert resp.status_code in (200, 302) + + async with get_session() as session: + host = (await session.execute( + select(Host).where(Host.name == "newhost"))).scalar_one() + reg = (await session.execute( + select(HostAgentRegistration).where( + HostAgentRegistration.host_id == host.id))).scalar_one() + assert reg.token_hash # hash persisted + assert len(reg.token_hash) == 64 # sha256 hex + + +async def test_rotate_token_changes_hash(admin_client, registered_host): + old_hash = registered_host["registration"].token_hash + resp = await admin_client.post( + f"/plugins/host_agent/settings/{registered_host['host'].id}/rotate-token") + assert resp.status_code in (200, 302) + + async with get_session() as session: + reg = (await session.execute( + select(HostAgentRegistration).where( + HostAgentRegistration.host_id == registered_host["host"].id) + )).scalar_one() + assert reg.token_hash != old_hash + + +async def test_delete_registration(admin_client, registered_host): + resp = await admin_client.post( + f"/plugins/host_agent/settings/{registered_host['host'].id}/delete") + assert resp.status_code in (200, 302) + + async with get_session() as session: + reg = (await session.execute( + select(HostAgentRegistration).where( + HostAgentRegistration.host_id == registered_host["host"].id) + )).scalar_one_or_none() + assert reg is None +``` + +> **The `admin_client` fixture**: Check `tests/conftest.py` for an existing admin-authenticated test client. If none exists, add one that logs in as an admin session before returning the client. This is an existing codebase pattern — don't reinvent it. Use whatever other plugin tests use for authenticated routes (e.g., grep `admin_client\|auth.*client` under `tests/`). + +- [ ] **Step 3: Run — expect failure** + +Run: `pytest tests/plugins/host_agent/test_settings_routes.py -v` + +- [ ] **Step 4: Implement routes** + +Append to `plugins/host_agent/routes.py`: + +```python +import secrets +from quart import redirect, url_for + +# TODO: replace _require_admin with the project-wide decorator found in Step 1. +# Placeholder below mirrors the shape; swap for real admin auth. +from roundtable.core.auth import require_admin # adjust import to actual path + + +def _new_token_pair() -> tuple[str, str]: + raw = secrets.token_urlsafe(32) + return raw, _hash_token(raw) + + +@host_agent_bp.post("/settings/add-host") +@require_admin +async def add_host(): + form = await request.form + name = (form.get("name") or "").strip() + address = (form.get("address") or "").strip() + if not name: + return _error(400, "missing_name") + + async with get_session() as session: + host = (await session.execute( + select(Host).where(Host.name == name))).scalar_one_or_none() + if host is None: + host = Host(name=name, address=address) + session.add(host) + await session.flush() + existing = (await session.execute( + select(HostAgentRegistration).where( + HostAgentRegistration.host_id == host.id))).scalar_one_or_none() + if existing is not None: + await session.rollback() + return _error(400, "already_registered") + + raw, hashed = _new_token_pair() + reg = HostAgentRegistration(host_id=host.id, token_hash=hashed) + session.add(reg) + await session.commit() + + # Store raw token in flash / query for one-time display on the settings page. + return redirect(url_for("host_agent.settings_list") + f"?new_token={raw}&host_id={host.id}") + + +@host_agent_bp.post("/settings//rotate-token") +@require_admin +async def rotate_token(host_id: str): + async with get_session() as session: + reg = (await session.execute( + select(HostAgentRegistration).where( + HostAgentRegistration.host_id == host_id))).scalar_one_or_none() + if reg is None: + return _error(404, "not_found") + raw, hashed = _new_token_pair() + reg.token_hash = hashed + reg.token_created_at = datetime.now(timezone.utc) + await session.commit() + return redirect(url_for("host_agent.settings_list") + f"?new_token={raw}&host_id={host_id}") + + +@host_agent_bp.post("/settings//delete") +@require_admin +async def delete_registration(host_id: str): + async with get_session() as session: + reg = (await session.execute( + select(HostAgentRegistration).where( + HostAgentRegistration.host_id == host_id))).scalar_one_or_none() + if reg is not None: + await session.delete(reg) + await session.commit() + return redirect(url_for("host_agent.settings_list")) + + +@host_agent_bp.get("/settings/") +@require_admin +async def settings_list(): + async with get_session() as session: + regs = (await session.execute(select(HostAgentRegistration))).scalars().all() + hosts_by_id = { + h.id: h for h in (await session.execute( + select(Host).where(Host.id.in_([r.host_id for r in regs])))).scalars().all() + } if regs else {} + + new_token = request.args.get("new_token") + new_host_id = request.args.get("host_id") + install_url = None + if new_token and new_host_id: + install_url = f"{request.host_url.rstrip('/')}/plugins/host_agent/install.sh?token={new_token}" + + return await render_template( + "settings_list.html", + registrations=[ + {"reg": r, "host": hosts_by_id.get(r.host_id)} for r in regs + ], + new_token=new_token, + install_url=install_url, + ) +``` + +- [ ] **Step 5: Create `plugins/host_agent/templates/settings_list.html`** + +```html +{% extends "base.html" %} +{% block title %}Host Agent — Roundtable{% endblock %} +{% block content %} +

Host Agent — Registered Hosts

+ +{% if new_token %} +
+

New token — copy this install command now

+

+ This is the only time the raw token is shown. Rotate if you lose it. +

+
curl -sSL '{{ install_url }}' | sudo sh
+
+{% endif %} + +
+
+
+ + +
+
+ + +
+ +
+
+ +
+ + + + + + + + + {% for item in registrations %} + + + + + + + + {% else %} + + {% endfor %} + +
HostAgent versionDistroLast seenActions
{{ item.host.name if item.host else item.reg.host_id }}{{ item.reg.agent_version or "—" }}{{ item.reg.distro or "—" }}{{ item.reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") if item.reg.last_seen_at else "never" }} +
+ +
+
+ +
+
No hosts registered. Add one above.
+
+{% endblock %} +``` + +- [ ] **Step 6: Run tests — iterate on auth decorator if needed** + +Run: `pytest tests/plugins/host_agent/test_settings_routes.py -v` +Expected: PASS. If admin auth fails, the actual import path from Step 1 is wrong — fix the `from roundtable.core.auth import require_admin` line. + +- [ ] **Step 7: Commit** + +```bash +git add plugins/host_agent/routes.py plugins/host_agent/templates/settings_list.html tests/plugins/host_agent/test_settings_routes.py +git commit -m "feat(host_agent): plugin settings page — add, rotate, delete" +``` + +--- + +## Task 10: Widgets + METRIC_CATALOG registration + +**Files:** +- Modify: `plugins/host_agent/routes.py` — add `/widget` and `/widget/history` partials. +- Create: `plugins/host_agent/templates/widget_table.html` +- Create: `plugins/host_agent/templates/widget_history.html` +- Modify: `roundtable/core/widgets.py` +- Modify: `roundtable/alerts/routes.py` + +- [ ] **Step 1: Add widget partial routes to `plugins/host_agent/routes.py`** + +```python +@host_agent_bp.get("/widget") +async def widget_table(): + """Fleet-glance table: one row per monitored host, latest metrics.""" + from sqlalchemy import func + async with get_session() as session: + regs = (await session.execute(select(HostAgentRegistration))).scalars().all() + host_ids = [r.host_id for r in regs] + hosts = { + h.id: h for h in (await session.execute( + select(Host).where(Host.id.in_(host_ids)) + )).scalars().all() + } if host_ids else {} + + # Latest value per (resource, metric) for host_agent source. + subq = ( + select( + PluginMetric.resource_name, + PluginMetric.metric_name, + func.max(PluginMetric.recorded_at).label("max_ts"), + ) + .where(PluginMetric.source_module == SOURCE_MODULE) + .group_by(PluginMetric.resource_name, PluginMetric.metric_name) + ).subquery() + latest_rows = (await session.execute( + select(PluginMetric).join( + subq, + (PluginMetric.resource_name == subq.c.resource_name) & + (PluginMetric.metric_name == subq.c.metric_name) & + (PluginMetric.recorded_at == subq.c.max_ts), + ).where(PluginMetric.source_module == SOURCE_MODULE) + )).scalars().all() + + # Group by resource_name (host name); ignore per-mount entries for the table. + latest: dict[str, dict[str, float]] = {} + for row in latest_rows: + if ":" in row.resource_name: + continue + latest.setdefault(row.resource_name, {})[row.metric_name] = row.value + + rows = [] + for reg in regs: + host = hosts.get(reg.host_id) + if host is None: + continue + m = latest.get(host.name, {}) + rows.append({ + "host": host, + "reg": reg, + "cpu_pct": m.get("cpu_pct"), + "mem_used_pct": m.get("mem_used_pct"), + "disk_worst": m.get("disk_used_pct_worst"), + "load_1m": m.get("load_1m"), + }) + + return await render_template("widget_table.html", rows=rows) + + +@host_agent_bp.get("/widget/history") +async def widget_history(): + host_id = request.args.get("host_id", "") + hours = int(request.args.get("hours", "6")) + from datetime import timedelta + cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) + + async with get_session() as session: + host = (await session.execute( + select(Host).where(Host.id == host_id))).scalar_one_or_none() + if host is None: + return _error(404, "not_found") + points = (await session.execute( + select(PluginMetric).where( + PluginMetric.source_module == SOURCE_MODULE, + PluginMetric.resource_name == host.name, + PluginMetric.metric_name.in_( + ("cpu_pct", "mem_used_pct", "disk_used_pct_worst")), + PluginMetric.recorded_at >= cutoff, + ).order_by(PluginMetric.recorded_at) + )).scalars().all() + + series: dict[str, list[dict]] = {"cpu_pct": [], "mem_used_pct": [], "disk_used_pct_worst": []} + for p in points: + series[p.metric_name].append({"t": p.recorded_at.isoformat(), "v": p.value}) + + return await render_template("widget_history.html", host=host, series=series, hours=hours) +``` + +- [ ] **Step 2: Create `plugins/host_agent/templates/widget_table.html`** + +```html + + + + + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} + +
HostCPU %Mem %Disk % (worst)Load 1mLast seen
{{ r.host.name }}{{ "%.1f"|format(r.cpu_pct) if r.cpu_pct is not none else "—" }}{{ "%.1f"|format(r.mem_used_pct) if r.mem_used_pct is not none else "—" }}{{ "%.1f"|format(r.disk_worst) if r.disk_worst is not none else "—" }}{{ "%.2f"|format(r.load_1m) if r.load_1m is not none else "—" }}{{ r.reg.last_seen_at.strftime("%H:%M:%S") if r.reg.last_seen_at else "never" }}
No hosts with agent data yet.
+``` + +- [ ] **Step 3: Create `plugins/host_agent/templates/widget_history.html`** + +```html +
+

{{ host.name }} — last {{ hours }}h

+ + +
+``` + +- [ ] **Step 4: Register widgets in `roundtable/core/widgets.py`** + +Add to `WIDGET_REGISTRY` (alphabetically by existing convention, or at the end — check the file first): + +```python + "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"}, + ], + }, + ], + }, +``` + +- [ ] **Step 5: Register `host_agent` in `METRIC_CATALOG`** + +Read `roundtable/alerts/routes.py`. Locate the `METRIC_CATALOG` dict. Add: + +```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", + ], +``` + +- [ ] **Step 6: Smoke-test the widget partial via the running app** + +Run: `pytest tests/plugins/host_agent/test_ingest_route.py -v` (existing tests should still pass — confirms no import breakage.) + +Then manually verify via dev server or add a quick route-reachable test: + +```python +# Append to tests/plugins/host_agent/test_ingest_route.py +async def test_widget_table_renders(client, registered_host): + # Ingest something first + resp = await client.post( + "/plugins/host_agent/ingest", + headers={"Authorization": f"Bearer {registered_host['token']}"}, + data=json.dumps(_payload()), + ) + assert resp.status_code == 200 + + resp = await client.get("/plugins/host_agent/widget") + assert resp.status_code == 200 + text = (await resp.get_data()).decode() + assert "testhost" in text +``` + +Run: `pytest tests/plugins/host_agent/test_ingest_route.py::test_widget_table_renders -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add plugins/host_agent/routes.py plugins/host_agent/templates/widget_table.html plugins/host_agent/templates/widget_history.html roundtable/core/widgets.py roundtable/alerts/routes.py tests/plugins/host_agent/test_ingest_route.py +git commit -m "feat(host_agent): dashboard widgets and alert metric catalog entry" +``` + +--- + +## Task 11: Per-host detail page + +**Files:** +- Create: `plugins/host_agent/templates/detail.html` +- Modify: `plugins/host_agent/routes.py` + +- [ ] **Step 1: Add route** + +```python +@host_agent_bp.get("//") +async def host_detail(host_id: str): + from datetime import timedelta + cutoff = datetime.now(timezone.utc) - timedelta(hours=6) + async with get_session() as session: + host = (await session.execute( + select(Host).where(Host.id == host_id))).scalar_one_or_none() + if host is None: + return _error(404, "not_found") + reg = (await session.execute( + select(HostAgentRegistration).where( + HostAgentRegistration.host_id == host_id))).scalar_one_or_none() + + latest_rows = (await session.execute( + select(PluginMetric).where( + PluginMetric.source_module == SOURCE_MODULE, + PluginMetric.resource_name == host.name, + PluginMetric.recorded_at >= cutoff, + ).order_by(PluginMetric.recorded_at.desc()) + )).scalars().all() + per_mount = (await session.execute( + select(PluginMetric).where( + PluginMetric.source_module == SOURCE_MODULE, + PluginMetric.resource_name.like(f"{host.name}:%"), + PluginMetric.metric_name == "disk_used_pct", + PluginMetric.recorded_at >= cutoff, + ).order_by(PluginMetric.recorded_at.desc()) + )).scalars().all() + + # Latest value per metric name + current: dict[str, float] = {} + for row in latest_rows: + current.setdefault(row.metric_name, row.value) + mounts: dict[str, float] = {} + for row in per_mount: + mount = row.resource_name.split(":", 1)[1] + mounts.setdefault(mount, row.value) + + return await render_template( + "detail.html", host=host, reg=reg, current=current, mounts=mounts, + ) +``` + +- [ ] **Step 2: Create `plugins/host_agent/templates/detail.html`** + +```html +{% extends "base.html" %} +{% block title %}{{ host.name }} — Host Agent{% endblock %} +{% block content %} +

{{ host.name }}

+ +
+
+
CPU
+
{{ "%.1f"|format(current.cpu_pct) if current.cpu_pct is defined else "—" }}%
+
+
+
Memory
+
{{ "%.1f"|format(current.mem_used_pct) if current.mem_used_pct is defined else "—" }}%
+
+
+
Load 1m
+
{{ "%.2f"|format(current.load_1m) if current.load_1m is defined else "—" }}
+
+
+
Uptime
+
{{ (current.uptime_secs // 86400) if current.uptime_secs is defined else "—" }}d
+
+
+ +
+

Storage

+ + + + {% for mount, pct in mounts.items() %} + + {% else %} + + {% endfor %} + +
MountUsed %
{{ mount }}{{ "%.1f"|format(pct) }}%
No data.
+
+ +
+

Agent

+
+
Version
{{ reg.agent_version if reg else "—" }}
+
Kernel
{{ reg.kernel if reg else "—" }}
+
Distro
{{ reg.distro if reg else "—" }}
+
Arch
{{ reg.arch if reg else "—" }}
+
Last seen
{{ reg.last_seen_at if reg and reg.last_seen_at else "never" }}
+
+
+ +
+ Loading history… +
+{% endblock %} +``` + +- [ ] **Step 3: Add test** + +```python +# Append to tests/plugins/host_agent/test_ingest_route.py +async def test_host_detail_page_renders(client, registered_host): + await client.post( + "/plugins/host_agent/ingest", + headers={"Authorization": f"Bearer {registered_host['token']}"}, + data=json.dumps(_payload()), + ) + resp = await client.get(f"/plugins/host_agent/{registered_host['host'].id}/") + assert resp.status_code == 200 + text = (await resp.get_data()).decode() + assert "testhost" in text + assert "Storage" in text +``` + +- [ ] **Step 4: Run** + +Run: `pytest tests/plugins/host_agent/test_ingest_route.py::test_host_detail_page_renders -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/host_agent/routes.py plugins/host_agent/templates/detail.html tests/plugins/host_agent/test_ingest_route.py +git commit -m "feat(host_agent): per-host detail page" +``` + +--- + +## Task 12: Stale-agent scheduler + +**Files:** +- Modify: `plugins/host_agent/scheduler.py` +- Create: `tests/plugins/host_agent/test_scheduler.py` + +The scheduler computes a "stale" view from `last_seen_at` — the spec notes this may become redundant once alert rules cover the same ground. It's a one-query safety net. We implement it as a simple function that callers can invoke; the scheduled task is a wrapper that logs stale hosts (no column update — staleness is computed on read). + +- [ ] **Step 1: Write failing test** + +```python +# tests/plugins/host_agent/test_scheduler.py +from datetime import datetime, timedelta, timezone +import pytest +from sqlalchemy import select + +from roundtable.core.db import get_session +from roundtable.models.hosts import Host +from plugins.host_agent.models import HostAgentRegistration +from plugins.host_agent.scheduler import find_stale_registrations + +pytestmark = pytest.mark.asyncio + + +async def test_find_stale_returns_old_registrations(app): + now = datetime.now(timezone.utc) + async with get_session() as session: + h1 = Host(name="fresh", address="") + h2 = Host(name="stale", address="") + h3 = Host(name="never", address="") + session.add_all([h1, h2, h3]) + await session.flush() + session.add_all([ + HostAgentRegistration(host_id=h1.id, token_hash="h1", + last_seen_at=now - timedelta(seconds=30)), + HostAgentRegistration(host_id=h2.id, token_hash="h2", + last_seen_at=now - timedelta(seconds=600)), + HostAgentRegistration(host_id=h3.id, token_hash="h3", + last_seen_at=None), + ]) + await session.commit() + + stale = await find_stale_registrations(stale_after_seconds=180) + stale_names = {s["host_name"] for s in stale} + assert "stale" in stale_names + assert "fresh" not in stale_names + # never-seen hosts are not "stale" — they're unregistered-in-practice; skip. + assert "never" not in stale_names +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `pytest tests/plugins/host_agent/test_scheduler.py -v` +Expected: FAIL. + +- [ ] **Step 3: Implement scheduler** + +Replace `plugins/host_agent/scheduler.py`: + +```python +# plugins/host_agent/scheduler.py +from __future__ import annotations +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select + +from roundtable.core.db import get_session +from roundtable.models.hosts import Host +from .models import HostAgentRegistration + + +async def find_stale_registrations(stale_after_seconds: int = 180) -> list[dict]: + cutoff = datetime.now(timezone.utc) - timedelta(seconds=stale_after_seconds) + async with get_session() as session: + rows = (await session.execute( + select(HostAgentRegistration).where( + HostAgentRegistration.last_seen_at.isnot(None), + HostAgentRegistration.last_seen_at < cutoff, + ) + )).scalars().all() + if not rows: + return [] + hosts = { + h.id: h for h in (await session.execute( + select(Host).where(Host.id.in_([r.host_id for r in rows])) + )).scalars().all() + } + return [ + { + "host_id": r.host_id, + "host_name": hosts[r.host_id].name if r.host_id in hosts else "?", + "last_seen_at": r.last_seen_at, + } + for r in rows + ] + + +def make_stale_task(app): + async def _task(): + stale = await find_stale_registrations() + if stale and app is not None: + app.logger.info("host_agent: %d stale agent(s): %s", + len(stale), [s["host_name"] for s in stale]) + return {"name": "host_agent.mark_stale", "interval_seconds": 60, "func": _task} +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `pytest tests/plugins/host_agent/test_scheduler.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/host_agent/scheduler.py tests/plugins/host_agent/test_scheduler.py +git commit -m "feat(host_agent): stale-agent scheduler" +``` + +--- + +## Task 13: End-to-end integration test + +This task ties the agent and server together without a real subprocess — we generate a payload via the agent's `build_payload()` with mocked collectors, then POST it through the Quart test client and assert the full pipeline. + +**Files:** +- Create: `tests/plugins/host_agent/test_integration.py` + +- [ ] **Step 1: Write the test** + +```python +# tests/plugins/host_agent/test_integration.py +"""End-to-end: agent build_payload → server ingest → PluginMetric rows.""" +import json +from datetime import datetime, timezone +from unittest.mock import patch +import pytest +from sqlalchemy import select + +from roundtable.core.db import get_session +from roundtable.models.metrics import PluginMetric +from plugins.host_agent import agent as a + +pytestmark = pytest.mark.asyncio + + +async def test_full_pipeline(client, registered_host): + fake_mem = {"total_bytes": 8_000_000_000, "used_bytes": 2_000_000_000, + "available_bytes": 6_000_000_000, "swap_used_bytes": 0} + fake_storage = [{"mount": "/", "total_bytes": 500_000_000_000, + "used_bytes": 100_000_000_000}] + + with patch.object(a, "collect_cpu", return_value=7.5), \ + patch.object(a, "collect_memory", return_value=fake_mem), \ + patch.object(a, "collect_load", return_value={"1m": 0.1, "5m": 0.2, "15m": 0.3}), \ + patch.object(a, "collect_uptime", return_value=100000), \ + patch.object(a, "collect_storage", return_value=fake_storage), \ + patch.object(a, "collect_metadata", return_value={ + "kernel": "6.8", "distro": "Ubuntu", "arch": "x86_64"}): + sample = a.build_sample(mounts=["/"]) + payload = a.build_payload([sample], hostname="testhost", + metadata=a.collect_metadata()) + + resp = await client.post( + "/plugins/host_agent/ingest", + headers={"Authorization": f"Bearer {registered_host['token']}", + "Content-Type": "application/json"}, + data=json.dumps(payload), + ) + assert resp.status_code == 200 + + async with get_session() as session: + rows = (await session.execute( + select(PluginMetric).where( + PluginMetric.source_module == "host_agent", + PluginMetric.resource_name == "testhost", + ) + )).scalars().all() + by_metric = {r.metric_name: r.value for r in rows} + assert by_metric["cpu_pct"] == pytest.approx(7.5) + assert by_metric["mem_used_pct"] == pytest.approx(25.0, abs=0.1) + assert by_metric["disk_used_pct_worst"] == pytest.approx(20.0, abs=0.1) + assert by_metric["load_1m"] == pytest.approx(0.1) +``` + +- [ ] **Step 2: Run** + +Run: `pytest tests/plugins/host_agent/test_integration.py -v` +Expected: PASS. + +- [ ] **Step 3: Run the full plugin test suite** + +Run: `pytest tests/plugins/host_agent -v` +Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +git add tests/plugins/host_agent/test_integration.py +git commit -m "test(host_agent): end-to-end integration (agent → ingest → metrics)" +``` + +--- + +## Task 14: Catalog entry + +**Files:** +- Modify: `docs/plugins/index.yaml.example` + +- [ ] **Step 1: Add catalog entry** + +Append to the `plugins:` list in `docs/plugins/index.yaml.example`: + +```yaml + - name: host_agent + version: "1.0.0" + description: "Remote Linux host resource monitoring via a lightweight Python push agent" + author: "Roundtable" + license: "MIT" + min_app_version: "0.1.0" + repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins" + homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/host_agent" + download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/host_agent-v1.0.0/host_agent.zip" + checksum_sha256: "" + tags: + - host + - monitoring + - cpu + - memory + - storage +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/plugins/index.yaml.example +git commit -m "docs(plugins): catalog entry for host_agent" +``` + +--- + +## Task 15: Fable bookkeeping + +Not a code task. After all above tasks are merged: + +- [ ] **Step 1: Transition Fable task #252 from `todo` → `done`** + +Use `fable_update_task` to set status=done on task 252 ("Implement host_agent plugin") with a short completion note linking to the spec and the commit range. + +- [ ] **Step 2: Add a Fable note summarizing what shipped** + +One-paragraph `fable_create_note` attached to Roundtable project (id 6) with: +- Link to spec: `docs/plugins/host-agent-design.md` +- Link to plan: `docs/plugins/host-agent-plan.md` +- Note any deviations from the spec discovered during implementation (e.g., auth decorator name, fixture adjustments). + +--- + +## Final validation + +Before declaring done: + +- [ ] `pytest tests/plugins/host_agent -v` — all green +- [ ] `pytest tests/` — no regressions in existing suites +- [ ] `wc -l plugins/host_agent/agent.py` — under ~350 lines +- [ ] Start the dev server, visit `/plugins/host_agent/settings/`, add a host, verify the install one-liner renders with the correct public URL and an unbuntu VM (or local shell in a throwaway container) can run it and start reporting. +- [ ] Verify the dashboard `host_resources` widget shows the new host after ~30s. diff --git a/docs/plugins/index.yaml.example b/docs/plugins/index.yaml.example index ec0c6d2..4f934df 100644 --- a/docs/plugins/index.yaml.example +++ b/docs/plugins/index.yaml.example @@ -1,10 +1,10 @@ -# fabledscryer-plugins / index.yaml +# roundtable-plugins / index.yaml # -# This file is the catalog index for the fabledscryer plugin repository. +# This file is the catalog index for the roundtable plugin repository. # It is fetched by the app's Settings → Plugins → Plugin Catalog UI. # -# Fabled Scryer reads this file from: -# https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/raw/branch/main/index.yaml +# Roundtable reads this file from: +# https://git.fabledsword.com/bvandeusen/Roundtable-plugins/raw/branch/main/index.yaml # # After adding or updating a plugin entry, commit and push — the change is # live immediately for anyone whose app fetches the catalog (cache TTL: 5 min). @@ -25,7 +25,7 @@ # # Download URL conventions: # Gitea release assets (recommended): -# https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/traefik-v1.0.0/traefik.zip +# https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/traefik-v1.0.0/traefik.zip # Upload the zip as a release attachment in Gitea; paste the URL here. # Source archive tarballs work too but release assets are preferred (smaller, plugin-only). @@ -34,15 +34,46 @@ updated: "2026-03-22" plugins: + - name: http + version: "1.0.0" + description: "Synthetic HTTP endpoint monitoring — status code, response time, content match, TLS expiry" + author: "Roundtable" + license: "MIT" + min_app_version: "0.1.0" + repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins" + homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/http" + download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/http-v1.0.0/http.zip" + checksum_sha256: "" + tags: + - monitoring + - http + - synthetic + - uptime + + - name: docker + version: "1.0.0" + description: "Docker container status, resource usage, and restart tracking via Docker socket" + author: "Roundtable" + license: "MIT" + min_app_version: "0.1.0" + repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins" + homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/docker" + download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/docker-v1.0.0/docker.zip" + checksum_sha256: "" + tags: + - containers + - docker + - infrastructure + - name: traefik version: "1.0.0" description: "Traefik reverse proxy metrics and access log integration" - author: "FabledScryer" + author: "Roundtable" license: "MIT" min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/traefik" - download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/traefik-v1.0.0/traefik.zip" + repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins" + homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/traefik" + download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/traefik-v1.0.0/traefik.zip" checksum_sha256: "" # fill in after running: sha256sum traefik.zip tags: - proxy @@ -52,29 +83,31 @@ plugins: - name: unifi version: "1.0.0" description: "UniFi Network controller integration — WAN health, devices, clients, DPI" - author: "FabledScryer" + author: "Roundtable" license: "MIT" min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/unifi" - download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/unifi-v1.0.0/unifi.zip" + repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins" + homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/unifi" + download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/unifi-v1.0.0/unifi.zip" checksum_sha256: "" tags: - network - unifi - ubiquiti - - name: ups + - name: host_agent version: "1.0.0" - description: "UPS monitoring via NUT (Network UPS Tools) with Ansible shutdown automation" - author: "FabledScryer" + description: "Remote Linux host resource monitoring via a lightweight Python push agent" + author: "Roundtable" license: "MIT" min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/ups" - download_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/releases/download/ups-v1.0.0/ups.zip" + repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins" + homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/host_agent" + download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/host_agent-v1.0.0/host_agent.zip" checksum_sha256: "" tags: - - ups - - power - - nut + - host + - monitoring + - cpu + - memory + - storage diff --git a/docs/plugins/overview.md b/docs/plugins/overview.md index 75b6164..fc27136 100644 --- a/docs/plugins/overview.md +++ b/docs/plugins/overview.md @@ -1,12 +1,12 @@ # Plugin System Overview -Plugins extend Fabled Scryer with new data sources, UI pages, scheduled tasks, and dashboard widgets. Only enabled plugins are imported — disabled or unlisted plugins have zero runtime overhead. +Plugins extend Roundtable with new data sources, UI pages, scheduled tasks, and dashboard widgets. Only enabled plugins are imported — disabled or unlisted plugins have zero runtime overhead. --- ## How Plugins Are Loaded -Plugin loading happens in step 9 of `create_app()`, after all core blueprints and tasks are registered. The entrypoint is `load_plugins(app)` in `fabledscryer/core/plugin_manager.py`. +Plugin loading happens in step 9 of `create_app()`, after all core blueprints and tasks are registered. The entrypoint is `load_plugins(app)` in `roundtable/core/plugin_manager.py`. For each plugin listed as `enabled: true` in the `PLUGINS` config: @@ -57,7 +57,7 @@ description: "Does a thing" # Optional author: "Your Name" -min_app_version: "0.1.0" # Minimum Fabled Scryer version required +min_app_version: "0.1.0" # Minimum Roundtable version required # Default config — merged with user overrides at runtime # Access at runtime via: app.config["PLUGINS"]["myplugin"]["my_setting"] @@ -90,7 +90,7 @@ def setup(app): Returns a list of `ScheduledTask` objects. Return `[]` if the plugin has no background tasks. Called after `setup()`, so any app references set in `setup()` are available. ```python -from fabledscryer.core.scheduler import ScheduledTask +from roundtable.core.scheduler import ScheduledTask def get_scheduled_tasks(): app = _app @@ -154,7 +154,7 @@ A plugin can contribute a dashboard widget by: 1. Adding a `GET /widget` route to its blueprint that returns an HTMX HTML fragment 2. The dashboard template polling that endpoint with HTMX -The dashboard (`fabledscryer/templates/dashboard/index.html`) currently includes the Traefik widget conditionally based on `traefik_enabled`. To add a new plugin widget, the dashboard route and template both need to be updated to detect and render the new widget. See the Traefik widget implementation as a reference. +The dashboard (`roundtable/templates/dashboard/index.html`) currently includes the Traefik widget conditionally based on `traefik_enabled`. To add a new plugin widget, the dashboard route and template both need to be updated to detect and render the new widget. See the Traefik widget implementation as a reference. --- diff --git a/docs/plugins/writing-a-plugin.md b/docs/plugins/writing-a-plugin.md index 225a047..87005c6 100644 --- a/docs/plugins/writing-a-plugin.md +++ b/docs/plugins/writing-a-plugin.md @@ -46,7 +46,7 @@ config: ## Step 3: Define Models (if needed) -If your plugin stores data, define SQLAlchemy models using the shared `Base` from `fabledscryer.models.base`. +If your plugin stores data, define SQLAlchemy models using the shared `Base` from `roundtable.models.base`. ```python # plugins/myplugin/models.py @@ -55,7 +55,7 @@ import uuid from datetime import datetime from sqlalchemy import String, Float, DateTime from sqlalchemy.orm import Mapped, mapped_column -from fabledscryer.models.base import Base +from roundtable.models.base import Base class MyPluginMetric(Base): @@ -79,7 +79,7 @@ Generate the initial migration: # From the project root alembic --config alembic.ini revision \ --autogenerate \ - --head=fabledscryer@head \ + --head=roundtable@head \ --branch-label=myplugin \ -m "myplugin initial" ``` @@ -106,8 +106,8 @@ Keep task logic in a separate file so `__init__.py` stays clean. # plugins/myplugin/scheduler.py from __future__ import annotations import logging -from fabledscryer.core.scheduler import ScheduledTask -from fabledscryer.core.alerts import record_metric +from roundtable.core.scheduler import ScheduledTask +from roundtable.core.alerts import record_metric logger = logging.getLogger(__name__) @@ -174,8 +174,8 @@ async def _fetch_value(url: str) -> float: ```python # plugins/myplugin/routes.py from quart import Blueprint, current_app, render_template -from fabledscryer.auth.middleware import require_role -from fabledscryer.models.users import UserRole +from roundtable.auth.middleware import require_role +from roundtable.models.users import UserRole from .models import MyPluginMetric myplugin_bp = Blueprint("myplugin", __name__, template_folder="templates") @@ -215,7 +215,7 @@ Templates live in `plugins/myplugin/templates/myplugin/` (the extra nesting avoi ```html {# plugins/myplugin/templates/myplugin/index.html #} {% extends "base.html" %} -{% block title %}My Plugin — Fabled Scryer{% endblock %} +{% block title %}My Plugin — Roundtable{% endblock %} {% block content %}
My Plugin
{% for row in rows %} @@ -283,7 +283,7 @@ On next startup, the plugin will be loaded, its migrations applied, and its blue `record_metric()` is how plugins feed data into the alert pipeline. Any metric you write here can be the target of an alert rule created in the UI. ```python -from fabledscryer.core.alerts import record_metric +from roundtable.core.alerts import record_metric # Must be inside an active transaction async with session.begin(): @@ -302,11 +302,11 @@ async with session.begin(): ## Auth in Routes -Use the `@require_role` decorator from `fabledscryer.auth.middleware`: +Use the `@require_role` decorator from `roundtable.auth.middleware`: ```python -from fabledscryer.auth.middleware import require_role -from fabledscryer.models.users import UserRole +from roundtable.auth.middleware import require_role +from roundtable.models.users import UserRole @myplugin_bp.get("/admin-only") @require_role(UserRole.admin) @@ -325,13 +325,13 @@ Role hierarchy: `admin > operator > viewer`. Requiring `viewer` grants access to ## Publishing to the Catalog -The official plugin catalog is hosted at `https://git.fabledsword.com/bvandeusen/FabledScryer-plugins`. Anyone can submit a plugin by opening a pull request — first-party and third-party plugins are treated identically by the catalog system. +The official plugin catalog is hosted at `https://git.fabledsword.com/bvandeusen/Roundtable-plugins`. Anyone can submit a plugin by opening a pull request — first-party and third-party plugins are treated identically by the catalog system. ### Repo layout ``` -fabledscryer-plugins/ -├── index.yaml ← catalog index — the only file Fabled Scryer fetches +roundtable-plugins/ +├── index.yaml ← catalog index — the only file Roundtable fetches ├── myplugin/ │ ├── plugin.yaml │ ├── __init__.py @@ -381,7 +381,7 @@ myplugin.zip Generate the zip and its checksum: ```bash -cd fabledscryer-plugins +cd roundtable-plugins zip -r myplugin.zip myplugin/ sha256sum myplugin.zip # paste this into index.yaml checksum_sha256 ``` diff --git a/docs/reference/code-map.md b/docs/reference/code-map.md index 13a3625..b273351 100644 --- a/docs/reference/code-map.md +++ b/docs/reference/code-map.md @@ -8,15 +8,15 @@ Quick reference for where key functions, models, and entry points live in the co | What | File | Function / Class | |---|---|---| -| App factory | `fabledscryer/app.py` | `create_app()` | -| CLI entry point | `fabledscryer/cli.py` | `main()` | -| Bootstrap config loading | `fabledscryer/config.py` | `load_bootstrap()` | -| Secret key resolution | `fabledscryer/config.py` | `_resolve_secret_key()` | -| DB engine init | `fabledscryer/database.py` | `init_db()` | -| Core migrations | `fabledscryer/core/migration_runner.py` | `run_core_migrations()` | -| Plugin migrations | `fabledscryer/core/migration_runner.py` | `run_plugin_migrations()` | -| Core task registration | `fabledscryer/app.py` | `_register_core_tasks()` | -| Scheduler loop | `fabledscryer/core/scheduler.py` | `start_scheduler()` | +| App factory | `roundtable/app.py` | `create_app()` | +| CLI entry point | `roundtable/cli.py` | `main()` | +| Bootstrap config loading | `roundtable/config.py` | `load_bootstrap()` | +| Secret key resolution | `roundtable/config.py` | `_resolve_secret_key()` | +| DB engine init | `roundtable/database.py` | `init_db()` | +| Core migrations | `roundtable/core/migration_runner.py` | `run_core_migrations()` | +| Plugin migrations | `roundtable/core/migration_runner.py` | `run_plugin_migrations()` | +| Core task registration | `roundtable/app.py` | `_register_core_tasks()` | +| Scheduler loop | `roundtable/core/scheduler.py` | `start_scheduler()` | --- @@ -24,15 +24,15 @@ Quick reference for where key functions, models, and entry points live in the co | What | File | Function | |---|---|---| -| All defaults | `fabledscryer/core/settings.py` | `DEFAULTS` dict | -| Read a setting | `fabledscryer/core/settings.py` | `get_setting(session, key)` | -| Write a setting | `fabledscryer/core/settings.py` | `set_setting(session, key, value)` | -| Read all settings | `fabledscryer/core/settings.py` | `get_all_settings(session)` | -| Sync load at startup | `fabledscryer/core/settings.py` | `load_settings_sync(db_url)` | -| Extract SMTP dict | `fabledscryer/core/settings.py` | `to_smtp_cfg(settings)` | -| Extract webhook dict | `fabledscryer/core/settings.py` | `to_webhook_cfg(settings)` | -| Extract Ansible dict | `fabledscryer/core/settings.py` | `to_ansible_cfg(settings)` | -| Extract plugins dict | `fabledscryer/core/settings.py` | `to_plugins_cfg(settings)` | +| All defaults | `roundtable/core/settings.py` | `DEFAULTS` dict | +| Read a setting | `roundtable/core/settings.py` | `get_setting(session, key)` | +| Write a setting | `roundtable/core/settings.py` | `set_setting(session, key, value)` | +| Read all settings | `roundtable/core/settings.py` | `get_all_settings(session)` | +| Sync load at startup | `roundtable/core/settings.py` | `load_settings_sync(db_url)` | +| Extract SMTP dict | `roundtable/core/settings.py` | `to_smtp_cfg(settings)` | +| Extract webhook dict | `roundtable/core/settings.py` | `to_webhook_cfg(settings)` | +| Extract Ansible dict | `roundtable/core/settings.py` | `to_ansible_cfg(settings)` | +| Extract plugins dict | `roundtable/core/settings.py` | `to_plugins_cfg(settings)` | --- @@ -40,9 +40,9 @@ Quick reference for where key functions, models, and entry points live in the co | What | File | Function | |---|---|---| -| Plugin loading | `fabledscryer/core/plugin_manager.py` | `load_plugins(app)` | -| ScheduledTask dataclass | `fabledscryer/core/scheduler.py` | `ScheduledTask` | -| Task runner | `fabledscryer/core/scheduler.py` | `start_scheduler(tasks)` | +| Plugin loading | `roundtable/core/plugin_manager.py` | `load_plugins(app)` | +| ScheduledTask dataclass | `roundtable/core/scheduler.py` | `ScheduledTask` | +| Task runner | `roundtable/core/scheduler.py` | `start_scheduler(tasks)` | --- @@ -50,11 +50,11 @@ Quick reference for where key functions, models, and entry points live in the co | What | File | Function | |---|---|---| -| Write metric + evaluate alerts | `fabledscryer/core/alerts.py` | `record_metric(session, source_module, resource_name, metric_name, value)` | -| Init alert pipeline | `fabledscryer/core/alerts.py` | `init_alerts(app)` | -| Rule evaluation | `fabledscryer/core/alerts.py` | `_evaluate_rule()` (internal) | -| Notification dispatch | `fabledscryer/core/alerts.py` | `_dispatch_notification()` (internal) | -| Email + webhook send | `fabledscryer/core/notifications.py` | `dispatch_notifications()` | +| Write metric + evaluate alerts | `roundtable/core/alerts.py` | `record_metric(session, source_module, resource_name, metric_name, value)` | +| Init alert pipeline | `roundtable/core/alerts.py` | `init_alerts(app)` | +| Rule evaluation | `roundtable/core/alerts.py` | `_evaluate_rule()` (internal) | +| Notification dispatch | `roundtable/core/alerts.py` | `_dispatch_notification()` (internal) | +| Email + webhook send | `roundtable/core/notifications.py` | `dispatch_notifications()` | --- @@ -62,9 +62,9 @@ Quick reference for where key functions, models, and entry points live in the co | What | File | Function | |---|---|---| -| Ping a host | `fabledscryer/monitors/ping.py` | `ping_check(host, session)` | -| DNS check a host | `fabledscryer/monitors/dns.py` | `dns_check(host, session)` | -| Data cleanup | `fabledscryer/core/cleanup.py` | `run_cleanup(app)` | +| Ping a host | `roundtable/monitors/ping.py` | `ping_check(host, session)` | +| DNS check a host | `roundtable/monitors/dns.py` | `dns_check(host, session)` | +| Data cleanup | `roundtable/core/cleanup.py` | `run_cleanup(app)` | --- @@ -72,9 +72,9 @@ Quick reference for where key functions, models, and entry points live in the co | What | File | Function / Class | |---|---|---| -| Role-based access decorator | `fabledscryer/auth/middleware.py` | `@require_role(UserRole.X)` | -| Login / session handling | `fabledscryer/auth/middleware.py` | `login_user()`, `logout_user()` | -| User count (for first-run) | `fabledscryer/auth/middleware.py` | `get_user_count(app)` | +| Role-based access decorator | `roundtable/auth/middleware.py` | `@require_role(UserRole.X)` | +| Login / session handling | `roundtable/auth/middleware.py` | `login_user()`, `logout_user()` | +| User count (for first-run) | `roundtable/auth/middleware.py` | `get_user_count(app)` | --- @@ -82,18 +82,18 @@ Quick reference for where key functions, models, and entry points live in the co | Model | File | Table | |---|---|---| -| `Host` | `fabledscryer/models/hosts.py` | `hosts` | -| `PingResult` | `fabledscryer/models/monitors.py` | `ping_results` | -| `DnsResult` | `fabledscryer/models/monitors.py` | `dns_results` | -| `AlertRule` | `fabledscryer/models/alerts.py` | `alert_rules` | -| `AlertState` | `fabledscryer/models/alerts.py` | `alert_states` | -| `AlertEvent` | `fabledscryer/models/alerts.py` | `alert_events` | -| `PluginMetric` | `fabledscryer/models/metrics.py` | `plugin_metrics` | -| `AnsibleRun` | `fabledscryer/models/ansible.py` | `ansible_runs` | -| `User` | `fabledscryer/models/users.py` | `users` | -| `AppSetting` | `fabledscryer/models/settings.py` | `app_settings` | +| `Host` | `roundtable/models/hosts.py` | `hosts` | +| `PingResult` | `roundtable/models/monitors.py` | `ping_results` | +| `DnsResult` | `roundtable/models/monitors.py` | `dns_results` | +| `AlertRule` | `roundtable/models/alerts.py` | `alert_rules` | +| `AlertState` | `roundtable/models/alerts.py` | `alert_states` | +| `AlertEvent` | `roundtable/models/alerts.py` | `alert_events` | +| `PluginMetric` | `roundtable/models/metrics.py` | `plugin_metrics` | +| `AnsibleRun` | `roundtable/models/ansible.py` | `ansible_runs` | +| `User` | `roundtable/models/users.py` | `users` | +| `AppSetting` | `roundtable/models/settings.py` | `app_settings` | | `TraefikMetric` | `plugins/traefik/models.py` | `traefik_metrics` | -| SQLAlchemy `Base` | `fabledscryer/models/base.py` | (shared declarative base) | +| SQLAlchemy `Base` | `roundtable/models/base.py` | (shared declarative base) | --- @@ -101,16 +101,16 @@ Quick reference for where key functions, models, and entry points live in the co | URL pattern | Blueprint | File | |---|---|---| -| `/` (dashboard) | `dashboard_bp` | `fabledscryer/dashboard/routes.py` | -| `/auth/login`, `/auth/logout` | `auth_bp` | `fabledscryer/auth/routes.py` | -| `/hosts/` | `hosts_bp` | `fabledscryer/hosts/routes.py` | -| `/ping/`, `/ping/rows`, `/ping/settings` | `ping_bp` | `fabledscryer/ping/routes.py` | -| `/dns/`, `/dns/rows` | `dns_bp` | `fabledscryer/dns/routes.py` | -| `/alerts/` | `alerts_bp` | `fabledscryer/alerts/routes.py` | -| `/ansible/` | `ansible_bp` | `fabledscryer/ansible/routes.py` | -| `/settings/` | `settings_bp` | `fabledscryer/settings/routes.py` | +| `/` (dashboard) | `dashboard_bp` | `roundtable/dashboard/routes.py` | +| `/auth/login`, `/auth/logout` | `auth_bp` | `roundtable/auth/routes.py` | +| `/hosts/` | `hosts_bp` | `roundtable/hosts/routes.py` | +| `/ping/`, `/ping/rows`, `/ping/settings` | `ping_bp` | `roundtable/ping/routes.py` | +| `/dns/`, `/dns/rows` | `dns_bp` | `roundtable/dns/routes.py` | +| `/alerts/` | `alerts_bp` | `roundtable/alerts/routes.py` | +| `/ansible/` | `ansible_bp` | `roundtable/ansible/routes.py` | +| `/settings/` | `settings_bp` | `roundtable/settings/routes.py` | | `/plugins/traefik/`, `/plugins/traefik/widget` | `traefik_bp` | `plugins/traefik/routes.py` | -| `/health` | (inline) | `fabledscryer/app.py` | +| `/health` | (inline) | `roundtable/app.py` | --- @@ -118,12 +118,12 @@ Quick reference for where key functions, models, and entry points live in the co | Template | Purpose | |---|---| -| `fabledscryer/templates/base.html` | Layout, navigation, full CSS design system | -| `fabledscryer/templates/dashboard/index.html` | Dashboard with stat strip and widget grid | -| `fabledscryer/templates/ping/rows.html` | HTMX fragment: ping pill rows (shared by dashboard and /ping/) | -| `fabledscryer/templates/ping/index.html` | Full /ping/ page | -| `fabledscryer/templates/dns/rows.html` | HTMX fragment: DNS status rows | -| `fabledscryer/templates/dns/index.html` | Full /dns/ page | +| `roundtable/templates/base.html` | Layout, navigation, full CSS design system | +| `roundtable/templates/dashboard/index.html` | Dashboard with stat strip and widget grid | +| `roundtable/templates/ping/rows.html` | HTMX fragment: ping pill rows (shared by dashboard and /ping/) | +| `roundtable/templates/ping/index.html` | Full /ping/ page | +| `roundtable/templates/dns/rows.html` | HTMX fragment: DNS status rows | +| `roundtable/templates/dns/index.html` | Full /dns/ page | | `plugins/traefik/templates/traefik/widget.html` | HTMX fragment: Traefik dashboard widget | | `plugins/traefik/templates/traefik/index.html` | Full Traefik detail page | @@ -133,6 +133,6 @@ Quick reference for where key functions, models, and entry points live in the co | Location | Covers | |---|---| -| `fabledscryer/migrations/versions/` | Core schema (hosts, users, monitors, alerts, app_settings) | +| `roundtable/migrations/versions/` | Core schema (hosts, users, monitors, alerts, app_settings) | | `plugins/traefik/migrations/versions/` | `traefik_metrics` table | | `alembic.ini` | Alembic config; `version_locations` lists all migration directories | diff --git a/docs/superpowers/plans/2026-04-13-rebrand.md b/docs/superpowers/plans/2026-04-13-rebrand.md new file mode 100644 index 0000000..89a0ff9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-rebrand.md @@ -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, `` +- `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 %} +``` + +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 `` 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 %} +``` + +- [ ] **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 +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). diff --git a/docs/superpowers/specs/2026-04-13-rebrand-design.md b/docs/superpowers/specs/2026-04-13-rebrand-design.md new file mode 100644 index 0000000..72e3d6a --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-rebrand-design.md @@ -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 + + + + + + + + + + +``` + +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, ``, 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. diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..ea128e0 --- /dev/null +++ b/entrypoint.sh @@ -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 "$@" diff --git a/fabledscryer/__init__.py b/fabledscryer/__init__.py deleted file mode 100644 index 3dc1f76..0000000 --- a/fabledscryer/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.1.0" diff --git a/fabledscryer/alerts/routes.py b/fabledscryer/alerts/routes.py deleted file mode 100644 index 368338e..0000000 --- a/fabledscryer/alerts/routes.py +++ /dev/null @@ -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")) diff --git a/fabledscryer/auth/routes.py b/fabledscryer/auth/routes.py deleted file mode 100644 index c9eb4c7..0000000 --- a/fabledscryer/auth/routes.py +++ /dev/null @@ -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")) diff --git a/fabledscryer/core/widgets.py b/fabledscryer/core/widgets.py deleted file mode 100644 index b66e178..0000000 --- a/fabledscryer/core/widgets.py +++ /dev/null @@ -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 diff --git a/fabledscryer/settings/__init__.py b/fabledscryer/settings/__init__.py deleted file mode 100644 index c2e9ca5..0000000 --- a/fabledscryer/settings/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# fabledscryer/settings/__init__.py diff --git a/fabledscryer/settings/routes.py b/fabledscryer/settings/routes.py deleted file mode 100644 index ad2d4e9..0000000 --- a/fabledscryer/settings/routes.py +++ /dev/null @@ -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") diff --git a/fabledscryer/templates/alerts/rules_form.html b/fabledscryer/templates/alerts/rules_form.html deleted file mode 100644 index 8ead9dc..0000000 --- a/fabledscryer/templates/alerts/rules_form.html +++ /dev/null @@ -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 %} diff --git a/fabledscryer/templates/ansible/run_detail.html b/fabledscryer/templates/ansible/run_detail.html deleted file mode 100644 index 8194d9d..0000000 --- a/fabledscryer/templates/ansible/run_detail.html +++ /dev/null @@ -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 %} diff --git a/fabledscryer/templates/auth/login.html b/fabledscryer/templates/auth/login.html deleted file mode 100644 index 9c8f9a9..0000000 --- a/fabledscryer/templates/auth/login.html +++ /dev/null @@ -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 %} diff --git a/fabledscryer/templates/dashboard/_edit_panels.html b/fabledscryer/templates/dashboard/_edit_panels.html deleted file mode 100644 index 2800361..0000000 --- a/fabledscryer/templates/dashboard/_edit_panels.html +++ /dev/null @@ -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> diff --git a/fabledscryer/templates/dashboard/edit.html b/fabledscryer/templates/dashboard/edit.html deleted file mode 100644 index 3140102..0000000 --- a/fabledscryer/templates/dashboard/edit.html +++ /dev/null @@ -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 %} diff --git a/fabledscryer/templates/settings/ansible.html b/fabledscryer/templates/settings/ansible.html deleted file mode 100644 index 3a8ee9e..0000000 --- a/fabledscryer/templates/settings/ansible.html +++ /dev/null @@ -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 %} diff --git a/fabledscryer/templates/settings/plugins.html b/fabledscryer/templates/settings/plugins.html deleted file mode 100644 index 3571ca5..0000000 --- a/fabledscryer/templates/settings/plugins.html +++ /dev/null @@ -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 %} diff --git a/pyproject.toml b/pyproject.toml index 2d97477..613b322 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "fabledscryer" +name = "roundtable" version = "0.1.0" requires-python = ">=3.11" dependencies = [ @@ -20,17 +20,23 @@ dependencies = [ ] [project.optional-dependencies] +ldap = [ + "ldap3>=2.9", +] +snmp = [ + "pysnmp-lextudio>=6.2", +] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", ] [project.scripts] -fabledscryer = "fabledscryer.cli:main" +roundtable = "roundtable.cli:main" [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] [tool.hatch.build.targets.wheel] -packages = ["fabledscryer"] +packages = ["roundtable"] diff --git a/roundtable/__init__.py b/roundtable/__init__.py new file mode 100644 index 0000000..a125e63 --- /dev/null +++ b/roundtable/__init__.py @@ -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__]) diff --git a/fabledscryer/alerts/__init__.py b/roundtable/alerts/__init__.py similarity index 100% rename from fabledscryer/alerts/__init__.py rename to roundtable/alerts/__init__.py diff --git a/roundtable/alerts/routes.py b/roundtable/alerts/routes.py new file mode 100644 index 0000000..bbcb9ef --- /dev/null +++ b/roundtable/alerts/routes.py @@ -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")) diff --git a/fabledscryer/ansible/__init__.py b/roundtable/ansible/__init__.py similarity index 100% rename from fabledscryer/ansible/__init__.py rename to roundtable/ansible/__init__.py diff --git a/fabledscryer/ansible/executor.py b/roundtable/ansible/executor.py similarity index 97% rename from fabledscryer/ansible/executor.py rename to roundtable/ansible/executor.py index 281d8c3..a7f1eaf 100644 --- a/fabledscryer/ansible/executor.py +++ b/roundtable/ansible/executor.py @@ -1,4 +1,4 @@ -# fabledscryer/ansible/executor.py +# roundtable/ansible/executor.py from __future__ import annotations import asyncio import logging @@ -77,7 +77,7 @@ async def start_run( async def _flush(final_status: str | None = None) -> None: nonlocal lines_since_flush, last_flush_at from sqlalchemy import update - from fabledscryer.models.ansible import AnsibleRun, AnsibleRunStatus + from roundtable.models.ansible import AnsibleRun, AnsibleRunStatus values: dict = {"output": db_output} if final_status is not None: diff --git a/fabledscryer/ansible/routes.py b/roundtable/ansible/routes.py similarity index 95% rename from fabledscryer/ansible/routes.py rename to roundtable/ansible/routes.py index 8624add..273f0b2 100644 --- a/fabledscryer/ansible/routes.py +++ b/roundtable/ansible/routes.py @@ -1,4 +1,4 @@ -# fabledscryer/ansible/routes.py +# roundtable/ansible/routes.py from __future__ import annotations import asyncio import uuid @@ -11,10 +11,10 @@ from quart import ( ) from sqlalchemy import select, update -from fabledscryer.ansible import executor, sources as src_module -from fabledscryer.auth.middleware import require_role -from fabledscryer.models.ansible import AnsibleRun, AnsibleRunStatus -from fabledscryer.models.users import UserRole +from roundtable.ansible import executor, sources as src_module +from roundtable.auth.middleware import require_role +from roundtable.models.ansible import AnsibleRun, AnsibleRunStatus +from roundtable.models.users import UserRole ansible_bp = Blueprint("ansible", __name__, url_prefix="/ansible") diff --git a/fabledscryer/ansible/sources.py b/roundtable/ansible/sources.py similarity index 96% rename from fabledscryer/ansible/sources.py rename to roundtable/ansible/sources.py index 40002ae..8d1a040 100644 --- a/fabledscryer/ansible/sources.py +++ b/roundtable/ansible/sources.py @@ -17,7 +17,7 @@ def get_sources(ansible_cfg: dict) -> list[dict]: Config structure in config.yaml:: ansible: - cache_dir: /var/cache/fabledscryer/ansible + cache_dir: /var/cache/roundtable/ansible sources: - name: my-playbooks type: local @@ -29,7 +29,7 @@ def get_sources(ansible_cfg: dict) -> list[dict]: pull_interval_seconds: 3600 """ sources = ansible_cfg.get("sources", []) - cache_dir = ansible_cfg.get("cache_dir", "/var/cache/fabledscryer/ansible") + cache_dir = ansible_cfg.get("cache_dir", "/var/cache/roundtable/ansible") result = [] for src in sources: src_type = src.get("type", "local") diff --git a/fabledscryer/app.py b/roundtable/app.py similarity index 88% rename from fabledscryer/app.py rename to roundtable/app.py index 5eb92a2..2e75829 100644 --- a/fabledscryer/app.py +++ b/roundtable/app.py @@ -1,8 +1,8 @@ -# fabledscryer/app.py +# roundtable/app.py from __future__ import annotations import asyncio from pathlib import Path -from quart import Quart +from quart import Quart, render_template from .config import load_bootstrap from .database import init_db @@ -49,7 +49,7 @@ def create_app( if not testing: from .core.settings import ( load_settings_sync, to_smtp_cfg, to_webhook_cfg, - to_ansible_cfg, to_plugins_cfg, + to_ansible_cfg, to_plugins_cfg, to_oidc_cfg, to_ldap_cfg, ) _settings = load_settings_sync(app.config["DATABASE_URL"]) app.config.update( @@ -60,6 +60,8 @@ def create_app( WEBHOOK=to_webhook_cfg(_settings), ANSIBLE=to_ansible_cfg(_settings), PLUGINS=to_plugins_cfg(_settings), + OIDC=to_oidc_cfg(_settings), + LDAP=to_ldap_cfg(_settings), ) else: app.config.update( @@ -70,6 +72,8 @@ def create_app( WEBHOOK={}, ANSIBLE={}, PLUGINS={}, + OIDC={"enabled": False}, + LDAP={"enabled": False}, ) # ── 5. Plugin migrations ─────────────────────────────────────────────────── @@ -95,6 +99,7 @@ def create_app( from .alerts.routes import alerts_bp from .ansible.routes import ansible_bp from .settings.routes import settings_bp + from .audit.routes import audit_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -104,6 +109,7 @@ def create_app( app.register_blueprint(alerts_bp) app.register_blueprint(ansible_bp) app.register_blueprint(settings_bp) + app.register_blueprint(audit_bp) # ── 8. Build task registry ───────────────────────────────────────────────── app._task_registry = [] @@ -116,7 +122,13 @@ def create_app( from .core.plugin_manager import load_plugins load_plugins(app) - # ── 10. Share-token middleware ───────────────────────────────────────────── + # ── 10. Template context: inject plugin_failures into every response ─────── + @app.context_processor + def _inject_plugin_failures(): + from .core.plugin_manager import get_plugin_failures + return {"plugin_failures": get_plugin_failures()} + + # ── 11. Share-token middleware ───────────────────────────────────────────── @app.before_request async def _validate_share_token(): """If ?s=<token> is present, validate it and set g.share_viewer.""" @@ -149,6 +161,15 @@ def create_app( async def health(): return {"status": "ok"} + # ── 12. Error handlers ───────────────────────────────────────────────────── + @app.errorhandler(404) + async def not_found(_): + return await render_template("errors/404.html"), 404 + + @app.errorhandler(500) + async def server_error(_): + return await render_template("errors/500.html"), 500 + # ── 11. Startup hook ─────────────────────────────────────────────────────── @app.before_serving async def startup(): @@ -199,7 +220,17 @@ def _register_core_tasks(app: Quart) -> None: from .core.cleanup import run_cleanup as _cleanup await _cleanup(app) + async def run_report_check(): + from .core.reports import check_and_send + await check_and_send(app) + app._task_registry.extend([ + ScheduledTask( + name="weekly_report_check", + coro_factory=run_report_check, + interval_seconds=3600, + run_on_startup=False, + ), ScheduledTask( name="ping_monitor", coro_factory=run_ping_monitors, diff --git a/fabledscryer/auth/__init__.py b/roundtable/audit/__init__.py similarity index 100% rename from fabledscryer/auth/__init__.py rename to roundtable/audit/__init__.py diff --git a/roundtable/audit/routes.py b/roundtable/audit/routes.py new file mode 100644 index 0000000..fe7e755 --- /dev/null +++ b/roundtable/audit/routes.py @@ -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, + ) diff --git a/fabledscryer/core/__init__.py b/roundtable/auth/__init__.py similarity index 100% rename from fabledscryer/core/__init__.py rename to roundtable/auth/__init__.py diff --git a/roundtable/auth/ldap_auth.py b/roundtable/auth/ldap_auth.py new file mode 100644 index 0000000..e64b93e --- /dev/null +++ b/roundtable/auth/ldap_auth.py @@ -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) diff --git a/fabledscryer/auth/middleware.py b/roundtable/auth/middleware.py similarity index 96% rename from fabledscryer/auth/middleware.py rename to roundtable/auth/middleware.py index 4af4103..17e9579 100644 --- a/fabledscryer/auth/middleware.py +++ b/roundtable/auth/middleware.py @@ -1,7 +1,7 @@ from __future__ import annotations import functools from quart import session, redirect, url_for, abort, g -from fabledscryer.models.users import UserRole +from roundtable.models.users import UserRole _ROLE_ORDER = [UserRole.viewer, UserRole.operator, UserRole.admin] diff --git a/roundtable/auth/oidc.py b/roundtable/auth/oidc.py new file mode 100644 index 0000000..0d1aac8 --- /dev/null +++ b/roundtable/auth/oidc.py @@ -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("=") diff --git a/roundtable/auth/routes.py b/roundtable/auth/routes.py new file mode 100644 index 0000000..49de43a --- /dev/null +++ b/roundtable/auth/routes.py @@ -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")) diff --git a/fabledscryer/cli.py b/roundtable/cli.py similarity index 91% rename from fabledscryer/cli.py rename to roundtable/cli.py index 4387264..ddeba2e 100644 --- a/fabledscryer/cli.py +++ b/roundtable/cli.py @@ -9,6 +9,6 @@ from .app import create_app @click.option("--port", default=5000, type=int) @click.option("--debug", is_flag=True, default=False) def main(config: str, host: str, port: int, debug: bool) -> None: - """Start the Fabled Scryer server.""" + """Start the Roundtable server.""" app = create_app(config_path=Path(config)) app.run(host=host, port=port, debug=debug) diff --git a/fabledscryer/config.py b/roundtable/config.py similarity index 77% rename from fabledscryer/config.py rename to roundtable/config.py index acc8d03..4d9832d 100644 --- a/fabledscryer/config.py +++ b/roundtable/config.py @@ -1,4 +1,4 @@ -# fabledscryer/config.py +# roundtable/config.py from __future__ import annotations import logging import os @@ -11,6 +11,17 @@ logger = logging.getLogger(__name__) _SECRET_KEY_FILE = Path("/data/secret.key") +def _env(suffix: str) -> str | None: + """Read ROUNDTABLE_<suffix>, falling back to FABLEDSCRYER_<suffix>. + + Allows existing deployments to keep their old env vars working through + the rebrand transition. Remove the fallback once users have migrated. + """ + return os.environ.get(f"ROUNDTABLE_{suffix}") or os.environ.get( + f"FABLEDSCRYER_{suffix}" + ) + + def load_bootstrap(config_path: Path | str | None = None) -> dict[str, str]: """Return the minimum bootstrap config: database_url and secret_key. @@ -33,19 +44,19 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, str]: raw = yaml.safe_load(f) or {} database_url = ( - os.environ.get("FABLEDSCRYER_DATABASE_URL") - or os.environ.get("FABLEDSCRYER_DATABASE__URL") + _env("DATABASE_URL") + or _env("DATABASE__URL") or raw.get("database", {}).get("url") ) if not database_url: raise ValueError( - "Database URL is required. Set FABLEDSCRYER_DATABASE_URL env var " + "Database URL is required. Set ROUNDTABLE_DATABASE_URL env var " "or add 'database.url' to config.yaml." ) secret_key = _resolve_secret_key(raw) plugin_dir = ( - os.environ.get("FABLEDSCRYER_PLUGIN_DIR") + _env("PLUGIN_DIR") or raw.get("plugin_dir", "plugins") ) @@ -58,7 +69,7 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, str]: def _resolve_secret_key(raw: dict) -> str: """Resolve secret_key: env var → file → auto-generate.""" - from_env = os.environ.get("FABLEDSCRYER_SECRET_KEY") or raw.get("secret_key") + from_env = _env("SECRET_KEY") or raw.get("secret_key") if from_env: return from_env diff --git a/fabledscryer/dashboard/__init__.py b/roundtable/core/__init__.py similarity index 100% rename from fabledscryer/dashboard/__init__.py rename to roundtable/core/__init__.py diff --git a/fabledscryer/core/alerts.py b/roundtable/core/alerts.py similarity index 88% rename from fabledscryer/core/alerts.py rename to roundtable/core/alerts.py index a003bfa..d77516f 100644 --- a/fabledscryer/core/alerts.py +++ b/roundtable/core/alerts.py @@ -5,17 +5,18 @@ import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING -from sqlalchemy import select +from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from fabledscryer.models.alerts import ( +from roundtable.models.alerts import ( AlertEvent, AlertOperator, AlertRule, AlertState, AlertStateEnum, + MaintenanceWindow, ) -from fabledscryer.models.metrics import PluginMetric +from roundtable.models.metrics import PluginMetric if TYPE_CHECKING: from quart import Quart @@ -60,6 +61,26 @@ async def record_metric( session.add(metric) await session.flush() + # Check active maintenance windows — skip rule evaluation if suppressed + mw_result = await session.execute( + select(MaintenanceWindow).where( + MaintenanceWindow.start_at <= now, + MaintenanceWindow.end_at >= now, + or_( + MaintenanceWindow.scope_source.is_(None), + and_( + MaintenanceWindow.scope_source == source_module, + or_( + MaintenanceWindow.scope_resource.is_(None), + MaintenanceWindow.scope_resource == resource_name, + ), + ), + ), + ).limit(1) + ) + if mw_result.scalar_one_or_none() is not None: + return # suppressed by maintenance window + # Load matching enabled rules result = await session.execute( select(AlertRule).where( @@ -205,7 +226,7 @@ async def _dispatch_notification( event_id: str, ) -> None: """Run after the transaction commits. Sends notifications and updates the event row.""" - from fabledscryer.core.notifications import dispatch_notifications + from roundtable.core.notifications import dispatch_notifications alert_data = { "rule_name": rule.name, diff --git a/roundtable/core/audit.py b/roundtable/core/audit.py new file mode 100644 index 0000000..9342666 --- /dev/null +++ b/roundtable/core/audit.py @@ -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) diff --git a/fabledscryer/core/cleanup.py b/roundtable/core/cleanup.py similarity index 87% rename from fabledscryer/core/cleanup.py rename to roundtable/core/cleanup.py index 7222d81..55329e9 100644 --- a/fabledscryer/core/cleanup.py +++ b/roundtable/core/cleanup.py @@ -5,9 +5,9 @@ from typing import TYPE_CHECKING from sqlalchemy import delete -from fabledscryer.models.monitors import DnsResult, PingResult -from fabledscryer.models.metrics import PluginMetric -from fabledscryer.models.ansible import AnsibleRun +from roundtable.models.monitors import DnsResult, PingResult +from roundtable.models.metrics import PluginMetric +from roundtable.models.ansible import AnsibleRun if TYPE_CHECKING: from quart import Quart diff --git a/fabledscryer/core/migration_runner.py b/roundtable/core/migration_runner.py similarity index 98% rename from fabledscryer/core/migration_runner.py rename to roundtable/core/migration_runner.py index 1b586b0..2a030b7 100644 --- a/fabledscryer/core/migration_runner.py +++ b/roundtable/core/migration_runner.py @@ -1,4 +1,4 @@ -# fabledscryer/core/migration_runner.py +# roundtable/core/migration_runner.py from __future__ import annotations import logging from pathlib import Path diff --git a/fabledscryer/core/migrations.py b/roundtable/core/migrations.py similarity index 100% rename from fabledscryer/core/migrations.py rename to roundtable/core/migrations.py diff --git a/fabledscryer/core/notifications.py b/roundtable/core/notifications.py similarity index 95% rename from fabledscryer/core/notifications.py rename to roundtable/core/notifications.py index c08b1ac..4a4080a 100644 --- a/fabledscryer/core/notifications.py +++ b/roundtable/core/notifications.py @@ -33,8 +33,8 @@ async def dispatch_notifications( async def _send_email(cfg: dict, alert: dict) -> dict: try: msg = EmailMessage() - msg["Subject"] = f"[Fabled Scryer] {alert['state']} — {alert['rule_name']}" - msg["From"] = cfg.get("username", "fabledscryer@localhost") + msg["Subject"] = f"[Roundtable] {alert['state']} — {alert['rule_name']}" + msg["From"] = cfg.get("username", "roundtable@localhost") msg["To"] = ", ".join(cfg["recipients"]) msg.set_content( f"Alert: {alert['state']}\n" diff --git a/fabledscryer/core/plugin_index.py b/roundtable/core/plugin_index.py similarity index 54% rename from fabledscryer/core/plugin_index.py rename to roundtable/core/plugin_index.py index 0113bc2..82e52ee 100644 --- a/fabledscryer/core/plugin_index.py +++ b/roundtable/core/plugin_index.py @@ -1,4 +1,4 @@ -# fabledscryer/core/plugin_index.py +# roundtable/core/plugin_index.py """Remote plugin catalog: fetch, parse, and cache the plugin index.yaml.""" from __future__ import annotations import logging @@ -8,7 +8,8 @@ from typing import Any logger = logging.getLogger(__name__) _CACHE_TTL = 300 # seconds -_cache: tuple[dict, float] | None = None # (raw_data, fetched_at monotonic) +# Per-URL cache: url → (raw_data, fetched_at monotonic) +_url_cache: dict[str, tuple[dict, float]] = {} class CatalogPlugin: @@ -34,44 +35,59 @@ class CatalogPlugin: self.tags: list[str] = data.get("tags", []) -async def fetch_catalog(index_url: str, force: bool = False) -> list[CatalogPlugin]: - """Fetch and parse the remote plugin catalog. - - Results are cached in-process for _CACHE_TTL seconds. Pass force=True to - bypass the cache. On network failure, stale cached data is returned if - available; otherwise an empty list is returned. - """ - global _cache +async def _fetch_one(url: str, force: bool = False) -> list[CatalogPlugin]: + """Fetch a single index URL, using per-URL cache.""" import httpx import yaml now = time.monotonic() - if not force and _cache is not None: - raw, fetched_at = _cache + if not force and url in _url_cache: + raw, fetched_at = _url_cache[url] if now - fetched_at < _CACHE_TTL: return [CatalogPlugin(p) for p in raw.get("plugins", [])] try: async with httpx.AsyncClient(timeout=10) as client: - resp = await client.get(index_url) + resp = await client.get(url) resp.raise_for_status() raw = yaml.safe_load(resp.text) or {} - _cache = (raw, now) - logger.info( - "Plugin catalog fetched from %s: %d plugin(s)", - index_url, len(raw.get("plugins", [])), - ) + _url_cache[url] = (raw, now) + logger.info("Plugin catalog fetched from %s: %d plugin(s)", url, len(raw.get("plugins", []))) return [CatalogPlugin(p) for p in raw.get("plugins", [])] except Exception: - logger.exception("Failed to fetch plugin catalog from %s", index_url) - if _cache is not None: - raw, _ = _cache - logger.warning("Returning stale cached catalog") + logger.exception("Failed to fetch plugin catalog from %s", url) + if url in _url_cache: + raw, _ = _url_cache[url] + logger.warning("Returning stale cached catalog for %s", url) return [CatalogPlugin(p) for p in raw.get("plugins", [])] return [] +async def fetch_catalog( + index_urls: str | list[str], + force: bool = False, +) -> list[CatalogPlugin]: + """Fetch and merge plugin catalogs from one or more index URLs. + + Results are cached per-URL for _CACHE_TTL seconds. Plugins with the same + name across repos are deduplicated — the first occurrence (repo list order) + wins. Pass force=True to bypass all caches. + """ + if isinstance(index_urls, str): + index_urls = [index_urls] + + seen: dict[str, CatalogPlugin] = {} + for url in index_urls: + url = url.strip() + if not url: + continue + for plugin in await _fetch_one(url, force=force): + if plugin.name not in seen: + seen[plugin.name] = plugin + + return list(seen.values()) + + def clear_catalog_cache() -> None: - """Invalidate the in-process catalog cache.""" - global _cache - _cache = None + """Invalidate all in-process catalog caches.""" + _url_cache.clear() diff --git a/fabledscryer/core/plugin_manager.py b/roundtable/core/plugin_manager.py similarity index 64% rename from fabledscryer/core/plugin_manager.py rename to roundtable/core/plugin_manager.py index 1537d97..4f87168 100644 --- a/fabledscryer/core/plugin_manager.py +++ b/roundtable/core/plugin_manager.py @@ -1,4 +1,4 @@ -# fabledscryer/core/plugin_manager.py +# roundtable/core/plugin_manager.py from __future__ import annotations import hashlib import importlib @@ -22,6 +22,48 @@ logger = logging.getLogger(__name__) # whether to register a blueprint (new plugin) or skip it (already registered). _LOADED_PLUGINS: set[str] = set() +# Track plugins that failed to load: name → human-readable reason. +_FAILED_PLUGINS: dict[str, str] = {} + + +def get_plugin_failures() -> dict[str, str]: + """Return a copy of the failed-plugin registry (name → error message).""" + return dict(_FAILED_PLUGINS) + + +def _import_plugin(name: str, plugin_path: Path): + """Load a plugin module by file path, avoiding sys.modules stdlib collisions. + + Using importlib.import_module(name) fails for plugins whose names shadow + Python stdlib modules (e.g. the 'http' plugin vs stdlib's 'http' package). + This helper loads from the filesystem path directly and registers the module + under a namespaced key so relative imports within the plugin still work. + """ + import importlib.util + + module_key = f"_roundtable_plugin_{name}" + if module_key in sys.modules: + return sys.modules[module_key] + + init_file = plugin_path / "__init__.py" + spec = importlib.util.spec_from_file_location( + module_key, + str(init_file), + submodule_search_locations=[str(plugin_path)], + ) + if spec is None or spec.loader is None: + raise ImportError(f"Could not create import spec for plugin {name!r}") + + module = importlib.util.module_from_spec(spec) + module.__package__ = module_key + sys.modules[module_key] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(module_key, None) + raise + return module + def load_plugins(app: "Quart") -> None: """Import all enabled plugins and register their blueprints and scheduled tasks. @@ -36,7 +78,7 @@ def load_plugins(app: "Quart") -> None: 7. Register blueprint at /plugins/<name>/ if get_blueprint() exists 8. Append get_scheduled_tasks() results to app._task_registry """ - import fabledscryer + import roundtable plugin_dir = Path(app.config["PLUGIN_DIR"]) plugins_cfg: dict = app.config["PLUGINS"] @@ -52,23 +94,29 @@ def load_plugins(app: "Quart") -> None: plugin_path = plugin_dir / name if not plugin_path.exists(): + _FAILED_PLUGINS[name] = f"Plugin directory not found: {plugin_path}" logger.error("Plugin %r: directory %s not found, skipping", name, plugin_path) continue # Load and validate plugin.yaml yaml_path = plugin_path / "plugin.yaml" if not yaml_path.exists(): + _FAILED_PLUGINS[name] = "plugin.yaml not found" logger.error("Plugin %r: plugin.yaml not found, skipping", name) continue try: with yaml_path.open() as f: meta = yaml.safe_load(f) or {} - except Exception: + except Exception as exc: + _FAILED_PLUGINS[name] = f"Failed to read plugin.yaml: {exc}" logger.exception("Plugin %r: failed to read plugin.yaml, skipping", name) continue if meta.get("name") != name: + _FAILED_PLUGINS[name] = ( + f"plugin.yaml name {meta.get('name')!r} does not match directory name" + ) logger.error( "Plugin %r: plugin.yaml name=%r does not match directory name, skipping", name, meta.get("name"), @@ -78,33 +126,52 @@ def load_plugins(app: "Quart") -> None: min_ver = meta.get("min_app_version") if min_ver: try: - if Version(fabledscryer.__version__) < Version(min_ver): + if Version(roundtable.__version__) < Version(min_ver): + _FAILED_PLUGINS[name] = ( + f"Requires roundtable>={min_ver}, running {roundtable.__version__}" + ) logger.error( - "Plugin %r: requires fabledscryer>=%s but running %s, skipping", - name, min_ver, fabledscryer.__version__, + "Plugin %r: requires roundtable>=%s but running %s, skipping", + name, min_ver, roundtable.__version__, ) continue - except Exception: + except Exception as exc: + _FAILED_PLUGINS[name] = f"Invalid min_app_version {min_ver!r}: {exc}" logger.exception("Plugin %r: invalid min_app_version %r, skipping", name, min_ver) continue - # Merge plugin.yaml config defaults with user overrides (non-"enabled" keys) + # Merge plugin.yaml config defaults with user overrides (non-"enabled" keys). + # If a stored value's type doesn't match the default (e.g. a list default + # was corrupted to a string in the DB), fall back to the yaml default. defaults = meta.get("config", {}) user_overrides = {k: v for k, v in cfg.items() if k != "enabled"} - plugins_cfg[name] = {"enabled": True, **defaults, **user_overrides} + merged = {"enabled": True, **defaults} + for k, v in user_overrides.items(): + default_v = defaults.get(k) + if default_v is not None and type(v) is not type(default_v): + logger.warning( + "Plugin %r: config key %r has wrong type (%s), using yaml default", + name, k, type(v).__name__, + ) + else: + merged[k] = v + plugins_cfg[name] = merged - # Import the plugin package + # Import the plugin package (file-path load avoids stdlib name collisions) try: - module = importlib.import_module(name) - except ImportError: + module = _import_plugin(name, plugin_path) + except Exception as exc: + _FAILED_PLUGINS[name] = f"Import error: {exc}" logger.exception("Plugin %r: import failed, skipping", name) continue # Validate required exports if not hasattr(module, "setup"): + _FAILED_PLUGINS[name] = "Missing required export: setup()" logger.error("Plugin %r: missing required export 'setup', skipping", name) continue if not hasattr(module, "get_scheduled_tasks"): + _FAILED_PLUGINS[name] = "Missing required export: get_scheduled_tasks()" logger.error( "Plugin %r: missing required export 'get_scheduled_tasks', skipping", name ) @@ -113,7 +180,8 @@ def load_plugins(app: "Quart") -> None: # Call setup try: module.setup(app) - except Exception: + except Exception as exc: + _FAILED_PLUGINS[name] = f"setup() failed: {exc}" logger.exception("Plugin %r: setup() raised, skipping", name) continue @@ -123,17 +191,22 @@ def load_plugins(app: "Quart") -> None: bp = module.get_blueprint() app.register_blueprint(bp, url_prefix=f"/plugins/{name}") logger.info("Plugin %r: blueprint registered at /plugins/%s/", name, name) - except Exception: + except Exception as exc: + _FAILED_PLUGINS[name] = f"Blueprint registration failed: {exc}" logger.exception("Plugin %r: get_blueprint() raised", name) + continue # Register scheduled tasks try: tasks = module.get_scheduled_tasks() app._task_registry.extend(tasks) logger.info("Plugin %r: registered %d scheduled task(s)", name, len(tasks)) - except Exception: + except Exception as exc: + _FAILED_PLUGINS[name] = f"get_scheduled_tasks() failed: {exc}" logger.exception("Plugin %r: get_scheduled_tasks() raised", name) + continue + _FAILED_PLUGINS.pop(name, None) # clear any stale failure from a previous reload attempt _LOADED_PLUGINS.add(name) logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?")) @@ -194,7 +267,7 @@ async def download_and_install_plugin( # Detect a single top-level directory to strip. Handles: # name/ (clean plugin zip) # name-v1.0.0/ (GitHub release tag archive) - # fabledscryer-plugins-main/name/ (whole-repo archive) + # roundtable-plugins-main/name/ (whole-repo archive) # Strategy: if there is exactly one top-level item and it is a # directory whose name starts with the plugin name (case-insensitive), # strip it. Otherwise extract flat into dest. @@ -223,12 +296,18 @@ async def download_and_install_plugin( logger.error("Plugin %r: %s", name, msg) return False, msg - # Run migrations for the newly installed plugin + # Run migrations for the newly installed plugin. + # Include all plugin migration dirs so Alembic can resolve any previously-stamped + # revisions from other plugins already in alembic_version. try: mdir = plugin_dir / name / "migrations" if mdir.exists(): - from fabledscryer.core.migration_runner import run_plugin_migrations - run_plugin_migrations(app.config["DATABASE_URL"], [mdir]) + from roundtable.core.migration_runner import ( + run_plugin_migrations, + discover_all_plugin_migration_dirs, + ) + all_dirs = discover_all_plugin_migration_dirs(plugin_dir) + run_plugin_migrations(app.config["DATABASE_URL"], all_dirs) except Exception: logger.exception("Plugin %r: migration failed after install", name) return False, "Plugin extracted but migrations failed — check logs" @@ -246,7 +325,7 @@ def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]: Returns (success, message). """ - import fabledscryer + import roundtable if name in _LOADED_PLUGINS: return False, "Plugin already loaded — restart required to apply updates" @@ -273,25 +352,49 @@ def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]: min_ver = meta.get("min_app_version") if min_ver: try: - if Version(fabledscryer.__version__) < Version(min_ver): + if Version(roundtable.__version__) < Version(min_ver): return False, ( - f"Plugin requires fabledscryer>={min_ver} " - f"but running {fabledscryer.__version__}" + f"Plugin requires roundtable>={min_ver} " + f"but running {roundtable.__version__}" ) except Exception: return False, f"Invalid min_app_version: {min_ver!r}" - # Merge config defaults with any stored user config + # Merge config defaults with any stored user config. + # Discard stored values whose type doesn't match the yaml default (corruption guard). defaults = meta.get("config", {}) stored_cfg = app.config.get("PLUGINS", {}).get(name, {}) - user_overrides = {k: v for k, v in stored_cfg.items() if k != "enabled"} - merged = {"enabled": True, **defaults, **user_overrides} + merged = {"enabled": True, **defaults} + for k, v in stored_cfg.items(): + if k == "enabled": + continue + default_v = defaults.get(k) + if default_v is not None and type(v) is not type(default_v): + logger.warning("Plugin %r: config key %r has wrong type, using yaml default", name, k) + else: + merged[k] = v app.config["PLUGINS"][name] = merged - # Import + # Run migrations if the plugin has them (safe to re-run — alembic is idempotent). + # Pass ALL plugin migration dirs so Alembic can resolve any previously-stamped + # revisions from other plugins that are already in alembic_version. + mdir = plugin_path / "migrations" + if mdir.exists(): + try: + from roundtable.core.migration_runner import ( + run_plugin_migrations, + discover_all_plugin_migration_dirs, + ) + all_dirs = discover_all_plugin_migration_dirs(plugin_dir) + run_plugin_migrations(app.config["DATABASE_URL"], all_dirs) + except Exception: + logger.exception("Plugin %r: migration failed during hot-reload", name) + return False, "Migration failed — check logs" + + # Import (file-path load avoids stdlib name collisions) try: - module = importlib.import_module(name) - except ImportError as exc: + module = _import_plugin(name, plugin_path) + except Exception as exc: return False, f"Import failed: {exc}" if not hasattr(module, "setup") or not hasattr(module, "get_scheduled_tasks"): diff --git a/roundtable/core/reports.py b/roundtable/core/reports.py new file mode 100644 index 0000000..992aedc --- /dev/null +++ b/roundtable/core/reports.py @@ -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) diff --git a/fabledscryer/core/scheduler.py b/roundtable/core/scheduler.py similarity index 100% rename from fabledscryer/core/scheduler.py rename to roundtable/core/scheduler.py diff --git a/fabledscryer/core/settings.py b/roundtable/core/settings.py similarity index 81% rename from fabledscryer/core/settings.py rename to roundtable/core/settings.py index 920cbbe..e6568f8 100644 --- a/fabledscryer/core/settings.py +++ b/roundtable/core/settings.py @@ -1,15 +1,15 @@ -# fabledscryer/core/settings.py +# roundtable/core/settings.py """DB-backed application settings. Keys use dotted notation (e.g. "smtp.host"). Values are JSON-encoded in the DB. Usage in create_app() (before event loop): - from fabledscryer.core.settings import load_settings_sync + from roundtable.core.settings import load_settings_sync settings = load_settings_sync(db_url) Usage at runtime (inside async handlers): - from fabledscryer.core.settings import get_setting, set_setting + from roundtable.core.settings import get_setting, set_setting async with app.db_sessionmaker() as session: value = await get_setting(session, "smtp.host") await set_setting(session, "smtp.host", "mail.example.com") @@ -24,7 +24,7 @@ from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from fabledscryer.models.settings import AppSetting +from roundtable.models.settings import AppSetting logger = logging.getLogger(__name__) @@ -50,7 +50,36 @@ DEFAULTS: dict[str, Any] = { "ansible.sources": [], "ping.threshold.good_ms": 50, "ping.threshold.warn_ms": 200, - "plugins.index_url": "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/raw/branch/main/index.yaml", + "plugins.index_url": "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/raw/branch/main/index.yaml", + # OIDC single-sign-on + "oidc.enabled": False, + "oidc.discovery_url": "", + "oidc.client_id": "", + "oidc.client_secret": "", + "oidc.scopes": "openid profile email", + "oidc.username_claim": "preferred_username", + "oidc.email_claim": "email", + "oidc.groups_claim": "groups", + "oidc.admin_group": "", + "oidc.operator_group": "", + # LDAP authentication + "ldap.enabled": False, + "ldap.host": "", + "ldap.port": 389, + "ldap.tls": False, + "ldap.bind_dn": "", + "ldap.bind_password": "", + "ldap.base_dn": "", + "ldap.user_filter": "(uid={username})", + "ldap.admin_group_dn": "", + "ldap.operator_group_dn": "", + "ldap.attr_username": "uid", + "ldap.attr_email": "mail", + # Scheduled reports + "reports.enabled": False, + "reports.schedule_day": 6, # 0=Monday … 6=Sunday + "reports.schedule_hour": 8, # UTC hour + "reports.last_sent_at": "", } @@ -123,6 +152,14 @@ def to_ansible_cfg(settings: dict[str, Any]) -> dict: return {"sources": settings.get("ansible.sources", [])} +def to_oidc_cfg(settings: dict[str, Any]) -> dict: + return {k[len("oidc."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("oidc.")} + + +def to_ldap_cfg(settings: dict[str, Any]) -> dict: + return {k[len("ldap."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("ldap.")} + + def to_plugins_cfg(settings: dict[str, Any]) -> dict: """Assemble {plugin_name: {...config}} from all plugin.* keys.""" result = {} diff --git a/fabledscryer/core/time_range.py b/roundtable/core/time_range.py similarity index 97% rename from fabledscryer/core/time_range.py rename to roundtable/core/time_range.py index 2884cc1..ad984f3 100644 --- a/fabledscryer/core/time_range.py +++ b/roundtable/core/time_range.py @@ -1,4 +1,4 @@ -# fabledscryer/core/time_range.py +# roundtable/core/time_range.py """Shared time-range utilities for scoping queries and sparkline data.""" from __future__ import annotations from datetime import datetime, timedelta, timezone diff --git a/roundtable/core/widgets.py b/roundtable/core/widgets.py new file mode 100644 index 0000000..15f8b56 --- /dev/null +++ b/roundtable/core/widgets.py @@ -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 diff --git a/fabledscryer/dns/__init__.py b/roundtable/dashboard/__init__.py similarity index 100% rename from fabledscryer/dns/__init__.py rename to roundtable/dashboard/__init__.py diff --git a/fabledscryer/dashboard/routes.py b/roundtable/dashboard/routes.py similarity index 88% rename from fabledscryer/dashboard/routes.py rename to roundtable/dashboard/routes.py index 071fd7c..b5ccfb0 100644 --- a/fabledscryer/dashboard/routes.py +++ b/roundtable/dashboard/routes.py @@ -1,15 +1,17 @@ from __future__ import annotations import hashlib +import json import secrets from datetime import datetime, timezone, timedelta +from urllib.parse import urlencode from quart import Blueprint, render_template, current_app, abort, redirect, request, session from sqlalchemy import select, func, update -from fabledscryer.auth.middleware import require_role, current_user_id -from fabledscryer.models.users import UserRole -from fabledscryer.models.hosts import Host -from fabledscryer.models.monitors import PingResult, DnsResult -from fabledscryer.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken -from fabledscryer.core.widgets import get_available_widgets, WIDGET_REGISTRY +from roundtable.auth.middleware import require_role, current_user_id +from roundtable.models.users import UserRole +from roundtable.models.hosts import Host +from roundtable.models.monitors import PingResult, DnsResult +from roundtable.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken +from roundtable.core.widgets import get_available_widgets, WIDGET_REGISTRY dashboard_bp = Blueprint("dashboard", __name__) @@ -146,7 +148,22 @@ def _resolve_widgets(widgets: list[DashboardWidget]) -> list[dict]: continue if defn["plugin"] and not plugins_cfg.get(defn["plugin"], {}).get("enabled", False): continue - out.append({"db": w, "defn": defn}) + # Build stored config (falls back to empty; defaults live in the registry) + config: dict = {} + if w.config_json: + try: + config = json.loads(w.config_json) + except (ValueError, TypeError): + pass + # Always append wid so widget templates can create unique canvas IDs + url_params = {**config, "wid": w.id} + hx_url = defn["hx_url"] + "?" + urlencode(url_params) + out.append({ + "db": w, + "defn": defn, + "hx_url": hx_url, + "title": w.title or defn["label"], + }) return out @@ -160,13 +177,12 @@ async def _get_panels_data(db, dashboard_id: int) -> tuple: widgets = await _get_widgets(db, dashboard_id) plugins_cfg = current_app.config.get("PLUGINS", {}) available = get_available_widgets(plugins_cfg) - placed_keys = {w.widget_key for w in widgets} - addable = [w for w in available if w["key"] not in placed_keys] + # Multiple instances of the same widget type are allowed — show all available resolved = [ - {"db": w, "defn": WIDGET_REGISTRY[w.widget_key]} + {"db": w, "defn": WIDGET_REGISTRY[w.widget_key], "title": w.title or WIDGET_REGISTRY[w.widget_key]["label"]} for w in widgets if w.widget_key in WIDGET_REGISTRY ] - return dash, resolved, addable + return dash, resolved, available async def _panels_response(dashboard_id: int): @@ -367,20 +383,36 @@ async def edit(dash_id: int): async def add_widget(dash_id: int, widget_key: str): user_id = current_user_id() user_role = session.get("user_role", "viewer") - if widget_key not in WIDGET_REGISTRY: + defn = WIDGET_REGISTRY.get(widget_key) + if defn is None: abort(400) + form = await request.form + title = form.get("title", "").strip() or None + # Parse configured params + config: dict = {} + for p in defn.get("params", []): + val = form.get(f"param_{p['key']}") + if val is not None: + if isinstance(p["default"], int): + try: + config[p["key"]] = int(val) + except ValueError: + config[p["key"]] = p["default"] + else: + config[p["key"]] = val + config_json = json.dumps(config) if config else None async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id)) dash = result.scalar_one_or_none() if dash is None or not _can_edit(dash, user_id, user_role): abort(403) - widgets = await _get_widgets(db, dash_id) - if not any(w.widget_key == widget_key for w in widgets): - next_pos = max((w.position for w in widgets), default=-1) + 1 - db.add(DashboardWidget( - dashboard_id=dash_id, widget_key=widget_key, position=next_pos, - )) + widgets = await _get_widgets(db, dash_id) + next_pos = max((w.position for w in widgets), default=-1) + 1 + db.add(DashboardWidget( + dashboard_id=dash_id, widget_key=widget_key, position=next_pos, + title=title, config_json=config_json, + )) return await _panels_response(dash_id) @@ -405,28 +437,27 @@ async def remove_widget(dash_id: int, widget_id: int): return await _panels_response(dash_id) -@dashboard_bp.post("/d/<int:dash_id>/edit/move/<int:widget_id>/<direction>") +@dashboard_bp.post("/d/<int:dash_id>/edit/reorder") @require_role(UserRole.viewer) -async def move_widget(dash_id: int, widget_id: int, direction: str): +async def reorder_widgets(dash_id: int): user_id = current_user_id() user_role = session.get("user_role", "viewer") - if direction not in ("up", "down"): + data = await request.get_json(silent=True) + if not data or not isinstance(data.get("order"), list): abort(400) + order: list[int] = data["order"] async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute(select(Dashboard).where(Dashboard.id == dash_id)) dash = result.scalar_one_or_none() if dash is None or not _can_edit(dash, user_id, user_role): abort(403) - widgets = await _get_widgets(db, dash_id) - idx = next((i for i, w in enumerate(widgets) if w.id == widget_id), None) - if idx is not None: - swap_idx = (idx - 1) if direction == "up" else (idx + 1) - if 0 <= swap_idx < len(widgets): - widgets[idx].position, widgets[swap_idx].position = ( - widgets[swap_idx].position, widgets[idx].position - ) - return await _panels_response(dash_id) + widgets = await _get_widgets(db, dash_id) + widget_map = {w.id: w for w in widgets} + for pos, wid in enumerate(order): + if wid in widget_map: + widget_map[wid].position = pos + return ("", 204) # ── Share tokens ────────────────────────────────────────────────────────────── diff --git a/fabledscryer/database.py b/roundtable/database.py similarity index 100% rename from fabledscryer/database.py rename to roundtable/database.py diff --git a/fabledscryer/hosts/__init__.py b/roundtable/dns/__init__.py similarity index 100% rename from fabledscryer/hosts/__init__.py rename to roundtable/dns/__init__.py diff --git a/fabledscryer/dns/routes.py b/roundtable/dns/routes.py similarity index 89% rename from fabledscryer/dns/routes.py rename to roundtable/dns/routes.py index c8e5199..04b9ca7 100644 --- a/fabledscryer/dns/routes.py +++ b/roundtable/dns/routes.py @@ -1,10 +1,10 @@ from __future__ import annotations from quart import Blueprint, current_app, render_template from sqlalchemy import select, func -from fabledscryer.auth.middleware import require_role -from fabledscryer.models.users import UserRole -from fabledscryer.models.hosts import Host -from fabledscryer.models.monitors import DnsResult +from roundtable.auth.middleware import require_role +from roundtable.models.users import UserRole +from roundtable.models.hosts import Host +from roundtable.models.monitors import DnsResult dns_bp = Blueprint("dns", __name__, url_prefix="/dns") diff --git a/fabledscryer/migrations/__init__.py b/roundtable/hosts/__init__.py similarity index 100% rename from fabledscryer/migrations/__init__.py rename to roundtable/hosts/__init__.py diff --git a/fabledscryer/hosts/routes.py b/roundtable/hosts/routes.py similarity index 54% rename from fabledscryer/hosts/routes.py rename to roundtable/hosts/routes.py index c4e3b78..aa1af6c 100644 --- a/fabledscryer/hosts/routes.py +++ b/roundtable/hosts/routes.py @@ -1,14 +1,62 @@ from __future__ import annotations -from quart import Blueprint, render_template, request, redirect, url_for, current_app -from sqlalchemy import select, func -from fabledscryer.auth.middleware import require_role -from fabledscryer.models.hosts import Host, ProbeType -from fabledscryer.models.monitors import PingResult, DnsResult -from fabledscryer.models.users import UserRole +from datetime import datetime, timedelta, timezone +from quart import Blueprint, render_template, request, redirect, url_for, current_app, session +from sqlalchemy import and_, case, select, func +from roundtable.auth.middleware import require_role +from roundtable.core.audit import log_audit +from roundtable.models.hosts import Host, ProbeType +from roundtable.models.monitors import PingResult, DnsResult, PingStatus +from roundtable.models.users import UserRole hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts") +async def _compute_uptime(db) -> dict[str, dict]: + """Return per-host uptime % for 24h, 7d, 30d windows. + + Returns {host_id: {"24h": float|None, "7d": float|None, "30d": float|None}}. + """ + now = datetime.now(timezone.utc) + cutoff_30d = now - timedelta(days=30) + cutoff_7d = now - timedelta(days=7) + cutoff_24h = now - timedelta(hours=24) + + result = await db.execute( + select( + PingResult.host_id, + # 30d window — all rows in query qualify + func.count(PingResult.id).label("total_30d"), + func.sum(case((PingResult.status == PingStatus.up, 1), else_=0)).label("up_30d"), + # 7d sub-window + func.sum(case((PingResult.probed_at >= cutoff_7d, 1), else_=0)).label("total_7d"), + func.sum(case( + (and_(PingResult.probed_at >= cutoff_7d, PingResult.status == PingStatus.up), 1), + else_=0, + )).label("up_7d"), + # 24h sub-window + func.sum(case((PingResult.probed_at >= cutoff_24h, 1), else_=0)).label("total_24h"), + func.sum(case( + (and_(PingResult.probed_at >= cutoff_24h, PingResult.status == PingStatus.up), 1), + else_=0, + )).label("up_24h"), + ) + .where(PingResult.probed_at >= cutoff_30d) + .group_by(PingResult.host_id) + ) + + def _pct(up, total): + return round(float(up) / float(total) * 100, 2) if total else None + + stats: dict[str, dict] = {} + for row in result: + stats[row.host_id] = { + "24h": _pct(row.up_24h, row.total_24h), + "7d": _pct(row.up_7d, row.total_7d), + "30d": _pct(row.up_30d, row.total_30d), + } + return stats + + @hosts_bp.get("/") @require_role(UserRole.viewer) async def list_hosts(): @@ -45,12 +93,14 @@ async def list_hosts(): ) ) latest_dns = {r.host_id: r for r in dr.scalars()} + uptime = await _compute_uptime(db) return await render_template( "hosts/list.html", hosts=hosts, latest_pings=latest_pings, latest_dns=latest_dns, + uptime=uptime, ) @@ -77,6 +127,9 @@ async def create_host(): async with current_app.db_sessionmaker() as db: async with db.begin(): db.add(host) + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "host.created", entity_type="host", entity_id=host.name, + detail={"address": host.address}) return redirect(url_for("hosts.list_hosts")) @@ -115,10 +168,43 @@ async def update_host(host_id: str): @hosts_bp.post("/<host_id>/delete") @require_role(UserRole.admin) async def delete_host(host_id: str): + host_name = None async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute(select(Host).where(Host.id == host_id)) host = result.scalar_one_or_none() if host: + host_name = host.name await db.delete(host) + if host_name: + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "host.deleted", entity_type="host", entity_id=host_name) return redirect(url_for("hosts.list_hosts")) + + +@hosts_bp.get("/uptime") +@require_role(UserRole.viewer) +async def uptime_page(): + async with current_app.db_sessionmaker() as db: + result = await db.execute(select(Host).order_by(Host.name)) + hosts = result.scalars().all() + uptime = await _compute_uptime(db) + return await render_template("hosts/uptime.html", hosts=hosts, uptime=uptime) + + +@hosts_bp.get("/uptime/widget") +@require_role(UserRole.viewer) +async def uptime_widget(): + share_token = request.args.get("s") + async with current_app.db_sessionmaker() as db: + result = await db.execute( + select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name) + ) + hosts = result.scalars().all() + uptime = await _compute_uptime(db) + return await render_template( + "hosts/uptime_widget.html", + hosts=hosts, + uptime=uptime, + share_token=share_token, + ) diff --git a/fabledscryer/migrations/versions/__init__.py b/roundtable/migrations/__init__.py similarity index 100% rename from fabledscryer/migrations/versions/__init__.py rename to roundtable/migrations/__init__.py diff --git a/fabledscryer/migrations/env.py b/roundtable/migrations/env.py similarity index 82% rename from fabledscryer/migrations/env.py rename to roundtable/migrations/env.py index 5853d97..8785208 100644 --- a/fabledscryer/migrations/env.py +++ b/roundtable/migrations/env.py @@ -4,8 +4,8 @@ from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config from alembic import context -from fabledscryer.models.base import Base -import fabledscryer.models # noqa: F401 — registers all models in metadata +from roundtable.models.base import Base +import roundtable.models # noqa: F401 — registers all models in metadata config = context.config if config.config_file_name is not None: @@ -16,18 +16,20 @@ target_metadata = Base.metadata def get_url() -> str: import os, yaml - cfg_path = os.environ.get("FABLEDSCRYER_CONFIG", "config.yaml") + + def _env(suffix: str) -> str | None: + return os.environ.get(f"ROUNDTABLE_{suffix}") or os.environ.get( + f"FABLEDSCRYER_{suffix}" + ) + + cfg_path = _env("CONFIG") or "config.yaml" try: with open(cfg_path) as f: cfg = yaml.safe_load(f) or {} url = cfg.get("database", {}).get("url", "") except FileNotFoundError: url = "" - return ( - os.environ.get("FABLEDSCRYER_DATABASE_URL") - or os.environ.get("FABLEDSCRYER_DATABASE__URL") - or url - ) + return _env("DATABASE_URL") or _env("DATABASE__URL") or url def _resolved_url() -> str: diff --git a/fabledscryer/migrations/script.py.mako b/roundtable/migrations/script.py.mako similarity index 100% rename from fabledscryer/migrations/script.py.mako rename to roundtable/migrations/script.py.mako diff --git a/fabledscryer/migrations/versions/0001_core_initial.py b/roundtable/migrations/versions/0001_core_initial.py similarity index 100% rename from fabledscryer/migrations/versions/0001_core_initial.py rename to roundtable/migrations/versions/0001_core_initial.py diff --git a/fabledscryer/migrations/versions/0002_core_monitors.py b/roundtable/migrations/versions/0002_core_monitors.py similarity index 100% rename from fabledscryer/migrations/versions/0002_core_monitors.py rename to roundtable/migrations/versions/0002_core_monitors.py diff --git a/fabledscryer/migrations/versions/0003_ansible_runs.py b/roundtable/migrations/versions/0003_ansible_runs.py similarity index 100% rename from fabledscryer/migrations/versions/0003_ansible_runs.py rename to roundtable/migrations/versions/0003_ansible_runs.py diff --git a/fabledscryer/migrations/versions/0004_app_settings.py b/roundtable/migrations/versions/0004_app_settings.py similarity index 100% rename from fabledscryer/migrations/versions/0004_app_settings.py rename to roundtable/migrations/versions/0004_app_settings.py diff --git a/fabledscryer/migrations/versions/0005_dashboards.py b/roundtable/migrations/versions/0005_dashboards.py similarity index 100% rename from fabledscryer/migrations/versions/0005_dashboards.py rename to roundtable/migrations/versions/0005_dashboards.py diff --git a/fabledscryer/migrations/versions/0006_dashboard_is_default.py b/roundtable/migrations/versions/0006_dashboard_is_default.py similarity index 100% rename from fabledscryer/migrations/versions/0006_dashboard_is_default.py rename to roundtable/migrations/versions/0006_dashboard_is_default.py diff --git a/fabledscryer/migrations/versions/0007_dashboard_ownership.py b/roundtable/migrations/versions/0007_dashboard_ownership.py similarity index 100% rename from fabledscryer/migrations/versions/0007_dashboard_ownership.py rename to roundtable/migrations/versions/0007_dashboard_ownership.py diff --git a/fabledscryer/migrations/versions/0008_share_tokens.py b/roundtable/migrations/versions/0008_share_tokens.py similarity index 100% rename from fabledscryer/migrations/versions/0008_share_tokens.py rename to roundtable/migrations/versions/0008_share_tokens.py diff --git a/roundtable/migrations/versions/0009_widget_variants.py b/roundtable/migrations/versions/0009_widget_variants.py new file mode 100644 index 0000000..286a3e5 --- /dev/null +++ b/roundtable/migrations/versions/0009_widget_variants.py @@ -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") diff --git a/roundtable/migrations/versions/0010_maintenance_windows.py b/roundtable/migrations/versions/0010_maintenance_windows.py new file mode 100644 index 0000000..309cd15 --- /dev/null +++ b/roundtable/migrations/versions/0010_maintenance_windows.py @@ -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") diff --git a/roundtable/migrations/versions/0011_audit_log.py b/roundtable/migrations/versions/0011_audit_log.py new file mode 100644 index 0000000..c3e3fdb --- /dev/null +++ b/roundtable/migrations/versions/0011_audit_log.py @@ -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") diff --git a/fabledscryer/monitors/__init__.py b/roundtable/migrations/versions/__init__.py similarity index 100% rename from fabledscryer/monitors/__init__.py rename to roundtable/migrations/versions/__init__.py diff --git a/fabledscryer/models/__init__.py b/roundtable/models/__init__.py similarity index 100% rename from fabledscryer/models/__init__.py rename to roundtable/models/__init__.py diff --git a/fabledscryer/models/alerts.py b/roundtable/models/alerts.py similarity index 78% rename from fabledscryer/models/alerts.py rename to roundtable/models/alerts.py index fcb061a..88e85e6 100644 --- a/fabledscryer/models/alerts.py +++ b/roundtable/models/alerts.py @@ -62,6 +62,24 @@ class AlertState(Base): acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) +class MaintenanceWindow(Base): + __tablename__ = "maintenance_windows" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + name: Mapped[str] = mapped_column(String(128), nullable=False) + start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + # Scope — null means "all". scope_resource requires scope_source to be set. + scope_source: Mapped[str | None] = mapped_column(String(64), nullable=True) + scope_resource: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_by: Mapped[str] = mapped_column( + String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + ) + + class AlertEvent(Base): __tablename__ = "alert_events" diff --git a/fabledscryer/models/ansible.py b/roundtable/models/ansible.py similarity index 100% rename from fabledscryer/models/ansible.py rename to roundtable/models/ansible.py diff --git a/roundtable/models/audit.py b/roundtable/models/audit.py new file mode 100644 index 0000000..2d53d50 --- /dev/null +++ b/roundtable/models/audit.py @@ -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) diff --git a/fabledscryer/models/base.py b/roundtable/models/base.py similarity index 100% rename from fabledscryer/models/base.py rename to roundtable/models/base.py diff --git a/fabledscryer/models/dashboard.py b/roundtable/models/dashboard.py similarity index 93% rename from fabledscryer/models/dashboard.py rename to roundtable/models/dashboard.py index c334dfa..34368c1 100644 --- a/fabledscryer/models/dashboard.py +++ b/roundtable/models/dashboard.py @@ -1,6 +1,6 @@ from __future__ import annotations from datetime import datetime, timezone -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column from .base import Base @@ -51,3 +51,5 @@ class DashboardWidget(Base): ) widget_key: Mapped[str] = mapped_column(String(64), nullable=False) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + title: Mapped[str | None] = mapped_column(String(128), nullable=True) + config_json: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/fabledscryer/models/hosts.py b/roundtable/models/hosts.py similarity index 100% rename from fabledscryer/models/hosts.py rename to roundtable/models/hosts.py diff --git a/fabledscryer/models/metrics.py b/roundtable/models/metrics.py similarity index 100% rename from fabledscryer/models/metrics.py rename to roundtable/models/metrics.py diff --git a/fabledscryer/models/monitors.py b/roundtable/models/monitors.py similarity index 100% rename from fabledscryer/models/monitors.py rename to roundtable/models/monitors.py diff --git a/fabledscryer/models/settings.py b/roundtable/models/settings.py similarity index 93% rename from fabledscryer/models/settings.py rename to roundtable/models/settings.py index 54c0997..02c014d 100644 --- a/fabledscryer/models/settings.py +++ b/roundtable/models/settings.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime, timezone from sqlalchemy import DateTime, String, Text from sqlalchemy.orm import Mapped, mapped_column -from fabledscryer.models.base import Base +from roundtable.models.base import Base class AppSetting(Base): diff --git a/fabledscryer/models/users.py b/roundtable/models/users.py similarity index 100% rename from fabledscryer/models/users.py rename to roundtable/models/users.py diff --git a/fabledscryer/ping/__init__.py b/roundtable/monitors/__init__.py similarity index 100% rename from fabledscryer/ping/__init__.py rename to roundtable/monitors/__init__.py diff --git a/fabledscryer/monitors/dns.py b/roundtable/monitors/dns.py similarity index 93% rename from fabledscryer/monitors/dns.py rename to roundtable/monitors/dns.py index 58b8f77..7f123db 100644 --- a/fabledscryer/monitors/dns.py +++ b/roundtable/monitors/dns.py @@ -6,9 +6,9 @@ import socket from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from fabledscryer.core.alerts import record_metric -from fabledscryer.models.hosts import Host -from fabledscryer.models.monitors import DnsResult, DnsStatus +from roundtable.core.alerts import record_metric +from roundtable.models.hosts import Host +from roundtable.models.monitors import DnsResult, DnsStatus logger = logging.getLogger(__name__) diff --git a/fabledscryer/monitors/ping.py b/roundtable/monitors/ping.py similarity index 92% rename from fabledscryer/monitors/ping.py rename to roundtable/monitors/ping.py index aaf2e21..13e4007 100644 --- a/fabledscryer/monitors/ping.py +++ b/roundtable/monitors/ping.py @@ -5,9 +5,9 @@ import time from sqlalchemy.ext.asyncio import AsyncSession -from fabledscryer.core.alerts import record_metric -from fabledscryer.models.hosts import Host, ProbeType -from fabledscryer.models.monitors import PingResult, PingStatus +from roundtable.core.alerts import record_metric +from roundtable.models.hosts import Host, ProbeType +from roundtable.models.monitors import PingResult, PingStatus logger = logging.getLogger(__name__) diff --git a/roundtable/ping/__init__.py b/roundtable/ping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fabledscryer/ping/routes.py b/roundtable/ping/routes.py similarity index 92% rename from fabledscryer/ping/routes.py rename to roundtable/ping/routes.py index 4b79e51..6e6021e 100644 --- a/fabledscryer/ping/routes.py +++ b/roundtable/ping/routes.py @@ -2,12 +2,12 @@ from __future__ import annotations import logging from quart import Blueprint, current_app, render_template, request, redirect, url_for from sqlalchemy import select, func, case -from fabledscryer.auth.middleware import require_role -from fabledscryer.models.users import UserRole -from fabledscryer.models.hosts import Host -from fabledscryer.models.monitors import PingResult -from fabledscryer.core.settings import get_all_settings, set_setting, DEFAULTS -from fabledscryer.core.time_range import parse_range, DEFAULT_RANGE +from roundtable.auth.middleware import require_role +from roundtable.models.users import UserRole +from roundtable.models.hosts import Host +from roundtable.models.monitors import PingResult +from roundtable.core.settings import get_all_settings, set_setting, DEFAULTS +from roundtable.core.time_range import parse_range, DEFAULT_RANGE ping_bp = Blueprint("ping", __name__, url_prefix="/ping") logger = logging.getLogger(__name__) diff --git a/roundtable/settings/__init__.py b/roundtable/settings/__init__.py new file mode 100644 index 0000000..0acad26 --- /dev/null +++ b/roundtable/settings/__init__.py @@ -0,0 +1 @@ +# roundtable/settings/__init__.py diff --git a/roundtable/settings/routes.py b/roundtable/settings/routes.py new file mode 100644 index 0000000..a75910c --- /dev/null +++ b/roundtable/settings/routes.py @@ -0,0 +1,661 @@ +# roundtable/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, session + +from roundtable.auth.middleware import require_role +from roundtable.core.audit import log_audit +from roundtable.models.users import UserRole +from roundtable.core.settings import ( + DEFAULTS, get_all_settings, set_setting, + to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg, + to_oidc_cfg, to_ldap_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), + OIDC=to_oidc_cfg(fresh), + LDAP=to_ldap_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() + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "settings.saved", detail={"section": "general"}) + 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() + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "settings.saved", detail={"section": "notifications"}) + return redirect(url_for("settings.notifications")) + + +# ── Ansible Sources ─────────────────────────────────────────────────────────── + +async def _get_ansible_sources() -> list[dict]: + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + sources = settings.get("ansible.sources", []) + return sources if isinstance(sources, list) else [] + + +async def _save_ansible_sources(sources: list[dict]) -> None: + async with current_app.db_sessionmaker() as db: + async with db.begin(): + await set_setting(db, "ansible.sources", sources) + await _reload_app_config() + + +async def _ansible_sources_partial(sources: list[dict]): + return await render_template("settings/_ansible_sources.html", sources=sources) + + +@settings_bp.get("/ansible/") +@require_role(UserRole.admin) +async def ansible(): + sources = await _get_ansible_sources() + return await render_template("settings/ansible.html", sources=sources) + + +@settings_bp.post("/ansible/sources/add") +@require_role(UserRole.admin) +async def ansible_add_source(): + form = await request.form + name = form.get("name", "").strip() + src_type = form.get("type", "local").strip() + if not name: + sources = await _get_ansible_sources() + return await _ansible_sources_partial(sources) + entry: dict = {"name": name, "type": src_type} + if src_type == "git": + entry["url"] = form.get("url", "").strip() + entry["branch"] = form.get("branch", "main").strip() or "main" + try: + entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600)) + except ValueError: + entry["pull_interval_seconds"] = 3600 + else: + entry["path"] = form.get("path", "").strip() + sources = await _get_ansible_sources() + sources.append(entry) + await _save_ansible_sources(sources) + return await _ansible_sources_partial(sources) + + +@settings_bp.post("/ansible/sources/<int:idx>/remove") +@require_role(UserRole.admin) +async def ansible_remove_source(idx: int): + sources = await _get_ansible_sources() + if 0 <= idx < len(sources): + sources.pop(idx) + await _save_ansible_sources(sources) + return await _ansible_sources_partial(sources) + + +@settings_bp.post("/ansible/sources/<int:idx>/save") +@require_role(UserRole.admin) +async def ansible_save_source(idx: int): + sources = await _get_ansible_sources() + if not (0 <= idx < len(sources)): + return await _ansible_sources_partial(sources) + form = await request.form + src_type = form.get("type", "local").strip() + entry: dict = { + "name": form.get("name", sources[idx].get("name", "")).strip(), + "type": src_type, + } + if src_type == "git": + entry["url"] = form.get("url", "").strip() + entry["branch"] = form.get("branch", "main").strip() or "main" + try: + entry["pull_interval_seconds"] = int(form.get("pull_interval_seconds", 3600)) + except ValueError: + entry["pull_interval_seconds"] = 3600 + else: + entry["path"] = form.get("path", "").strip() + sources[idx] = entry + await _save_ansible_sources(sources) + return await _ansible_sources_partial(sources) + + +@settings_bp.post("/ansible/sources/<int:idx>/sync") +@require_role(UserRole.admin) +async def ansible_sync_source(idx: int): + """Trigger a git pull for a git source and return a status badge.""" + sources = await _get_ansible_sources() + if not (0 <= idx < len(sources)): + return ('<span style="color:var(--red);font-size:0.8rem;">Source not found</span>', 404) + source = sources[idx] + if source.get("type") != "git": + return '<span style="color:var(--text-muted);font-size:0.8rem;">Not a git source</span>' + from roundtable.ansible.sources import git_pull + from roundtable.core.settings import to_ansible_cfg + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + ansible_cfg = to_ansible_cfg(settings) + from roundtable.ansible.sources import get_sources + resolved = next((s for s in get_sources(ansible_cfg) if s["name"] == source["name"]), None) + if resolved is None: + return '<span style="color:var(--red);font-size:0.8rem;">Could not resolve source path</span>' + try: + await git_pull(resolved) + return '<span style="color:var(--green);font-size:0.8rem;">Synced</span>' + except Exception as exc: + logger.exception("git pull failed for source %r", source["name"]) + return f'<span style="color:var(--red);font-size:0.8rem;">Sync failed: {exc}</span>' + + +# ── Auth (OIDC / LDAP) ──────────────────────────────────────────────────────── + +@settings_bp.get("/auth/") +@require_role(UserRole.admin) +async def auth_settings(): + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + return await render_template("settings/auth.html", settings=settings) + + +@settings_bp.post("/auth/") +@require_role(UserRole.admin) +async def save_auth_settings(): + form = await request.form + async with current_app.db_sessionmaker() as db: + async with db.begin(): + # OIDC + await set_setting(db, "oidc.enabled", "oidc.enabled" in form) + await set_setting(db, "oidc.discovery_url", form.get("oidc.discovery_url", "").strip()) + await set_setting(db, "oidc.client_id", form.get("oidc.client_id", "").strip()) + if form.get("oidc.client_secret"): + await set_setting(db, "oidc.client_secret", form.get("oidc.client_secret").strip()) + await set_setting(db, "oidc.scopes", form.get("oidc.scopes", "openid profile email").strip()) + await set_setting(db, "oidc.username_claim", form.get("oidc.username_claim", "preferred_username").strip()) + await set_setting(db, "oidc.email_claim", form.get("oidc.email_claim", "email").strip()) + await set_setting(db, "oidc.groups_claim", form.get("oidc.groups_claim", "groups").strip()) + await set_setting(db, "oidc.admin_group", form.get("oidc.admin_group", "").strip()) + await set_setting(db, "oidc.operator_group", form.get("oidc.operator_group", "").strip()) + # LDAP + await set_setting(db, "ldap.enabled", "ldap.enabled" in form) + await set_setting(db, "ldap.host", form.get("ldap.host", "").strip()) + await set_setting(db, "ldap.port", int(form.get("ldap.port") or 389)) + await set_setting(db, "ldap.tls", "ldap.tls" in form) + await set_setting(db, "ldap.bind_dn", form.get("ldap.bind_dn", "").strip()) + if form.get("ldap.bind_password"): + await set_setting(db, "ldap.bind_password", form.get("ldap.bind_password").strip()) + await set_setting(db, "ldap.base_dn", form.get("ldap.base_dn", "").strip()) + await set_setting(db, "ldap.user_filter", form.get("ldap.user_filter", "(uid={username})").strip()) + await set_setting(db, "ldap.admin_group_dn", form.get("ldap.admin_group_dn", "").strip()) + await set_setting(db, "ldap.operator_group_dn", form.get("ldap.operator_group_dn", "").strip()) + await set_setting(db, "ldap.attr_username", form.get("ldap.attr_username", "uid").strip()) + await set_setting(db, "ldap.attr_email", form.get("ldap.attr_email", "mail").strip()) + await _reload_app_config() + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "settings.saved", detail={"section": "auth"}) + # Clear OIDC discovery cache so new settings take effect + from roundtable.auth.oidc import _discovery_cache + _discovery_cache.clear() + return redirect(url_for("settings.auth_settings")) + + +# ── Reports ─────────────────────────────────────────────────────────────────── + +@settings_bp.get("/reports/") +@require_role(UserRole.admin) +async def reports(): + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + return await render_template("settings/reports.html", settings=settings, flash=None) + + +@settings_bp.post("/reports/") +@require_role(UserRole.admin) +async def save_reports(): + form = await request.form + async with current_app.db_sessionmaker() as db: + async with db.begin(): + await set_setting(db, "reports.enabled", "reports.enabled" in form) + await set_setting(db, "reports.schedule_day", + int(form.get("reports.schedule_day", 6))) + await set_setting(db, "reports.schedule_hour", + int(form.get("reports.schedule_hour", 8))) + return redirect(url_for("settings.reports")) + + +@settings_bp.post("/reports/send-now/") +@require_role(UserRole.admin) +async def send_report_now(): + from roundtable.core.reports import send_report + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + ok, message = await send_report(current_app._get_current_object()) + return await render_template( + "settings/reports.html", + settings=settings, + flash={"ok": ok, "message": message}, + ) + + +# ── Plugin repository helpers ───────────────────────────────────────────────── + +def _get_plugin_repos(settings: dict) -> list[dict]: + """Return the list of plugin repositories from settings. + + Migrates the legacy plugins.index_url single-URL field to the list format + on first access if plugins.repositories is not yet set. + """ + repos = settings.get("plugins.repositories") + if repos and isinstance(repos, list): + return repos + # Migrate legacy single URL + legacy = settings.get("plugins.index_url", "").strip() + if legacy: + return [{"name": "Default", "url": legacy}] + return [] + + +async def _save_plugin_repos(db, repos: list[dict]) -> None: + await set_setting(db, "plugins.repositories", repos) + + +async def _plugin_repos_partial(settings: dict): + repos = _get_plugin_repos(settings) + return await render_template("settings/_plugin_repos.html", repos=repos) + + +def _build_plugin_cfg_from_form(plugin: dict, form) -> dict: + """Extract config values for one plugin from a form submission.""" + 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, list): + pass # list configs must be set in plugin.yaml + elif 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 + return plugin_cfg + + +# ── Plugins list ────────────────────────────────────────────────────────────── + +@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)) + repos = _get_plugin_repos(settings) + return await render_template( + "settings/plugins.html", + discovered_plugins=discovered, + repos=repos, + settings=settings, + ) + + +# ── Per-plugin detail (settings) ────────────────────────────────────────────── + +@settings_bp.get("/plugins/<name>/") +@require_role(UserRole.admin) +async def plugin_detail(name: str): + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + discovered = _discover_plugins() + plugin = next((p for p in discovered if p["_dir"] == name), None) + if plugin is None: + return redirect(url_for("settings.plugins")) + _merge_plugin_config([plugin], to_plugins_cfg(settings)) + from roundtable.core.plugin_manager import _LOADED_PLUGINS, get_plugin_failures + return await render_template( + "settings/plugin_detail.html", + plugin=plugin, + is_loaded=name in _LOADED_PLUGINS, + fail_reason=get_plugin_failures().get(name), + ) + + +@settings_bp.post("/plugins/<name>/") +@require_role(UserRole.admin) +async def save_plugin_detail(name: str): + form = await request.form + discovered = _discover_plugins() + plugin = next((p for p in discovered if p["_dir"] == name), None) + if plugin is None: + return redirect(url_for("settings.plugins")) + + async with current_app.db_sessionmaker() as db: + old_settings = await get_all_settings(db) + old_enabled = to_plugins_cfg(old_settings).get(name, {}).get("enabled", False) + + plugin_cfg = _build_plugin_cfg_from_form(plugin, form) + + async with current_app.db_sessionmaker() as db: + async with db.begin(): + await set_setting(db, f"plugin.{name}", plugin_cfg) + + await _reload_app_config() + from roundtable.core.plugin_index import clear_catalog_cache + clear_catalog_cache() + + new_enabled = plugin_cfg.get("enabled", False) + if old_enabled != new_enabled: + action = "plugin.enabled" if new_enabled else "plugin.disabled" + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + action, entity_type="plugin", entity_id=name) + if new_enabled and not old_enabled: + from roundtable.core.plugin_manager import hot_reload_plugin + hot_reload_plugin(current_app._get_current_object(), name) + + return redirect(url_for("settings.plugin_detail", name=name)) + + +# ── Plugin repository HTMX routes ───────────────────────────────────────────── + +@settings_bp.post("/plugins/repos/add") +@require_role(UserRole.admin) +async def plugin_repos_add(): + form = await request.form + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + repos = _get_plugin_repos(settings) + url = form.get("url", "").strip() + repo_name = form.get("name", "").strip() or url + if url: + repos.append({"name": repo_name, "url": url}) + async with current_app.db_sessionmaker() as db: + async with db.begin(): + await _save_plugin_repos(db, repos) + from roundtable.core.plugin_index import clear_catalog_cache + clear_catalog_cache() + return await _plugin_repos_partial({"plugins.repositories": repos}) + + +@settings_bp.post("/plugins/repos/<int:idx>/remove") +@require_role(UserRole.admin) +async def plugin_repos_remove(idx: int): + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + repos = _get_plugin_repos(settings) + if 0 <= idx < len(repos): + repos.pop(idx) + async with current_app.db_sessionmaker() as db: + async with db.begin(): + await _save_plugin_repos(db, repos) + from roundtable.core.plugin_index import clear_catalog_cache + clear_catalog_cache() + return await _plugin_repos_partial({"plugins.repositories": repos}) + + +@settings_bp.post("/plugins/repos/<int:idx>/save") +@require_role(UserRole.admin) +async def plugin_repos_save(idx: int): + form = await request.form + async with current_app.db_sessionmaker() as db: + settings = await get_all_settings(db) + repos = _get_plugin_repos(settings) + if 0 <= idx < len(repos): + repos[idx] = { + "name": form.get("name", "").strip() or repos[idx]["name"], + "url": form.get("url", "").strip() or repos[idx]["url"], + } + async with current_app.db_sessionmaker() as db: + async with db.begin(): + await _save_plugin_repos(db, repos) + from roundtable.core.plugin_index import clear_catalog_cache + clear_catalog_cache() + return await _plugin_repos_partial({"plugins.repositories": repos}) + + +# ── 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) + repos = _get_plugin_repos(settings) + repo_urls = [r["url"] for r in repos if r.get("url")] + + if not repo_urls: + return await render_template( + "settings/_catalog_partial.html", + catalog=[], + installed_names=set(), + loaded_names=set(), + error="No plugin repositories configured.", + ) + + from roundtable.core.plugin_index import fetch_catalog + from roundtable.core.plugin_manager import _LOADED_PLUGINS + + force = request.args.get("refresh") == "1" + try: + catalog = await fetch_catalog(repo_urls, 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} + installed_versions = {p["_dir"]: p.get("version", "0.0.0") for p in discovered} + + return await render_template( + "settings/_catalog_partial.html", + catalog=catalog, + installed_names=installed_names, + installed_versions=installed_versions, + 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 roundtable.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 roundtable.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 roundtable.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") diff --git a/fabledscryer/templates/_time_range.html b/roundtable/templates/_time_range.html similarity index 100% rename from fabledscryer/templates/_time_range.html rename to roundtable/templates/_time_range.html diff --git a/roundtable/templates/alerts/_rule_fields.html b/roundtable/templates/alerts/_rule_fields.html new file mode 100644 index 0000000..b45ba49 --- /dev/null +++ b/roundtable/templates/alerts/_rule_fields.html @@ -0,0 +1,29 @@ +{# alerts/_rule_fields.html — HTMX partial: resource + metric selects for a given source_module #} +<div class="form-group"> + <label>Resource Name</label> + {% if resources %} + <select name="resource_name" required> + {% if not current_resource %}<option value="" disabled selected>— pick a resource —</option>{% endif %} + {% for r in resources %} + <option value="{{ r }}" {% if r == current_resource %}selected{% endif %}>{{ r }}</option> + {% endfor %} + </select> + {% else %} + <select name="resource_name" required disabled> + <option value="">No data yet — run monitors first</option> + </select> + <p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;"> + Resource names populate from live metric data. Start the scheduler and try again. + </p> + {% endif %} +</div> + +<div class="form-group"> + <label>Metric Name</label> + <select name="metric_name" required> + {% if not current_metric %}<option value="" disabled selected>— pick a metric —</option>{% endif %} + {% for m in metrics %} + <option value="{{ m }}" {% if m == current_metric %}selected{% endif %}>{{ m }}</option> + {% endfor %} + </select> +</div> diff --git a/fabledscryer/templates/alerts/list.html b/roundtable/templates/alerts/list.html similarity index 64% rename from fabledscryer/templates/alerts/list.html rename to roundtable/templates/alerts/list.html index be058bc..df2e643 100644 --- a/fabledscryer/templates/alerts/list.html +++ b/roundtable/templates/alerts/list.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Alerts — Fabled Scryer{% endblock %} +{% block title %}Alerts — Roundtable{% endblock %} {% block content %} <h1 class="page-title">Alerts</h1> @@ -49,7 +49,10 @@ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;"> <h2 class="section-title">Rules</h2> - <a class="btn" href="/alerts/rules/new">New Rule</a> + <div style="display:flex;gap:0.5rem;"> + <a class="btn btn-ghost" href="/alerts/maintenance">Maintenance</a> + <a class="btn" href="/alerts/rules/new">New Rule</a> + </div> </div> {% if rules %} <div class="card-flush"> @@ -58,24 +61,33 @@ <tr> <th>Name</th> <th>Condition</th> - <th>Consec.</th> - <th>Enabled</th> + <th style="text-align:center;">Consec.</th> <th></th> </tr> </thead> <tbody> {% for rule in rules %} - <tr> - <td>{{ rule.name }}</td> - <td style="color:var(--text-muted);"> - {{ rule.source_module }}/{{ rule.resource_name }}:{{ rule.metric_name }} - {{ rule.operator.value }} {{ rule.threshold }} - </td> - <td style="color:var(--text-muted);">{{ rule.consecutive_failures_required }}</td> + <tr {% if not rule.enabled %}style="opacity:0.55;"{% endif %}> <td> - {% if rule.enabled %}<span class="badge badge-green">yes</span>{% else %}<span class="badge badge-dim">no</span>{% endif %} + {{ rule.name }} + {% if not rule.enabled %} + <span style="font-size:0.72rem;color:var(--text-dim);margin-left:0.35rem;">disabled</span> + {% endif %} </td> + <td style="color:var(--text-muted);font-size:0.85rem;"> + <span style="color:var(--text-dim);">{{ rule.source_module }}/</span>{{ rule.resource_name }} + <span style="font-family:ui-monospace,monospace;font-size:0.8rem;"> + :{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }} + </span> + </td> + <td style="color:var(--text-muted);text-align:center;">{{ rule.consecutive_failures_required }}</td> <td class="td-actions"> + <a href="/alerts/rules/{{ rule.id }}/edit" class="btn btn-sm btn-ghost">Edit</a> + <form method="post" action="/alerts/rules/{{ rule.id }}/toggle" style="display:inline;"> + <button type="submit" class="btn btn-sm btn-ghost"> + {% if rule.enabled %}Disable{% else %}Enable{% endif %} + </button> + </form> <form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;"> <button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Delete rule {{ rule.name }}?')">Delete</button> diff --git a/roundtable/templates/alerts/maintenance.html b/roundtable/templates/alerts/maintenance.html new file mode 100644 index 0000000..5fd62bc --- /dev/null +++ b/roundtable/templates/alerts/maintenance.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}Maintenance Windows — Roundtable{% endblock %} +{% block content %} +<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;"> + <h1 class="page-title" style="margin-bottom:0;">Maintenance Windows</h1> + <div style="display:flex;gap:0.5rem;"> + <a class="btn btn-ghost" href="/alerts/">← Alerts</a> + <a class="btn" href="/alerts/maintenance/new">New Window</a> + </div> +</div> + +<p style="color:var(--text-muted);font-size:0.88rem;margin-bottom:1.5rem;"> + During an active window, alert rule evaluation is suppressed for the matching scope. + Metrics are still recorded — only notifications are silenced. +</p> + +{% if windows %} +<div class="card-flush"> + <table class="table"> + <thead> + <tr> + <th>Name</th> + <th>Scope</th> + <th>Start</th> + <th>End</th> + <th style="text-align:center;">Status</th> + <th></th> + </tr> + </thead> + <tbody> + {% for w in windows %} + {% set is_active = w.start_at <= now and w.end_at >= now %} + {% set is_upcoming = w.start_at > now %} + <tr> + <td style="font-weight:500;">{{ w.name }}</td> + <td style="font-size:0.85rem;color:var(--text-muted);"> + {% if w.scope_source is none %} + <span title="All alert rules">All alerts</span> + {% elif w.scope_resource is none %} + <span>{{ w.scope_source }}/*</span> + {% else %} + <span>{{ w.scope_source }}/{{ w.scope_resource }}</span> + {% endif %} + </td> + <td style="font-size:0.85rem;font-family:ui-monospace,monospace;"> + {{ w.start_at.strftime("%Y-%m-%d %H:%M") }} UTC + </td> + <td style="font-size:0.85rem;font-family:ui-monospace,monospace;"> + {{ w.end_at.strftime("%Y-%m-%d %H:%M") }} UTC + </td> + <td style="text-align:center;"> + {% if is_active %} + <span class="badge badge-yellow">active</span> + {% elif is_upcoming %} + <span class="badge badge-dim">upcoming</span> + {% else %} + <span style="color:var(--text-dim);font-size:0.8rem;">expired</span> + {% endif %} + </td> + <td class="td-actions"> + <form method="post" action="/alerts/maintenance/{{ w.id }}/delete" style="display:inline;"> + <button type="submit" class="btn btn-sm btn-danger" + onclick="return confirm('Delete window "{{ w.name }}"?')">Delete</button> + </form> + </td> + </tr> + {% endfor %} + </tbody> + </table> +</div> +{% else %} +<div class="card" style="text-align:center;padding:3rem;"> + <p style="color:var(--text-muted);">No maintenance windows configured.</p> +</div> +{% endif %} +{% endblock %} diff --git a/roundtable/templates/alerts/maintenance_form.html b/roundtable/templates/alerts/maintenance_form.html new file mode 100644 index 0000000..a7224f4 --- /dev/null +++ b/roundtable/templates/alerts/maintenance_form.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% block title %}New Maintenance Window — Roundtable{% endblock %} +{% block content %} +<div style="max-width:560px;margin:2rem auto;"> + <h1 class="page-title">New Maintenance Window</h1> + <div class="card"> + <form method="post" action="/alerts/maintenance"> + + <div class="form-group"> + <label>Window Name</label> + <input type="text" name="name" required autofocus + placeholder="e.g. Weekly reboot — homelab"> + </div> + + <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;"> + <div class="form-group"> + <label>Start (UTC)</label> + <input type="datetime-local" name="start_at" required> + </div> + <div class="form-group"> + <label>End (UTC)</label> + <input type="datetime-local" name="end_at" required> + </div> + </div> + + {# ── Scope ─────────────────────────────────────────────────────────────── #} + <div class="form-group"> + <label>Scope</label> + <select name="_scope_type" id="scope-type" onchange="updateScope()"> + <option value="all">All alerts</option> + <option value="source">By source module</option> + <option value="resource">By source + resource</option> + </select> + </div> + + <div id="scope-source-row" style="display:none;" class="form-group"> + <label>Source Module</label> + <select name="scope_source" id="scope-source"> + <option value="">— select —</option> + {% for src in source_modules %} + <option value="{{ src }}">{{ src }}</option> + {% endfor %} + </select> + </div> + + <div id="scope-resource-row" style="display:none;" class="form-group"> + <label>Resource Name</label> + <input type="text" name="scope_resource" id="scope-resource" + placeholder="Exact resource name (e.g. my-server)"> + <p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;"> + Leave blank to suppress all resources within the selected source. + </p> + </div> + + <div style="display:flex;gap:1rem;margin-top:1.5rem;"> + <button type="submit" class="btn">Create Window</button> + <a href="/alerts/maintenance" class="btn btn-ghost">Cancel</a> + </div> + </form> + </div> + + <div class="card" style="margin-top:1rem;"> + <div class="section-title" style="margin-bottom:0.5rem;">How scope works</div> + <div style="display:grid;gap:0.4rem;font-size:0.82rem;color:var(--text-muted);"> + <div><strong style="color:var(--text);">All alerts</strong> — every alert rule is suppressed</div> + <div><strong style="color:var(--text);">By source</strong> — e.g. source=ping suppresses all ping alerts</div> + <div><strong style="color:var(--text);">By source + resource</strong> — e.g. ping/my-server suppresses only that host's ping alerts</div> + </div> + </div> +</div> + +<script> +function updateScope() { + var t = document.getElementById('scope-type').value; + document.getElementById('scope-source-row').style.display = t === 'all' ? 'none' : ''; + document.getElementById('scope-resource-row').style.display = t === 'resource' ? '' : 'none'; + if (t === 'all') { + document.getElementById('scope-source').value = ''; + document.getElementById('scope-resource').value = ''; + } + if (t === 'source') { + document.getElementById('scope-resource').value = ''; + } +} +</script> +{% endblock %} diff --git a/roundtable/templates/alerts/rules_form.html b/roundtable/templates/alerts/rules_form.html new file mode 100644 index 0000000..f1a148f --- /dev/null +++ b/roundtable/templates/alerts/rules_form.html @@ -0,0 +1,138 @@ +{% extends "base.html" %} +{% block title %}{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %} — Roundtable{% endblock %} +{% block content %} +<div style="max-width:600px;margin:2rem auto;"> + <h1 class="page-title">{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}</h1> + <div class="card"> + <form method="post" action="{% if rule %}/alerts/rules/{{ rule.id }}{% else %}/alerts/rules{% endif %}"> + + <div class="form-group"> + <label>Rule Name</label> + <input type="text" name="name" required autofocus + value="{{ rule.name if rule else '' }}" + placeholder="e.g. Home server down"> + </div> + + {# ── Source module ───────────────────────────────────────────────────── #} + <div class="form-group"> + <label>Source</label> + <select name="source_module" required + hx-get="/alerts/api/rule_fields" + hx-trigger="change" + hx-target="#rule-fields" + hx-include="[name='source_module']"> + {% for src in source_modules %} + <option value="{{ src }}" {% if src == initial_source %}selected{% endif %}> + {{ src }} + </option> + {% endfor %} + </select> + </div> + + {# ── Dynamic resource + metric ────────────────────────────────────────── #} + <div id="rule-fields"> + {# Inline the same partial logic so the page is fully usable without JS #} + <div class="form-group"> + <label>Resource Name</label> + {% if resources %} + <select name="resource_name" required> + {% if not (rule and rule.resource_name) %}<option value="" disabled selected>— pick a resource —</option>{% endif %} + {% for r in resources %} + <option value="{{ r }}" {% if rule and r == rule.resource_name %}selected{% endif %}>{{ r }}</option> + {% endfor %} + </select> + {% else %} + <select name="resource_name" required disabled> + <option value="">No data yet — run monitors first</option> + </select> + <p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;"> + Resource names populate from live metric data. Start the scheduler and try again. + </p> + {% endif %} + </div> + + <div class="form-group"> + <label>Metric Name</label> + <select name="metric_name" required> + {% if not (rule and rule.metric_name) %}<option value="" disabled selected>— pick a metric —</option>{% endif %} + {% for m in metrics %} + <option value="{{ m }}" {% if rule and m == rule.metric_name %}selected{% endif %}>{{ m }}</option> + {% endfor %} + </select> + </div> + </div> + + {# ── Condition ───────────────────────────────────────────────────────── #} + <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 }}" {% if rule and rule.operator.value == val %}selected{% endif %}> + {{ label }} + </option> + {% endfor %} + </select> + </div> + <div class="form-group"> + <label>Threshold</label> + <input type="number" name="threshold" step="any" required + value="{{ rule.threshold if rule else '' }}" + placeholder="0.5"> + </div> + </div> + + <div class="form-group"> + <label>Consecutive failures before FIRING</label> + <input type="number" name="consecutive_failures_required" min="1" + value="{{ rule.consecutive_failures_required if rule else 1 }}"> + </div> + + {# ── Metric reference ────────────────────────────────────────────────── #} + <details style="margin-bottom:1rem;"> + <summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);user-select:none;"> + Metric reference + </summary> + <div style="margin-top:0.75rem;display:grid;gap:0.5rem;"> + {% set descs = { + "ping": [("up", "1.0 = reachable, 0.0 = unreachable"), + ("response_time_ms", "round-trip latency in milliseconds")], + "dns": [("resolved", "1.0 = resolved, 0.0 = failed"), + ("ip_changed", "1.0 when resolved IP differs from expected")], + "traefik": [("request_rate", "requests/sec for this router"), + ("error_rate", "fraction of 5xx responses (0–1)"), + ("latency_p50_ms", "median response time ms"), + ("latency_p95_ms", "p95 response time ms"), + ("latency_p99_ms", "p99 response time ms"), + ("response_bytes_rate", "bytes/sec of responses"), + ("cert_expiry_days", "days until TLS cert expires")], + "unifi": [("is_up", "1.0 = WAN up, 0.0 = down"), + ("latency_ms", "WAN latency in ms"), + ("total_clients", "number of connected clients")], + "docker": [("cpu_pct", "container CPU usage %"), + ("mem_pct", "container memory usage %")], + "http": [("is_up", "1.0 = check passed, 0.0 = failed"), + ("response_ms", "HTTP response time in ms")], + } %} + {% for src, pairs in descs.items() %} + <div> + <div style="font-size:0.78rem;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.2rem;">{{ src }}</div> + {% for metric, desc in pairs %} + <div style="display:grid;grid-template-columns:160px 1fr;gap:0.5rem;font-size:0.78rem;padding:0.15rem 0;"> + <code>{{ metric }}</code> + <span style="color:var(--text-muted);">{{ desc }}</span> + </div> + {% endfor %} + </div> + {% endfor %} + </div> + </details> + + <div style="display:flex;gap:1rem;margin-top:1.5rem;"> + <button type="submit" class="btn">{% if rule %}Save Changes{% else %}Create Rule{% endif %}</button> + <a href="/alerts/" class="btn btn-ghost">Cancel</a> + </div> + </form> + </div> +</div> +{% endblock %} diff --git a/fabledscryer/templates/ansible/browse.html b/roundtable/templates/ansible/browse.html similarity index 84% rename from fabledscryer/templates/ansible/browse.html rename to roundtable/templates/ansible/browse.html index 6479252..bf9ed05 100644 --- a/fabledscryer/templates/ansible/browse.html +++ b/roundtable/templates/ansible/browse.html @@ -1,25 +1,25 @@ {% extends "base.html" %} -{% block title %}Browse Playbooks — Fabled Scryer{% endblock %} +{% block title %}Browse Playbooks — Roundtable{% endblock %} {% block content %} <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;"> <h1 class="page-title" style="margin-bottom:0;">Browse Playbooks</h1> - <a href="/ansible/" style="color:#a0a0ff;font-size:0.9rem;">← Run History</a> + <a href="/ansible/" class="btn btn-ghost btn-sm">← Run History</a> </div> {% if view_contents is defined %} <div class="card"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;"> <h3 class="section-title">{{ view_path }}</h3> - <a href="/ansible/browse" style="color:#a0a0ff;font-size:0.85rem;">← Back to browse</a> + <a href="/ansible/browse" class="btn btn-ghost btn-sm">← Back to browse</a> </div> - <pre style="background:#0a0a1a;padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:#c0c0c0;max-height:600px;overflow-y:auto;">{{ view_contents }}</pre> + <pre style="background:var(--bg);padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:var(--text-muted);max-height:600px;overflow-y:auto;">{{ view_contents }}</pre> </div> {% endif %} {% if not source_data %} {% if view_contents is not defined %} <div class="card"> - <p style="color:#606080;">No Ansible sources configured. Add sources under <code>ansible.sources</code> in <code>config.yaml</code>.</p> + <p style="color:var(--text-muted);">No Ansible sources configured. <a href="/settings/ansible/">Add sources in Settings → Ansible</a>.</p> </div> {% endif %} {% else %} @@ -27,8 +27,8 @@ <div class="card" style="margin-bottom:1.5rem;"> <div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;"> <h3 class="section-title">{{ sd.source.name }}</h3> - <span style="color:#404060;font-size:0.8rem;background:#12122a;padding:0.2rem 0.5rem;border-radius:3px;">{{ sd.source.type }}</span> - <span style="color:#505070;font-size:0.8rem;">{{ sd.source.path }}</span> + <span style="color:var(--text-muted);font-size:0.8rem;background:var(--bg-elevated);padding:0.2rem 0.5rem;border-radius:3px;">{{ sd.source.type }}</span> + <span style="color:var(--text-dim);font-size:0.8rem;">{{ sd.source.path }}</span> </div> {% if not sd.playbooks %} diff --git a/fabledscryer/templates/ansible/index.html b/roundtable/templates/ansible/index.html similarity index 92% rename from fabledscryer/templates/ansible/index.html rename to roundtable/templates/ansible/index.html index 0768636..a924da6 100644 --- a/fabledscryer/templates/ansible/index.html +++ b/roundtable/templates/ansible/index.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Ansible Runs — Fabled Scryer{% endblock %} +{% block title %}Ansible Runs — Roundtable{% endblock %} {% block content %} <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;"> <h1 class="page-title" style="margin-bottom:0;">Ansible Runs</h1> @@ -7,7 +7,7 @@ </div> {% if not runs %} <div class="card"> - <p style="color:var(--text-muted);">No runs yet. <a href="/ansible/browse">Browse playbooks</a> to trigger a run.</p> + <p style="color:var(--text-muted);">No runs yet. <a href="/ansible/browse">Browse playbooks</a> to begin.</p> </div> {% else %} <div class="card-flush"> diff --git a/roundtable/templates/ansible/run_detail.html b/roundtable/templates/ansible/run_detail.html new file mode 100644 index 0000000..57e1175 --- /dev/null +++ b/roundtable/templates/ansible/run_detail.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}Run {{ run.id[:8] }} — Roundtable{% endblock %} +{% block content %} +<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;"> + <a href="/ansible/" class="btn btn-ghost btn-sm">← Runs</a> + <h1 class="page-title" style="margin-bottom:0;">Run Detail</h1> +</div> + +{% set status_color = {"running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %} +<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:var(--text-muted);">ID</span><span style="color:var(--text-dim);font-family:monospace;">{{ run.id }}</span> + <span style="color:var(--text-muted);">Playbook</span><span>{{ run.playbook_path }}</span> + <span style="color:var(--text-muted);">Inventory</span><span>{{ run.inventory_path }}</span> + <span style="color:var(--text-muted);">Source</span><span>{{ run.source_name }}</span> + <span style="color:var(--text-muted);">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span> + <span style="color:var(--text-muted);">Started</span><span style="color:var(--text-dim);">{{ run.started_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span> + {% if run.finished_at %} + <span style="color:var(--text-muted);">Finished</span><span style="color:var(--text-dim);">{{ 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:var(--bg-elevated);border-bottom:1px solid var(--border);"> + <span style="color:var(--text-muted);font-size:0.85rem;">Output</span> + </div> + {% if run.status.value == "running" %} + <pre id="live-output" + style="margin:0;padding:1rem;background:var(--bg);font-size:0.78rem;color:var(--green);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:var(--text-muted);"> + 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:var(--bg);font-size:0.78rem;color:var(--text-muted);max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "(no output)" }}</pre> + {% endif %} +</div> +{% endblock %} diff --git a/fabledscryer/templates/ansible/run_started.html b/roundtable/templates/ansible/run_started.html similarity index 100% rename from fabledscryer/templates/ansible/run_started.html rename to roundtable/templates/ansible/run_started.html diff --git a/roundtable/templates/audit/list.html b/roundtable/templates/audit/list.html new file mode 100644 index 0000000..0051f76 --- /dev/null +++ b/roundtable/templates/audit/list.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Audit Log — Roundtable{% endblock %} +{% block content %} +<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;"> + <h1 class="page-title" style="margin-bottom:0;">Audit Log</h1> + <form method="get" style="display:flex;gap:0.5rem;align-items:center;"> + <input type="text" name="action" value="{{ action_filter }}" + placeholder="Filter by action…" + style="width:200px;font-size:0.85rem;"> + <button type="submit" class="btn btn-ghost btn-sm">Filter</button> + {% if action_filter %} + <a href="/audit/" class="btn btn-ghost btn-sm">Clear</a> + {% endif %} + </form> +</div> + +{% if events %} +<div class="card-flush"> + <table class="table"> + <thead> + <tr> + <th style="min-width:160px;">Time (UTC)</th> + <th>User</th> + <th>Action</th> + <th>Entity</th> + <th>Detail</th> + </tr> + </thead> + <tbody> + {% for item in events %} + {% set e = item.event %} + {% set d = item.detail %} + <tr> + <td style="font-family:ui-monospace,monospace;font-size:0.8rem;white-space:nowrap;color:var(--text-muted);"> + {{ e.timestamp.strftime("%Y-%m-%d %H:%M:%S") }} + </td> + <td style="font-size:0.85rem;">{{ e.username }}</td> + <td> + {% set cat = e.action.split('.')[0] %} + <span style=" + font-size:0.78rem;font-family:ui-monospace,monospace; + padding:0.15rem 0.4rem;border-radius:3px; + background:{% if cat == 'auth' %}var(--bg-elevated){% elif cat == 'plugin' %}var(--gold-dim){% elif cat == 'alert' %}var(--red-dim){% elif cat == 'host' %}var(--green-dim){% elif cat == 'settings' %}var(--bg-elevated){% else %}var(--bg-elevated){% endif %}; + color:{% if cat == 'auth' %}var(--accent){% elif cat == 'plugin' %}var(--gold){% elif cat == 'alert' %}var(--red){% elif cat == 'host' %}var(--green){% elif cat == 'settings' %}var(--text-muted){% else %}var(--text-muted){% endif %};"> + {{ e.action }} + </span> + </td> + <td style="font-size:0.82rem;color:var(--text-muted);"> + {% if e.entity_type %} + <span style="color:var(--text-dim);">{{ e.entity_type }}/</span>{{ e.entity_id or "" }} + {% endif %} + </td> + <td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;"> + {% if d %} + {% for k, v in d.items() %} + <span style="color:var(--text-dim);">{{ k }}:</span> + <span style="font-family:ui-monospace,monospace;">{{ v }}</span> + {% if not loop.last %}  ·  {% endif %} + {% endfor %} + {% endif %} + </td> + </tr> + {% endfor %} + </tbody> + </table> +</div> +<p style="font-size:0.78rem;color:var(--text-dim);margin-top:0.75rem;"> + Showing last {{ limit }} events. + {% if events | length == limit %} + <a href="?limit={{ limit * 2 }}{% if action_filter %}&action={{ action_filter }}{% endif %}">Load more</a> + {% endif %} +</p> +{% else %} +<div class="card" style="text-align:center;padding:3rem;"> + <p style="color:var(--text-muted);">No audit events recorded yet.</p> +</div> +{% endif %} +{% endblock %} diff --git a/roundtable/templates/auth/login.html b/roundtable/templates/auth/login.html new file mode 100644 index 0000000..e481b76 --- /dev/null +++ b/roundtable/templates/auth/login.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}Login — Roundtable{% endblock %} +{% block content %} +<div style="max-width:400px;margin:4rem auto;"> + <div class="card"> + <p style="color: var(--text-dim); font-family: var(--font-serif); font-style: italic; font-size: 1.15rem; text-align: center; margin: 0 0 1.5rem;">Under Watch, Under Care.</p> + {% if error %} + <div style="color:var(--red);font-size:0.88rem;margin-bottom:1rem; + background:var(--red-dim);padding:0.6rem 0.75rem;border-radius:4px;"> + {{ error }} + </div> + {% endif %} + {% if oidc_enabled %} + <a href="/login/oidc" class="btn" style="width:100%;text-align:center;margin-bottom:1.25rem;display:block;"> + Sign in with SSO + </a> + <div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.25rem;"> + <div style="flex:1;height:1px;background:var(--border-mid);"></div> + <span style="font-size:0.78rem;color:var(--text-dim);">or</span> + <div style="flex:1;height:1px;background:var(--border-mid);"></div> + </div> + {% endif %} + <form method="post" action="/login"> + <div class="form-group"> + <input type="text" name="username" placeholder="Username" autofocus required> + </div> + <div class="form-group"> + <input type="password" name="password" placeholder="Password" required> + </div> + <button type="submit" class="btn {% if oidc_enabled %}btn-ghost{% endif %}" style="width:100%;"> + Sign In + </button> + </form> + </div> +</div> +{% endblock %} diff --git a/fabledscryer/templates/auth/setup.html b/roundtable/templates/auth/setup.html similarity index 93% rename from fabledscryer/templates/auth/setup.html rename to roundtable/templates/auth/setup.html index d428040..6172efe 100644 --- a/fabledscryer/templates/auth/setup.html +++ b/roundtable/templates/auth/setup.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}First-Run Setup — Fabled Scryer{% endblock %} +{% block title %}First-Run Setup — Roundtable{% endblock %} {% block content %} <div style="max-width:400px;margin:4rem auto;"> <div class="card"> diff --git a/fabledscryer/templates/base.html b/roundtable/templates/base.html similarity index 57% rename from fabledscryer/templates/base.html rename to roundtable/templates/base.html index 585852f..ec8d295 100644 --- a/fabledscryer/templates/base.html +++ b/roundtable/templates/base.html @@ -3,34 +3,37 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% block title %}Fabled Scryer{% endblock %} + {% block title %}Roundtable{% endblock %} + - + +{% endblock %} diff --git a/fabledscryer/templates/dashboard/index.html b/roundtable/templates/dashboard/index.html similarity index 96% rename from fabledscryer/templates/dashboard/index.html rename to roundtable/templates/dashboard/index.html index 7247466..1abd060 100644 --- a/fabledscryer/templates/dashboard/index.html +++ b/roundtable/templates/dashboard/index.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}{{ dashboard.name }} — Fabled Scryer{% endblock %} +{% block title %}{{ dashboard.name }} — Roundtable{% endblock %} {% block content %} {# ── Header ────────────────────────────────────────────────────────────────── #} @@ -66,11 +66,11 @@ {% for item in widgets %}
- {{ item.defn.label }} + {{ item.title }} Details →
diff --git a/fabledscryer/templates/dashboard/list.html b/roundtable/templates/dashboard/list.html similarity index 99% rename from fabledscryer/templates/dashboard/list.html rename to roundtable/templates/dashboard/list.html index 63390e8..8fe4ea2 100644 --- a/fabledscryer/templates/dashboard/list.html +++ b/roundtable/templates/dashboard/list.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Dashboards — Fabled Scryer{% endblock %} +{% block title %}Dashboards — Roundtable{% endblock %} {% block content %}

Dashboards

diff --git a/fabledscryer/templates/dashboard/share.html b/roundtable/templates/dashboard/share.html similarity index 97% rename from fabledscryer/templates/dashboard/share.html rename to roundtable/templates/dashboard/share.html index c49c995..234936a 100644 --- a/fabledscryer/templates/dashboard/share.html +++ b/roundtable/templates/dashboard/share.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Share: {{ dashboard.name }} — Fabled Scryer{% endblock %} +{% block title %}Share: {{ dashboard.name }} — Roundtable{% endblock %} {% block content %}

Share: {{ dashboard.name }}

diff --git a/fabledscryer/templates/dashboard/share_created.html b/roundtable/templates/dashboard/share_created.html similarity index 95% rename from fabledscryer/templates/dashboard/share_created.html rename to roundtable/templates/dashboard/share_created.html index 098b3c1..aaf59b2 100644 --- a/fabledscryer/templates/dashboard/share_created.html +++ b/roundtable/templates/dashboard/share_created.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Share Link Created — Fabled Scryer{% endblock %} +{% block title %}Share Link Created — Roundtable{% endblock %} {% block content %}

Share Link Created

diff --git a/fabledscryer/templates/dashboard/share_view.html b/roundtable/templates/dashboard/share_view.html similarity index 93% rename from fabledscryer/templates/dashboard/share_view.html rename to roundtable/templates/dashboard/share_view.html index 9bc3cb9..b8ee771 100644 --- a/fabledscryer/templates/dashboard/share_view.html +++ b/roundtable/templates/dashboard/share_view.html @@ -3,8 +3,9 @@ - {{ dashboard.name }} — Fabled Scryer + {{ dashboard.name }} — Roundtable + @@ -66,7 +67,7 @@ {{ dashboard.name }} - Fabled Scryer · read-only view + Roundtable · read-only view
@@ -75,11 +76,11 @@ {% for item in widgets %}
- {{ item.defn.label }} + {{ item.title }}
- {# Pass share token as ?s= param so require_role allows through #} + {# Pass share token as &s= param so require_role allows through #}
diff --git a/fabledscryer/templates/dns/index.html b/roundtable/templates/dns/index.html similarity index 89% rename from fabledscryer/templates/dns/index.html rename to roundtable/templates/dns/index.html index bf9bf21..423204d 100644 --- a/fabledscryer/templates/dns/index.html +++ b/roundtable/templates/dns/index.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}DNS Monitor — Fabled Scryer{% endblock %} +{% block title %}DNS Monitor — Roundtable{% endblock %} {% block content %}

DNS Monitor

diff --git a/fabledscryer/templates/dns/rows.html b/roundtable/templates/dns/rows.html similarity index 89% rename from fabledscryer/templates/dns/rows.html rename to roundtable/templates/dns/rows.html index 66d3259..1aa4a7c 100644 --- a/fabledscryer/templates/dns/rows.html +++ b/roundtable/templates/dns/rows.html @@ -25,5 +25,5 @@
{% endfor %} {% else %} -

No DNS-enabled hosts. Enable DNS on a host →

+

No DNS checks yet. Enable DNS on a host →

{% endif %} diff --git a/roundtable/templates/errors/404.html b/roundtable/templates/errors/404.html new file mode 100644 index 0000000..cc15b0f --- /dev/null +++ b/roundtable/templates/errors/404.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}Not Found — Roundtable{% endblock %} +{% block content %} +
+

404

+

No such seat at this table.

+

The page you sought is not among us.

+ Return to the hall +
+{% endblock %} diff --git a/roundtable/templates/errors/500.html b/roundtable/templates/errors/500.html new file mode 100644 index 0000000..81f4868 --- /dev/null +++ b/roundtable/templates/errors/500.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}Error — Roundtable{% endblock %} +{% block content %} +
+

500

+

The watch has faltered.

+

An unexpected error occurred. The cause has been logged.

+ Return to the hall +
+{% endblock %} diff --git a/fabledscryer/templates/hosts/form.html b/roundtable/templates/hosts/form.html similarity index 98% rename from fabledscryer/templates/hosts/form.html rename to roundtable/templates/hosts/form.html index 5296d8c..c667bf4 100644 --- a/fabledscryer/templates/hosts/form.html +++ b/roundtable/templates/hosts/form.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}{% if host %}Edit Host{% else %}New Host{% endif %} — Fabled Scryer{% endblock %} +{% block title %}{% if host %}Edit Host{% else %}New Host{% endif %} — Roundtable{% endblock %} {% block content %}

{% if host %}Edit Host{% else %}Add Host{% endif %}

diff --git a/fabledscryer/templates/hosts/list.html b/roundtable/templates/hosts/list.html similarity index 69% rename from fabledscryer/templates/hosts/list.html rename to roundtable/templates/hosts/list.html index 9bf73a6..b619ebd 100644 --- a/fabledscryer/templates/hosts/list.html +++ b/roundtable/templates/hosts/list.html @@ -1,9 +1,12 @@ {% extends "base.html" %} -{% block title %}Hosts — Fabled Scryer{% endblock %} +{% block title %}Hosts — Roundtable{% endblock %} {% block content %}

Hosts

- Add Host +
+ SLA + Add Host +
{% if hosts %}
@@ -16,6 +19,9 @@ Ping Latency DNS + 24h + 7d + 30d @@ -23,6 +29,7 @@ {% for host in hosts %} {% set ping = latest_pings.get(host.id) %} {% set dns = latest_dns.get(host.id) %} + {% set ut = uptime.get(host.id, {}) %} {{ host.name }} {{ host.address }} @@ -64,6 +71,24 @@ {% endif %} + {% for window in ["24h", "7d", "30d"] %} + {% set pct = ut.get(window) %} + + {% if not host.ping_enabled %} + + {% elif pct is none %} + + {% elif pct >= 99.9 %} + {{ "%.1f"|format(pct) }}% + {% elif pct >= 99 %} + {{ "%.1f"|format(pct) }}% + {% elif pct >= 95 %} + {{ "%.1f"|format(pct) }}% + {% else %} + {{ "%.1f"|format(pct) }}% + {% endif %} + + {% endfor %} Edit
@@ -78,7 +103,7 @@
{% else %}
-

No hosts configured yet. Add one.

+

No hosts yet. Summon one to the table.

{% endif %} {% endblock %} diff --git a/roundtable/templates/hosts/uptime.html b/roundtable/templates/hosts/uptime.html new file mode 100644 index 0000000..b3ff1b7 --- /dev/null +++ b/roundtable/templates/hosts/uptime.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% block title %}Uptime / SLA — Roundtable{% endblock %} +{% block content %} +
+

Uptime / SLA

+ ← Hosts +
+ +{# ── Summary pills ──────────────────────────────────────────────────────────── #} +{% set ping_hosts = hosts | selectattr("ping_enabled") | list %} +{% set total = ping_hosts | length %} +{% if total %} +{% set full_30d = ping_hosts | selectattr("id", "in", uptime.keys()) + | selectattr("id", "defined") | list %} +{% set healthy = namespace(count=0) %} +{% for h in ping_hosts %} + {% set pct = uptime.get(h.id, {}).get("30d") %} + {% if pct is not none and pct >= 99 %}{% set healthy.count = healthy.count + 1 %}{% endif %} +{% endfor %} + +
+
+
Hosts monitored
+ {{ total }} +
+
+
≥ 99% (30d)
+ + {{ healthy.count }} + +
+
+{% endif %} + +{# ── SLA table ──────────────────────────────────────────────────────────────── #} +{% if hosts %} +
+ + + + + + + + + + + + + {% for host in hosts %} + {% set ut = uptime.get(host.id, {}) %} + + + + + {% for window in ["24h", "7d", "30d"] %} + {% set pct = ut.get(window) %} + + {% endfor %} + + {# 30-day visual bar #} + {% set pct30 = ut.get("30d") %} + + + {% endfor %} + +
HostAddress24 h7 d30 d30-day bar
{{ host.name }}{{ host.address }} + {% if not host.ping_enabled %} + + {% elif pct is none %} + + {% elif pct >= 99.9 %} + {{ "%.2f"|format(pct) }}% + {% elif pct >= 99 %} + {{ "%.2f"|format(pct) }}% + {% elif pct >= 95 %} + {{ "%.2f"|format(pct) }}% + {% else %} + {{ "%.2f"|format(pct) }}% + {% endif %} + + {% if not host.ping_enabled %} + ping off + {% elif pct30 is none %} + no data + {% else %} +
+
+
+
+
+ {% endif %} +
+
+{% else %} +
+

No hosts under watch. Summon one to the table.

+
+{% endif %} + +

+ Uptime is computed from ping probe results only. Hosts with ping disabled show —. +

+{% endblock %} diff --git a/roundtable/templates/hosts/uptime_widget.html b/roundtable/templates/hosts/uptime_widget.html new file mode 100644 index 0000000..0f4df37 --- /dev/null +++ b/roundtable/templates/hosts/uptime_widget.html @@ -0,0 +1,45 @@ +{# hosts/uptime_widget.html — dashboard widget: SLA uptime summary #} +{% if not hosts %} +
+ No ping-enabled hosts configured. +
+{% else %} + +
+
+ Host + 24h + 7d + 30d +
+ {% for host in hosts %} + {% set ut = uptime.get(host.id, {}) %} +
+ {{ host.name }} + {% for window in ["24h", "7d", "30d"] %} + {% set pct = ut.get(window) %} + + {% if pct is none %} + + {% elif pct >= 99.9 %} + {{ "%.1f"|format(pct) }}% + {% elif pct >= 99 %} + {{ "%.1f"|format(pct) }}% + {% elif pct >= 95 %} + {{ "%.1f"|format(pct) }}% + {% else %} + {{ "%.1f"|format(pct) }}% + {% endif %} + + {% endfor %} +
+ {% endfor %} +
+ +{% if share_token %} + +{% endif %} + +{% endif %} diff --git a/fabledscryer/templates/ping/index.html b/roundtable/templates/ping/index.html similarity index 97% rename from fabledscryer/templates/ping/index.html rename to roundtable/templates/ping/index.html index f28c7ea..728f115 100644 --- a/fabledscryer/templates/ping/index.html +++ b/roundtable/templates/ping/index.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Ping Monitor — Fabled Scryer{% endblock %} +{% block title %}Ping Monitor — Roundtable{% endblock %} {% block content %}
diff --git a/fabledscryer/templates/ping/rows.html b/roundtable/templates/ping/rows.html similarity index 100% rename from fabledscryer/templates/ping/rows.html rename to roundtable/templates/ping/rows.html diff --git a/roundtable/templates/settings/_ansible_sources.html b/roundtable/templates/settings/_ansible_sources.html new file mode 100644 index 0000000..ba47f76 --- /dev/null +++ b/roundtable/templates/settings/_ansible_sources.html @@ -0,0 +1,206 @@ +{# settings/_ansible_sources.html — HTMX partial for Ansible source management #} +
+ + {% if sources %} +
+ {% for src in sources %} + {% set idx = loop.index0 %} + +
+ + {# ── View row ──────────────────────────────────────────────────────── #} +
+ + + {{ src.type }} + + +
+
{{ src.name }}
+
+ {% if src.type == 'git' %} + {{ src.url or '(no URL)' }} + branch: {{ src.branch or 'main' }} + {% if src.pull_interval_seconds %}· pull every {{ src.pull_interval_seconds }}s{% endif %} + {% else %} + {{ src.path or '(no path)' }} + {% endif %} +
+
+ +
+ Browse + + {% if src.type == 'git' %} + + + {% endif %} + + + + +
+
+ + {# ── Inline edit form ──────────────────────────────────────────────── #} + + +
+ {% endfor %} +
+ {% else %} +
+ No sources mustered. Add one below. +
+ {% endif %} + + {# ── Add source ────────────────────────────────────────────────────────── #} +
+ + Add Source + New ▾ + + +
+
+ +
+ + +
+ +
+ + +
+ + + +
+
+ + +
+
+ +
+ +
+
+ +
+ + diff --git a/fabledscryer/templates/settings/_catalog_partial.html b/roundtable/templates/settings/_catalog_partial.html similarity index 84% rename from fabledscryer/templates/settings/_catalog_partial.html rename to roundtable/templates/settings/_catalog_partial.html index cc3afd8..886671d 100644 --- a/fabledscryer/templates/settings/_catalog_partial.html +++ b/roundtable/templates/settings/_catalog_partial.html @@ -12,17 +12,27 @@ {% for plugin in catalog %} {% set is_installed = plugin.name in installed_names %} {% set is_loaded = plugin.name in loaded_names %} -
+ {% set installed_ver = installed_versions.get(plugin.name, "") %} + {# update_available: catalog version is strictly newer than what's on disk #} + {% set update_available = is_installed and installed_ver and installed_ver != plugin.version %} + +
{{ plugin.name }} v{{ plugin.version }} - {% if is_loaded %} + + {% if update_available %} + + Update available — installed v{{ installed_ver }} + + {% elif is_loaded %} Active {% elif is_installed %} Installed {% endif %} + {% for tag in plugin.tags %} {{ tag }} {% endfor %} @@ -46,8 +56,8 @@ class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Source {% endif %} - {% if is_loaded %} - {# Already active — update available if versions differ #} + {% if update_available %} + {# Installed but outdated — offer update (download + restart) #}
- +
+ {% elif is_loaded %} + {# Active and up to date — no action needed #} + {% elif is_installed %} {# Installed but not active — offer hot-reload #}
{% elif not error %} -
No plugins found in catalog.
+
No plugins listed in the catalog.
{% endif %}
diff --git a/fabledscryer/templates/settings/_install_result.html b/roundtable/templates/settings/_install_result.html similarity index 100% rename from fabledscryer/templates/settings/_install_result.html rename to roundtable/templates/settings/_install_result.html diff --git a/roundtable/templates/settings/_plugin_repos.html b/roundtable/templates/settings/_plugin_repos.html new file mode 100644 index 0000000..6ad83f7 --- /dev/null +++ b/roundtable/templates/settings/_plugin_repos.html @@ -0,0 +1,99 @@ +{# settings/_plugin_repos.html — HTMX partial for plugin repository management #} +
+ + {% if repos %} +
+ {% for repo in repos %} + {% set idx = loop.index0 %} + +
+ + {# ── View row ────────────────────────────────────────────────────────── #} +
+
+
{{ repo.name or repo.url }}
+
{{ repo.url }}
+
+
+ + +
+
+ + {# ── Inline edit form ──────────────────────────────────────────────── #} + + +
+ {% endfor %} +
+ {% else %} +
+ No repositories charted. Add one below. +
+ {% endif %} + + {# ── Add repository ────────────────────────────────────────────────────────── #} +
+ + Add Repository + New ▾ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + diff --git a/fabledscryer/templates/settings/_restart_pending.html b/roundtable/templates/settings/_restart_pending.html similarity index 100% rename from fabledscryer/templates/settings/_restart_pending.html rename to roundtable/templates/settings/_restart_pending.html diff --git a/fabledscryer/templates/settings/_tabs.html b/roundtable/templates/settings/_tabs.html similarity index 87% rename from fabledscryer/templates/settings/_tabs.html rename to roundtable/templates/settings/_tabs.html index c73a70a..252391e 100644 --- a/fabledscryer/templates/settings/_tabs.html +++ b/roundtable/templates/settings/_tabs.html @@ -4,6 +4,8 @@ {% set tabs = [ ("general", "General", "/settings/general/"), ("notifications", "Notifications", "/settings/notifications/"), + ("reports", "Reports", "/settings/reports/"), + ("auth", "Auth", "/settings/auth/"), ("ansible", "Ansible", "/settings/ansible/"), ("plugins", "Plugins", "/settings/plugins/"), ] %} diff --git a/roundtable/templates/settings/ansible.html b/roundtable/templates/settings/ansible.html new file mode 100644 index 0000000..f7b0532 --- /dev/null +++ b/roundtable/templates/settings/ansible.html @@ -0,0 +1,19 @@ +{# roundtable/templates/settings/ansible.html #} +{% extends "base.html" %} +{% block title %}Settings — Ansible — Roundtable{% endblock %} +{% block content %} +{% set active_tab = "ansible" %} +{% include "settings/_tabs.html" %} + +
+
+
Ansible Sources
+ Run History → +
+

+ Sources are directories of playbooks — either a local path or a git repository + cloned automatically. Add as many as you need; each can be browsed and run independently. +

+ {% include "settings/_ansible_sources.html" %} +
+{% endblock %} diff --git a/roundtable/templates/settings/auth.html b/roundtable/templates/settings/auth.html new file mode 100644 index 0000000..aecece1 --- /dev/null +++ b/roundtable/templates/settings/auth.html @@ -0,0 +1,178 @@ +{# roundtable/templates/settings/auth.html #} +{% extends "base.html" %} +{% block title %}Settings — Auth — Roundtable{% endblock %} +{% block content %} +{% set active_tab = "auth" %} +{% include "settings/_tabs.html" %} + +
+
+ + {# ── OIDC ─────────────────────────────────────────────────────────────────── #} +
+

OIDC / SSO

+

+ Supports any OIDC provider — Authentik, Keycloak, Authelia, etc. Uses Authorization Code flow. + Local admin login always works regardless of OIDC status. +

+ +
+ + +
+ +
+ + +

+ Authentik: https://<host>/application/o/<slug>/.well-known/openid-configuration +

+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +

Members → admin role

+
+
+ + +

Members → operator role; others → viewer

+
+
+
+ + {# ── LDAP ─────────────────────────────────────────────────────────────────── #} +
+

LDAP

+

+ Binds user credentials against an LDAP/AD directory. Requires the + ldap3 Python package (pip install ldap3). + If both LDAP and OIDC are enabled, the login form tries LDAP; OIDC uses the SSO button. +

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +

+ {username} is replaced with the login username. AD example: (sAMAccountName={username}) +

+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+
+ + + Changes take effect immediately. + +
+
+{% endblock %} diff --git a/fabledscryer/templates/settings/general.html b/roundtable/templates/settings/general.html similarity index 93% rename from fabledscryer/templates/settings/general.html rename to roundtable/templates/settings/general.html index c3043ae..fec48dc 100644 --- a/fabledscryer/templates/settings/general.html +++ b/roundtable/templates/settings/general.html @@ -1,6 +1,6 @@ -{# fabledscryer/templates/settings/general.html #} +{# roundtable/templates/settings/general.html #} {% extends "base.html" %} -{% block title %}Settings — General — Fabled Scryer{% endblock %} +{% block title %}Settings — General — Roundtable{% endblock %} {% block content %} {% set active_tab = "general" %} {% include "settings/_tabs.html" %} diff --git a/fabledscryer/templates/settings/index.html b/roundtable/templates/settings/index.html similarity index 98% rename from fabledscryer/templates/settings/index.html rename to roundtable/templates/settings/index.html index 1286f8c..7454c62 100644 --- a/fabledscryer/templates/settings/index.html +++ b/roundtable/templates/settings/index.html @@ -1,6 +1,6 @@ -{# fabledscryer/templates/settings/index.html #} +{# roundtable/templates/settings/index.html #} {% extends "base.html" %} -{% block title %}Settings — Fabled Scryer{% endblock %} +{% block title %}Settings — Roundtable{% endblock %} {% block content %}

Settings

diff --git a/fabledscryer/templates/settings/notifications.html b/roundtable/templates/settings/notifications.html similarity index 95% rename from fabledscryer/templates/settings/notifications.html rename to roundtable/templates/settings/notifications.html index 1625ff5..f9e18da 100644 --- a/fabledscryer/templates/settings/notifications.html +++ b/roundtable/templates/settings/notifications.html @@ -1,6 +1,6 @@ -{# fabledscryer/templates/settings/notifications.html #} +{# roundtable/templates/settings/notifications.html #} {% extends "base.html" %} -{% block title %}Settings — Notifications — Fabled Scryer{% endblock %} +{% block title %}Settings — Notifications — Roundtable{% endblock %} {% block content %} {% set active_tab = "notifications" %} {% include "settings/_tabs.html" %} diff --git a/roundtable/templates/settings/plugin_detail.html b/roundtable/templates/settings/plugin_detail.html new file mode 100644 index 0000000..94c4ad5 --- /dev/null +++ b/roundtable/templates/settings/plugin_detail.html @@ -0,0 +1,134 @@ +{# roundtable/templates/settings/plugin_detail.html #} +{% extends "base.html" %} +{% block title %}{{ plugin.get('name', plugin._dir) }} Settings — Roundtable{% endblock %} +{% block content %} +{% set active_tab = "plugins" %} +{% include "settings/_tabs.html" %} + +
+ ← Plugins +

{{ plugin.get('name', plugin._dir) }}

+ v{{ plugin.get('version', '?') }} + {% if fail_reason %} + Error + {% elif is_loaded %} + Loaded + {% elif plugin._enabled %} + Enabled (restart required) + {% else %} + Disabled + {% endif %} +
+ +{% if fail_reason %} +
+ + Failed to load + — {{ fail_reason }} + +
+{% endif %} + +{% if plugin.get('description') %} +

{{ plugin.description }}

+{% endif %} + + + + {# ── Enable toggle ────────────────────────────────────────────────────────── #} +
+
+ + +
+
+ Enable/disable changes take effect after a restart. +
+
+ + {# ── Config fields ─────────────────────────────────────────────────────────── #} + {% if plugin.get('config') %} +
+
Configuration
+
+ + {% for cfg_key, cfg_default in plugin.config.items() %} + + {% if cfg_default is iterable and cfg_default is not string and cfg_default is not mapping %} +
+ 📋 + {{ cfg_key }} — list config, edit in + plugin.yaml ({{ cfg_default | length }} item{{ 's' if cfg_default | length != 1 }}). +
+ + {% elif cfg_default is mapping %} +
+
{{ cfg_key }}
+ {% set sub_cfg = plugin._config.get(cfg_key, cfg_default) if plugin._config.get(cfg_key, cfg_default) is mapping else cfg_default %} +
+ {% for sub_key, sub_default in cfg_default.items() %} + {% if sub_default is sameas false or sub_default is sameas true %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} + {% endfor %} +
+
+ + {% elif cfg_default is sameas false or cfg_default is sameas true %} +
+ + +
+ + {% else %} +
+ + +
+ {% endif %} + + {% endfor %} +
+
+ {% endif %} + + + +
+{% endblock %} diff --git a/roundtable/templates/settings/plugins.html b/roundtable/templates/settings/plugins.html new file mode 100644 index 0000000..4959ec0 --- /dev/null +++ b/roundtable/templates/settings/plugins.html @@ -0,0 +1,116 @@ +{# roundtable/templates/settings/plugins.html #} +{% extends "base.html" %} +{% block title %}Settings — Plugins — Roundtable{% endblock %} +{% block content %} +{% set active_tab = "plugins" %} +{% include "settings/_tabs.html" %} + +{# ── Restart banner ────────────────────────────────────────────────────────── #} +
+ +{# ── Installed Plugins ─────────────────────────────────────────────────────── #} +
+
Installed Plugins
+
+ +
+
+ +{% if not discovered_plugins %} +
+ No plugins installed. The table awaits new seats. +
+{% else %} +
+ {% for plugin in discovered_plugins %} + {% set fail_reason = plugin_failures.get(plugin._dir) %} +
+ + {# Status indicator #} + {% if fail_reason %} + + {% elif plugin._enabled %} + + {% else %} + + {% endif %} + + {# Name + version + description #} +
+
+ {{ plugin.get('name', plugin._dir) }} + v{{ plugin.get('version', '?') }} + {% if fail_reason %} + Error + {% elif plugin._enabled %} + Enabled + {% else %} + Disabled + {% endif %} +
+ {% if plugin.get('description') %} +
{{ plugin.description }}
+ {% endif %} + {% if fail_reason %} +
{{ fail_reason }}
+ {% endif %} +
+ + {# Settings button #} + Settings → +
+ {% endfor %} +
+{% endif %} + +{# ── Plugin Repositories ───────────────────────────────────────────────────── #} +
+
Plugin Repositories
+

+ Repositories are remote index.yaml files that Scryer checks for available + and updated plugins. Add repos from other developers to expand the plugin catalog. +

+ {% include "settings/_plugin_repos.html" %} +
+ +{# ── Plugin Catalog ────────────────────────────────────────────────────────── #} +
+
+
Plugin Catalog
+ +
+ + {% if repos %} +
+
Loading catalog…
+
+ {% else %} +
+ Add a plugin repository above to browse available plugins. +
+ {% endif %} +
+ +{% endblock %} diff --git a/roundtable/templates/settings/reports.html b/roundtable/templates/settings/reports.html new file mode 100644 index 0000000..02e03ec --- /dev/null +++ b/roundtable/templates/settings/reports.html @@ -0,0 +1,91 @@ +{# roundtable/templates/settings/reports.html #} +{% extends "base.html" %} +{% block title %}Settings — Reports — Roundtable{% endblock %} +{% block content %} +{% set active_tab = "reports" %} +{% include "settings/_tabs.html" %} + +{% if flash %} +
+ {{ flash.message }} +
+{% endif %} + +
+ + {# ── Schedule ─────────────────────────────────────────────────────────────── #} +
+
+

Weekly Digest

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +

+ Requires SMTP configured in Notifications. + {% if settings['reports.last_sent_at'] %} + Last sent: {{ settings['reports.last_sent_at'][:16] }} UTC. + {% endif %} +

+ +
+ +
+
+
+ + {# ── Report contents ─────────────────────────────────────────────────────── #} +
+

Report contents

+
+
Uptime summary — 7-day % per ping-enabled host
+
Hosts currently down — highlighted at top if any
+
Active alerts — firing, acknowledged, pending rules
+
Alert events (7d) — fired and resolved counts
+
Top Traefik routers — avg req/s over last 24h (if plugin enabled)
+
+
+ + {# ── Send now ─────────────────────────────────────────────────────────────── #} +
+

Send Now

+

+ Send a report immediately regardless of schedule. Useful for testing. +

+
+ +
+
+ +
+{% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index 6459198..1e68dd0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ import pytest import pytest_asyncio from pathlib import Path import textwrap -from fabledscryer.app import create_app +from roundtable.app import create_app @pytest.fixture diff --git a/tests/plugins/__init__.py b/tests/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugins/host_agent/__init__.py b/tests/plugins/host_agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugins/host_agent/test_agent_build_payload.py b/tests/plugins/host_agent/test_agent_build_payload.py new file mode 100644 index 0000000..f38f567 --- /dev/null +++ b/tests/plugins/host_agent/test_agent_build_payload.py @@ -0,0 +1,117 @@ +from unittest.mock import patch +from plugins.host_agent import agent as a + + +def test_build_sample_shape(): + fake_mem = {"total_bytes": 100, "used_bytes": 40, + "available_bytes": 60, "swap_used_bytes": 0} + fake_load = {"1m": 0.1, "5m": 0.2, "15m": 0.3} + fake_storage = [{"mount": "/", "total_bytes": 1000, "used_bytes": 400}] + + with patch.object(a, "collect_cpu", return_value=12.5), \ + patch.object(a, "collect_memory", return_value=fake_mem), \ + patch.object(a, "collect_load", return_value=fake_load), \ + patch.object(a, "collect_uptime", return_value=1234), \ + patch.object(a, "collect_storage", return_value=fake_storage): + sample = a.build_sample(mounts=["/"]) + + assert sample["cpu_pct"] == 12.5 + assert sample["mem"]["used_bytes"] == 40 + assert sample["load"]["1m"] == 0.1 + assert sample["uptime_secs"] == 1234 + assert sample["storage"][0]["mount"] == "/" + assert "ts" in sample + # ISO-8601 UTC string + assert "T" in sample["ts"] + + +def test_build_sample_tolerates_collector_failure(): + # If a collector raises, the sample should still be built with None for that field. + def _boom(): + raise RuntimeError("no /proc") + + with patch.object(a, "collect_cpu", side_effect=_boom), \ + patch.object(a, "collect_memory", return_value={"total_bytes": 1, "used_bytes": 0, + "available_bytes": 1, "swap_used_bytes": 0}), \ + patch.object(a, "collect_load", return_value={"1m": 0.0, "5m": 0.0, "15m": 0.0}), \ + patch.object(a, "collect_uptime", return_value=0), \ + patch.object(a, "collect_storage", return_value=[]): + sample = a.build_sample(mounts=["/"]) + assert sample["cpu_pct"] is None + assert sample["mem"]["total_bytes"] == 1 + + +def test_build_payload_wraps_samples(): + metadata = {"kernel": "6.8", "distro": "Ubuntu", "arch": "x86_64"} + payload = a.build_payload( + samples=[{"ts": "2026-04-14T00:00:00+00:00", "cpu_pct": 5.0}], + hostname="myhost", + metadata=metadata, + ) + assert payload["agent_version"] == a.AGENT_VERSION + assert payload["hostname"] == "myhost" + assert payload["metadata"] == metadata + assert len(payload["samples"]) == 1 + assert payload["samples"][0]["cpu_pct"] == 5.0 + + +import urllib.error +from unittest.mock import MagicMock, patch + + +def test_post_payload_success(): + from plugins.host_agent import agent as a + captured = {} + + class FakeResp: + status = 200 + def __enter__(self): return self + def __exit__(self, *a): return False + def read(self): return b'{"ok":true}' + + def fake_urlopen(req, timeout=10): + captured["url"] = req.full_url + captured["headers"] = dict(req.header_items()) + captured["body"] = req.data + return FakeResp() + + with patch.object(a.urllib.request, "urlopen", side_effect=fake_urlopen): + ok, status = a.post_payload("https://x", "tok", {"hello": "world"}) + assert ok is True + assert status == 200 + assert captured["url"] == "https://x/plugins/host_agent/ingest" + assert captured["headers"]["Authorization"] == "Bearer tok" + + +def test_post_payload_http_error_returns_status(): + from plugins.host_agent import agent as a + + def raise_401(req, timeout=10): + raise urllib.error.HTTPError(req.full_url, 401, "unauthorized", {}, None) + + with patch.object(a.urllib.request, "urlopen", side_effect=raise_401): + ok, status = a.post_payload("https://x", "tok", {}) + assert ok is False + assert status == 401 + + +def test_post_payload_network_error_returns_none_status(): + from plugins.host_agent import agent as a + + def raise_url(req, timeout=10): + raise urllib.error.URLError("conn refused") + + with patch.object(a.urllib.request, "urlopen", side_effect=raise_url): + ok, status = a.post_payload("https://x", "tok", {}) + assert ok is False + assert status is None + + +def test_backoff_schedule(): + from plugins.host_agent.agent import next_backoff + assert next_backoff(0) == 30 + assert next_backoff(30) == 60 + assert next_backoff(60) == 120 + assert next_backoff(120) == 240 + assert next_backoff(240) == 300 # capped + assert next_backoff(300) == 300 diff --git a/tests/plugins/host_agent/test_agent_collectors.py b/tests/plugins/host_agent/test_agent_collectors.py new file mode 100644 index 0000000..9f3d1fa --- /dev/null +++ b/tests/plugins/host_agent/test_agent_collectors.py @@ -0,0 +1,101 @@ +# tests/plugins/host_agent/test_agent_collectors.py +"""Unit tests for per-resource collectors, driven by fixture /proc files.""" +from unittest.mock import patch +import pytest +from plugins.host_agent import agent as a + + +PROC_STAT_SAMPLE_1 = "cpu 100 0 50 850 0 0 0 0 0 0\n" +PROC_STAT_SAMPLE_2 = "cpu 200 0 100 1700 0 0 0 0 0 0\n" + +PROC_MEMINFO = ( + "MemTotal: 16000000 kB\n" + "MemFree: 2000000 kB\n" + "MemAvailable: 6000000 kB\n" + "SwapTotal: 4000000 kB\n" + "SwapFree: 3500000 kB\n" +) + +PROC_LOADAVG = "0.42 0.55 0.61 1/234 5678\n" +PROC_UPTIME = "482934.12 1000000.00\n" +OS_RELEASE = 'PRETTY_NAME="Ubuntu 24.04 LTS"\nNAME="Ubuntu"\n' + + +def test_collect_memory_uses_available(tmp_path): + p = tmp_path / "meminfo" + p.write_text(PROC_MEMINFO) + with patch.object(a, "MEMINFO_PATH", str(p)): + mem = a.collect_memory() + assert mem["total_bytes"] == 16000000 * 1024 + assert mem["available_bytes"] == 6000000 * 1024 + assert mem["used_bytes"] == (16000000 - 6000000) * 1024 + assert mem["swap_used_bytes"] == (4000000 - 3500000) * 1024 + + +def test_collect_load(tmp_path): + p = tmp_path / "loadavg" + p.write_text(PROC_LOADAVG) + with patch.object(a, "LOADAVG_PATH", str(p)): + load = a.collect_load() + assert load == {"1m": 0.42, "5m": 0.55, "15m": 0.61} + + +def test_collect_uptime(tmp_path): + p = tmp_path / "uptime" + p.write_text(PROC_UPTIME) + with patch.object(a, "UPTIME_PATH", str(p)): + assert a.collect_uptime() == 482934 + + +def test_collect_cpu_reads_twice(): + # Two sequential _read_file calls return two stat snapshots. + reads = iter([PROC_STAT_SAMPLE_1, PROC_STAT_SAMPLE_2]) + + def _read(_path): + return next(reads) + + with patch.object(a, "_read_file", _read), \ + patch.object(a.time, "sleep", lambda _s: None): + pct = a.collect_cpu(sample_window=0.0) + # totals: 1000 → 2000 (delta 1000). idle+iowait: 850 → 1700 (delta 850). + # busy = 150. pct = 15.0. + assert pct == pytest.approx(15.0, abs=0.1) + + +def test_collect_storage(): + fake = {"/": (1000, 400, 600), "/mnt/data": (2000, 500, 1500)} + + def _fake_usage(path): + total, used, free = fake[path] + + class R: + pass + + r = R() + r.total = total + r.used = used + r.free = free + return r + + with patch.object(a.shutil, "disk_usage", side_effect=_fake_usage): + out = a.collect_storage(["/", "/mnt/data"]) + assert out == [ + {"mount": "/", "total_bytes": 1000, "used_bytes": 400}, + {"mount": "/mnt/data", "total_bytes": 2000, "used_bytes": 500}, + ] + + +def test_collect_metadata(tmp_path): + p = tmp_path / "os-release" + p.write_text(OS_RELEASE) + + class Uname: + release = "6.8.0-45-generic" + machine = "x86_64" + + with patch.object(a, "OS_RELEASE_PATH", str(p)), \ + patch.object(a.os, "uname", return_value=Uname()): + meta = a.collect_metadata() + assert meta["kernel"] == "6.8.0-45-generic" + assert meta["arch"] == "x86_64" + assert "Ubuntu 24.04" in meta["distro"] diff --git a/tests/plugins/host_agent/test_agent_config.py b/tests/plugins/host_agent/test_agent_config.py new file mode 100644 index 0000000..0364d86 --- /dev/null +++ b/tests/plugins/host_agent/test_agent_config.py @@ -0,0 +1,60 @@ +# tests/plugins/host_agent/test_agent_config.py +"""Unit tests for the agent's homegrown config parser.""" +import pytest +from plugins.host_agent.agent import read_config, ConfigError + + +def test_parses_flat_key_value(tmp_path): + p = tmp_path / "agent.conf" + p.write_text( + "url = https://roundtable.example\n" + "token = abc123\n" + "interval_seconds = 45\n" + ) + cfg = read_config(str(p)) + assert cfg["url"] == "https://roundtable.example" + assert cfg["token"] == "abc123" + assert cfg["interval_seconds"] == 45 + + +def test_ignores_blank_lines_and_comments(tmp_path): + p = tmp_path / "agent.conf" + p.write_text( + "# top comment\n" + "\n" + "url = https://x\n" + " # indented comment\n" + "token = t\n" + ) + cfg = read_config(str(p)) + # interval_seconds defaults to 30 when unset + assert cfg["url"] == "https://x" + assert cfg["token"] == "t" + + +def test_parses_mounts_as_list(tmp_path): + p = tmp_path / "agent.conf" + p.write_text("url = x\ntoken = y\nmounts = /, /mnt/data, /srv\n") + cfg = read_config(str(p)) + assert cfg["mounts"] == ["/", "/mnt/data", "/srv"] + + +def test_missing_required_fields_raises(tmp_path): + p = tmp_path / "agent.conf" + p.write_text("url = https://x\n") + with pytest.raises(ConfigError, match="token"): + read_config(str(p)) + + +def test_malformed_line_raises(tmp_path): + p = tmp_path / "agent.conf" + p.write_text("url https://x\ntoken = y\n") + with pytest.raises(ConfigError): + read_config(str(p)) + + +def test_default_interval_seconds(tmp_path): + p = tmp_path / "agent.conf" + p.write_text("url = x\ntoken = y\n") + cfg = read_config(str(p)) + assert cfg["interval_seconds"] == 30 diff --git a/tests/plugins/host_agent/test_agent_ring_buffer.py b/tests/plugins/host_agent/test_agent_ring_buffer.py new file mode 100644 index 0000000..f0d7a46 --- /dev/null +++ b/tests/plugins/host_agent/test_agent_ring_buffer.py @@ -0,0 +1,24 @@ +from plugins.host_agent.agent import RingBuffer + + +def test_ring_buffer_preserves_order(): + rb = RingBuffer(maxlen=3) + rb.push(1); rb.push(2); rb.push(3) + assert list(rb.drain()) == [1, 2, 3] + assert len(rb) == 0 + + +def test_ring_buffer_drops_oldest_when_full(): + rb = RingBuffer(maxlen=3) + for v in (1, 2, 3, 4, 5): + rb.push(v) + assert list(rb.drain()) == [3, 4, 5] + + +def test_ring_buffer_drain_clears_and_is_atomic(): + rb = RingBuffer(maxlen=5) + rb.push("a"); rb.push("b") + out = list(rb.drain()) + rb.push("c") + assert out == ["a", "b"] + assert list(rb.drain()) == ["c"] diff --git a/tests/plugins/host_agent/test_install_template.py b/tests/plugins/host_agent/test_install_template.py new file mode 100644 index 0000000..07714df --- /dev/null +++ b/tests/plugins/host_agent/test_install_template.py @@ -0,0 +1,75 @@ +"""Pure-function tests for install.sh.j2 rendering and agent.py parsing.""" +import subprocess +from pathlib import Path + +import pytest +from jinja2 import Environment, FileSystemLoader + +from plugins.host_agent.routes import _agent_version, AGENT_SOURCE_PATH + + +TEMPLATE_DIR = Path(__file__).resolve().parents[3] / "plugins" / "host_agent" / "templates" + + +def _render(**overrides) -> str: + env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR))) + defaults = dict( + url="https://r.example", + token="tok-abc", + agent_version="1.0.0", + host_name="testhost", + host_address="10.0.0.1", + ) + defaults.update(overrides) + return env.get_template("install.sh.j2").render(**defaults) + + +def test_render_contains_token_and_url(): + out = _render() + assert "tok-abc" in out + assert "https://r.example" in out + assert "testhost" in out + + +def test_render_contains_hardening_directives(): + out = _render() + for needle in ( + "NoNewPrivileges=yes", + "ProtectSystem=strict", + "ProtectHome=yes", + "PrivateTmp=yes", + "ReadOnlyPaths=/proc /sys", + ): + assert needle in out, f"missing {needle}" + + +def test_render_has_uninstall_branch(): + out = _render() + assert "--uninstall" in out + assert "systemctl disable --now roundtable-agent.service" in out + + +def test_render_has_systemctl_enable(): + out = _render() + assert "systemctl enable --now roundtable-agent.service" in out + + +def test_render_uses_agent_py_url(): + out = _render() + assert "/plugins/host_agent/agent.py" in out + + +def test_rendered_script_passes_sh_n(tmp_path): + out = _render() + script = tmp_path / "install.sh" + script.write_text(out) + result = subprocess.run( + ["sh", "-n", str(script)], capture_output=True, text=True + ) + assert result.returncode == 0, f"sh -n failed: {result.stderr}" + + +def test_agent_version_parses_on_disk_agent(): + assert AGENT_SOURCE_PATH.exists() + v = _agent_version() + assert v == "1.0.0" diff --git a/tests/plugins/host_agent/test_routes_expand_metrics.py b/tests/plugins/host_agent/test_routes_expand_metrics.py new file mode 100644 index 0000000..92bd300 --- /dev/null +++ b/tests/plugins/host_agent/test_routes_expand_metrics.py @@ -0,0 +1,98 @@ +"""Pure-function tests for host_agent metric expansion.""" +from datetime import datetime, timezone + +from plugins.host_agent.routes import _expand_sample_to_metrics + + +NOW = datetime(2026, 4, 14, 12, 0, 0, tzinfo=timezone.utc) + + +def _sample() -> dict: + return { + "ts": NOW.isoformat(), + "cpu_pct": 12.5, + "mem": { + "total_bytes": 16_000_000_000, + "used_bytes": 8_000_000_000, + "available_bytes": 8_000_000_000, + "swap_used_bytes": 0, + }, + "load": {"1m": 0.1, "5m": 0.2, "15m": 0.3}, + "uptime_secs": 1234, + "storage": [ + {"mount": "/", "total_bytes": 1000, "used_bytes": 400}, + {"mount": "/mnt/data", "total_bytes": 2000, "used_bytes": 1800}, + ], + } + + +def _by_metric(rows, name): + return [r for r in rows if r.metric_name == name] + + +def test_expand_emits_cpu_mem_load_uptime(): + rows = _expand_sample_to_metrics(_sample(), "testhost", NOW) + names = {r.metric_name for r in rows} + assert "cpu_pct" in names + assert "mem_used_pct" in names + assert "mem_available_bytes" in names + assert "swap_used_bytes" in names + assert "load_1m" in names and "load_5m" in names and "load_15m" in names + assert "uptime_secs" in names + + +def test_expand_source_module_and_recorded_at(): + rows = _expand_sample_to_metrics(_sample(), "testhost", NOW) + assert rows, "expected non-empty rows" + for r in rows: + assert r.source_module == "host_agent" + assert r.recorded_at == NOW + + +def test_expand_cpu_value_and_resource(): + cpu = _by_metric(_expand_sample_to_metrics(_sample(), "testhost", NOW), "cpu_pct") + assert len(cpu) == 1 + assert cpu[0].value == 12.5 + assert cpu[0].resource_name == "testhost" + + +def test_expand_mem_used_pct_math(): + # used = total - available = 50% + used = _by_metric(_expand_sample_to_metrics(_sample(), "testhost", NOW), "mem_used_pct") + assert len(used) == 1 + assert abs(used[0].value - 50.0) < 0.01 + + +def test_expand_per_mount_disk_metrics(): + rows = _expand_sample_to_metrics(_sample(), "testhost", NOW) + per_mount = _by_metric(rows, "disk_used_pct") + assert {r.resource_name for r in per_mount} == {"testhost:/", "testhost:/mnt/data"} + # Values: / is 40%, /mnt/data is 90% + by_res = {r.resource_name: r.value for r in per_mount} + assert abs(by_res["testhost:/"] - 40.0) < 0.01 + assert abs(by_res["testhost:/mnt/data"] - 90.0) < 0.01 + + +def test_expand_disk_worst_is_max(): + worst = _by_metric(_expand_sample_to_metrics(_sample(), "testhost", NOW), "disk_used_pct_worst") + assert len(worst) == 1 + assert worst[0].resource_name == "testhost" + assert abs(worst[0].value - 90.0) < 0.01 + + +def test_expand_skips_missing_cpu_and_load(): + sample = {"mem": {"total_bytes": 100, "available_bytes": 50}} + rows = _expand_sample_to_metrics(sample, "h", NOW) + names = {r.metric_name for r in rows} + assert "cpu_pct" not in names + assert "load_1m" not in names + assert "uptime_secs" not in names + assert "mem_used_pct" in names + + +def test_expand_empty_storage_emits_no_disk_worst(): + sample = {"cpu_pct": 1.0, "storage": []} + rows = _expand_sample_to_metrics(sample, "h", NOW) + names = {r.metric_name for r in rows} + assert "disk_used_pct_worst" not in names + assert "cpu_pct" in names diff --git a/tests/plugins/host_agent/test_scheduler.py b/tests/plugins/host_agent/test_scheduler.py new file mode 100644 index 0000000..49be93a --- /dev/null +++ b/tests/plugins/host_agent/test_scheduler.py @@ -0,0 +1,48 @@ +"""Pure-function tests for host_agent staleness filter.""" +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from plugins.host_agent.scheduler import _filter_stale + + +@dataclass +class FakeReg: + host_id: str + last_seen_at: datetime | None + + +NOW = datetime(2026, 4, 15, 12, 0, 0, tzinfo=timezone.utc) + + +def test_filter_stale_returns_only_old_rows(): + regs = [ + FakeReg("fresh", NOW - timedelta(seconds=30)), + FakeReg("stale", NOW - timedelta(seconds=600)), + FakeReg("never", None), + ] + stale = _filter_stale(regs, now=NOW, stale_after_seconds=180) + ids = {r.host_id for r in stale} + assert ids == {"stale"} + + +def test_filter_stale_empty_input(): + assert _filter_stale([], now=NOW, stale_after_seconds=180) == [] + + +def test_filter_stale_all_fresh(): + regs = [ + FakeReg("a", NOW - timedelta(seconds=10)), + FakeReg("b", NOW - timedelta(seconds=30)), + ] + assert _filter_stale(regs, now=NOW, stale_after_seconds=60) == [] + + +def test_filter_stale_boundary_exact_cutoff_is_not_stale(): + # A row exactly at the cutoff should not be flagged (strict less-than). + regs = [FakeReg("edge", NOW - timedelta(seconds=180))] + assert _filter_stale(regs, now=NOW, stale_after_seconds=180) == [] + + +def test_filter_stale_skips_none_last_seen(): + regs = [FakeReg("never", None)] + assert _filter_stale(regs, now=NOW, stale_after_seconds=1) == [] diff --git a/tests/test_app.py b/tests/test_app.py index 219ec5e..006f674 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,5 +1,5 @@ import pytest -from fabledscryer.app import create_app +from roundtable.app import create_app @pytest.mark.asyncio diff --git a/tests/test_auth.py b/tests/test_auth.py index 876e318..f6933d4 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,7 @@ import pytest import bcrypt from unittest.mock import AsyncMock, patch -from fabledscryer.models.users import User, UserRole +from roundtable.models.users import User, UserRole def make_user(role=UserRole.admin): @@ -23,7 +23,7 @@ async def test_login_page_returns_200(client): @pytest.mark.asyncio async def test_login_redirects_to_setup_when_no_users(client, app): - with patch("fabledscryer.auth.routes.get_user_count", new=AsyncMock(return_value=0)): + with patch("roundtable.auth.routes.get_user_count", new=AsyncMock(return_value=0)): response = await client.get("/login") assert response.status_code == 302 assert "/setup" in response.headers["Location"] @@ -32,8 +32,8 @@ async def test_login_redirects_to_setup_when_no_users(client, app): @pytest.mark.asyncio async def test_login_success_redirects_to_dashboard(client, app): user = make_user() - with patch("fabledscryer.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \ - patch("fabledscryer.auth.routes.get_user_by_username", new=AsyncMock(return_value=user)): + with patch("roundtable.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \ + patch("roundtable.auth.routes.get_user_by_username", new=AsyncMock(return_value=user)): response = await client.post("/login", form={"username": "admin", "password": "password"}) assert response.status_code == 302 assert "/" in response.headers["Location"] @@ -42,8 +42,8 @@ async def test_login_success_redirects_to_dashboard(client, app): @pytest.mark.asyncio async def test_login_failure_returns_400(client, app): user = make_user() - with patch("fabledscryer.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \ - patch("fabledscryer.auth.routes.get_user_by_username", new=AsyncMock(return_value=user)): + with patch("roundtable.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \ + patch("roundtable.auth.routes.get_user_by_username", new=AsyncMock(return_value=user)): response = await client.post("/login", form={"username": "admin", "password": "wrong"}) assert response.status_code == 400 diff --git a/tests/test_config.py b/tests/test_config.py index 31fcf20..ba5a019 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,18 +1,18 @@ import os import textwrap import pytest -from fabledscryer.config import load_bootstrap +from roundtable.config import load_bootstrap def test_load_bootstrap_from_yaml(tmp_path): cfg_file = tmp_path / "config.yaml" cfg_file.write_text(textwrap.dedent("""\ database: - url: postgresql+asyncpg://user:pass@localhost/fabledscryer + url: postgresql+asyncpg://user:pass@localhost/roundtable secret_key: test-secret """)) cfg = load_bootstrap(cfg_file) - assert cfg["database_url"] == "postgresql+asyncpg://user:pass@localhost/fabledscryer" + assert cfg["database_url"] == "postgresql+asyncpg://user:pass@localhost/roundtable" assert cfg["secret_key"] == "test-secret" diff --git a/tests/test_models.py b/tests/test_models.py index f09c952..fbed0c7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,5 @@ import pytest -from fabledscryer.models.users import User, UserRole +from roundtable.models.users import User, UserRole def test_user_model_fields():