Files
heartbeat/docs/superpowers/specs/2026-05-09-config-editor-design.md
T

9.7 KiB

Config Editor — Design Spec

Date: 2026-05-09 Status: Approved

Goal

Allow admins to edit the full .hb.yaml config through the Settings page UI, and allow regular users to manage their own notification channels and profile fields through the Profile page. The YAML file remains the single authoritative source; comments are preserved on every write.


Architecture Overview

Browser (admin)                     Browser (user)
  staged edits (JS state)             form fields
        │                                  │
        │ POST /api/0/config               │ PUT /api/0/users/me
        ▼                                  ▼
  http.py handlers ────────────────────────┘
        │
        ▼
  configio.py   ←── ruamel.yaml (round-trip, comment-preserving)
        │
        ├── backup .hb.yaml.bak.YYYYMMDD-HHMMSS  (keep last 10)
        ├── write atomically (temp file → os.replace)
        └── ReloadableConfig.reload()

New Dependency

Add ruamel.yaml>=0.18 to [project.optional-dependencies] server in pyproject.toml. PyYAML stays (used by the client and config loader for reads); ruamel.yaml is used only for write-back.


New Module: hbd/server/configio.py

Single responsibility: all YAML read/write for .hb.yaml.

_write_lock = threading.Lock()

def read_roundtrip(path: str) -> CommentedMap:
    """Load .hb.yaml with ruamel.yaml, preserving comments and ordering."""

def write_config(path: str, data: CommentedMap) -> None:
    """Backup current file, then atomically write data.

    Backup naming: {path}.bak.YYYYMMDD-HHMMSS
    Rotation: keep the 10 most recent backups, delete older ones.
    Atomic write: write to {path}.tmp, then os.replace({path}.tmp, path).
    Acquires _write_lock for the full backup+write sequence.
    """

def list_backups(path: str) -> list[str]:
    """Return backup paths sorted newest-first."""

def apply_structured_section(data: CommentedMap, section: str, values: dict) -> None:
    """Merge a dict of scalar/list values into data[section], key by key.
    Preserves comments on unmodified keys.
    """

def apply_yaml_section(data: CommentedMap, section: str, yaml_text: str) -> None:
    """Replace data[section] entirely by parsing yaml_text.
    Used for YAML-editor sections (notification_channels, thresholds, hosts, dns).
    """

API Endpoints

All endpoints require authentication. Admin-only endpoints return 403 for non-admins.

Method Path Auth Purpose
GET /api/0/config admin Full config as JSON (secrets masked)
POST /api/0/config admin Publish staged changes to .hb.yaml
GET /api/0/config/section/{name} admin Raw YAML text for one section (for YAML editors)
GET /api/0/config/backups admin List of backup timestamps, newest first
POST /api/0/config/rollback admin {"backup": "…"} → restore backup and reload
PUT /api/0/users/me any user Update own full_name, avatar, notification_channels, password

POST /api/0/config payload

{
  "server":                 { "hbd_port": 50004, "interval": 20, ... },
  "users":                  { "alice": { "full_name": "Alice", "admin": true, ... }, ... },
  "oauth":                  { "gitea": { "type": "gitea", "url": "...", ... }, ... },
  "notification_channels":  "<raw yaml text>",
  "thresholds":             "<raw yaml text>",
  "hosts":                  "<raw yaml text>",
  "dns":                    "<raw yaml text>"
}

Only sections present in the payload are updated; omitted sections are left unchanged in the file.

Section-to-key mapping: Most config fields are top-level keys in .hb.yaml (not nested under a section key). The API uses logical section names that map to specific top-level keys:

Logical section Top-level YAML keys covered
server hbd_port, hbd_host, ws_port, wss_port, hb_port, interval, grace, base_url, threshold_renotify_interval, logfile, pidfile, pickfile, journal_enabled, journal_dir, journal_max_size, journal_max_backups, default_owner
users users (top-level dict)
oauth oauth (top-level dict)
notification_channels notification_channels (top-level dict, YAML text)
thresholds threshold_configs (top-level dict if present, YAML text)
hosts hosts (top-level dict, YAML text)
dns nsupdate_bin, dyndomains, dyndnshosts, drophosts (YAML text of just these keys)

apply_structured_section for server iterates the known key list and updates each present key individually, preserving comments on unchanged keys. apply_yaml_section for dict-valued sections (notification_channels, hosts, oauth) replaces the entire subtree. For dns, it replaces each of the four top-level keys listed.

PUT /api/0/users/me payload

{
  "full_name": "Alice Smith",
  "avatar": "/avatars/alice.png",
  "notification_channels": ["pushover_ops", "matrix_alerts"],
  "password": { "current": "oldpass", "new": "newpass" }
}

All fields are optional. password change requires current to match; server re-hashes with PBKDF2-HMAC-SHA256 before writing. Both full_name/avatar/notification_channels and password can be sent in one request or separately.


Settings Page Changes (/settings)

Section split

Section Edit mode Notes
Server settings Form Scalar fields: ports, intervals, base_url, grace, renotify interval, log/pid/pickle paths, journal settings
Users Form CRUD list: add/edit/delete users; fields: username, full_name, avatar, admin toggle, notification_channels multiselect. Password field: leave blank to keep existing hash; enter a new plain-text password to replace it (server hashes before writing). New users require a password.
OAuth providers Form CRUD list: add/edit/delete providers; fields: name (slug), type, url, client_id, client_secret, label, logo
Notification channels YAML editor Too many provider-specific credential shapes for typed forms
Thresholds YAML editor Complex nested rules
Hosts YAML editor Complex per-host config
DNS / DynDNS YAML editor nsupdate settings, dyndomains, drophosts

Publish flow

  1. Each section has a "Stage changes" button. Clicking it stores that section's current form/editor values in browser JS state. A banner appears: "N pending changes — not yet saved to .hb.yaml".
  2. "Publish to .hb.yaml" sends POST /api/0/config with all staged sections.
  3. On success: banner clears, page reloads to show current saved state.
  4. "Discard all" clears JS state and reloads from server without writing.

Rollback UI

A "View backups / rollback" link at the bottom of the settings sidebar opens a modal listing available backups (timestamp + approximate age). Clicking a backup shows a confirmation prompt before calling POST /api/0/config/rollback.

settings.py changes

  • Set "editable": True on all fields that now have form inputs.
  • The existing field descriptor structure (key, type, label, value, sensitive) is already designed for this — no structural changes needed.
  • Add "section_mode": "form" | "yaml" per section, used by the template to render the appropriate editor.

Profile Page Changes (/profile)

New editable fields alongside the existing read-only display:

Identity card (saves via PUT /api/0/users/me):

  • Display name — text input, current full_name
  • Avatar — text input, current avatar URL or path
  • Save button → immediate write, no publish step

Change password (saves via PUT /api/0/users/me):

  • Current password, new password inputs
  • Save button → validates current password server-side, re-hashes new password, writes

Notification channels (saves via PUT /api/0/users/me):

  • Checkbox list of all globally-defined channels (from config["notification_channels"])
  • Shows channel type and min_level as secondary text
  • Pre-checked based on user's current notification_channels list
  • Save button → writes user's channel list immediately

Host access list remains read-only (existing behaviour).


Write Safety

  • configio._write_lock serializes all writes (admin publish and user self-service can race if multiple requests arrive simultaneously).
  • All writes are atomic: temp file written in same directory as .hb.yaml, then os.replace(). A crash mid-write leaves the backup intact and the original file unchanged.
  • If .hb.yaml cannot be written (permissions, disk full), the API returns 500 with an error message; no partial write occurs.

Secrets Handling

  • GET /api/0/config masks sensitive fields (passwords, tokens, API keys) with "•••" — same logic as the existing read-only settings page.
  • GET /api/0/config/section/{name} for YAML-editor sections returns the raw YAML text including real credential values, since the admin needs to edit them. This endpoint requires admin auth and must only be served over HTTPS in production.
  • Secrets in backups are unmasked (they are copies of the real file). Backup directory should have the same file permissions as .hb.yaml itself.

Out of Scope

  • Conflict detection if .hb.yaml is modified externally between page load and publish (the last write wins; the previous state is always recoverable from a backup)
  • Multi-admin concurrent edit awareness
  • Config validation UI beyond what the server returns as errors
  • Diff view before publish
  • Audit log of who published what (beyond the event log entry already added for login/logout)
  • Per-host threshold editing via UI (thresholds section uses YAML editor)