Skip to content

feat(f5): dashboard backend — read-core, LISTEN/NOTIFY push, WS + scope authz - #18

Merged
patricznr1 merged 2 commits into
mainfrom
feat/f5-dashboard-backend
Jun 16, 2026
Merged

feat(f5): dashboard backend — read-core, LISTEN/NOTIFY push, WS + scope authz#18
patricznr1 merged 2 commits into
mainfrom
feat/f5-dashboard-backend

Conversation

@patricznr1

@patricznr1 patricznr1 commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Backend-Fundament des Dashboards (F5) — Live-Push + Erstbild, Frontend folgt separat. Designgrundlage: docs/research/FOREMAN_Designstudie_Frontend.md §5.1.

Was

  • Geteilter, transport-neutraler Read-Core (src/foreman/reads/): queries/status/overview/trend. mcp/reads.py + _compose_status dorthin verschoben (Rename), MCP ruft den Core auf — keine Duplikation, F7 unverändert.
  • Live-Push über Postgres LISTEN/NOTIFY (src/foreman/realtime/): NOTIFY-Producer (ein pg_notify pro Commit/Batch, transaktional, dünner ID-Payload), LISTEN-Consumer + In-Process-Hub pro Worker (debounce→load, Reconnect→Snapshot-Reload), ein gemultiplexter WS-Endpoint /api/v1/ws.
  • Abo-Autorisierung (realtime/authz.py): default-deny, Rollenmatrix 3.1 (worker→seine Maschinen, shift_lead→seine Linien, manager/technician unrestricted, overview nur manager/shift_lead). Gleiche Prüfung auf WS UND HTTP — der PII-Strich hält auf beiden Transporten.
  • HTTP-Read-Routen: GET /api/v1/overview + GET /api/v1/machines/{id}/trend.
  • Migration 0008: users.assigned_line_ids / assigned_machine_ids (bigint[]) als Scope-Quelle.

Die vier Bau-Vorgaben (erfüllt + getestet)

  1. CAGG-Aktualität — Trend liest readings_1m (real-time aggregation); Test beweist, der frische Bucket erscheint ohne Refresh.
  2. Abo-Autorisierung (nicht nur Auth) — default-deny, WS + HTTP identisch.
  3. Multi-Worker — LISTEN + Hub pro Worker, kein Singleton.
  4. Ein NOTIFY pro Commit → debounce → load (nicht pro Zeile, nicht load→debounce).

Adversariales Review (vor diesem PR) — 2 Defekte gefunden + gefixt

  • 🔴 WS-Push re-autorisierte nie: User-Objekt beim Connect eingefroren → entzogener Scope hätte PII bis Reconnect weitergestreamt. Fix: nur user_id führen, pro Push frisch laden + re-autorisieren (Regressions-Test).
  • 🟡 _push_loop ohne Fehlerabfang: transiente Exception → stiller Zombie + Subscription-Leak. Fix: fehler-robuster Loop.

Verifikation

mypy --strict clean (121 Dateien) · ruff + ruff format clean · 439 Tests grün (F-PRED-shap/lightgbm lokal ausgelassen → Py-3.13-Segfault, CI-Linux verifiziert).

Bewusst offen

Das dynamische F4-Eigenprofil-Overlay im Trend (Feld profile_band reserviert/null; statisches Normalband steht) — braucht den gegateten F4-Replay, daher eigener Schritt.

GROUND_TRUTH §4/§5/§20 + WALKTHROUGH (F5) sind nachgezogen.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Dashboard-Backend mit Live-Push-Funktionalität eingeführt (Read-only), inkl. Flotten-Übersicht und Maschinen-Trend.
    • Neue HTTP-Endpunkte für Übersicht und Trend bereitgestellt.
    • WebSocket-Funktion für Echtzeit-Updates mit Snapshot + Live-Deltas.
    • Rollen- und Scope-basierte Zugriffskontrolle für Dashboard und Live-Push.
  • Database
    • Benutzerschema um Scope-Zuordnungen für Lines und Maschinen erweitert (Migration).
  • Documentation / Tests
    • Erweiterte Spezifikations- und Walkthrough-Dokumentation; umfassende Integration-/Unit-Tests ergänzt.

…, WS-Endpoint + Scope-Autorisierung

- reads/: transport-neutraler Read-Core (queries/status/overview/trend); MCP umgestellt, mcp/reads.py entfernt (keine Duplikation)
- realtime/: NOTIFY-Producer (ein NOTIFY/Commit, transaktional), Hub + LISTEN pro Worker (debounce->load, Reconnect-Snapshot), gemultiplexter WS-Endpoint, Abo-Autorisierung (default-deny, Rollenmatrix 3.1 + Per-User-Scope)
- HTTP-Read-Routen /api/v1/overview + /machines/{id}/trend (gleiche Autorisierung wie WS)
- Migration 0008: users.assigned_line_ids/assigned_machine_ids (Scope-Quelle)
- Review-Fixes: WS-Push laedt Nutzer pro Push frisch + re-autorisiert (entzogener Scope greift sofort, kein PII-Weiterstrom); _push_loop fehler-robust (kein Zombie/Leak)
- GROUND_TRUTH §4/§5/§20 + WALKTHROUGH (F5) nachgezogen
- Vorgaben 1-4 erfuellt; mypy --strict + ruff clean; 439 Tests gruen (F-PRED CI-only)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c7662568-23c6-442a-aec3-801f52794e61

📥 Commits

Reviewing files that changed from the base of the PR and between bf944ae and 4dc43d4.

📒 Files selected for processing (4)
  • src/foreman/realtime/channels.py
  • src/foreman/realtime/ws.py
  • tests/integration/test_realtime_ws.py
  • tests/unit/test_realtime_channels.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/foreman/realtime/channels.py
  • tests/integration/test_realtime_ws.py
  • tests/unit/test_realtime_channels.py
  • src/foreman/realtime/ws.py

📝 Walkthrough

Walkthrough

PR führt vollständig die F5-Dashboard-/Live-Push-Schicht ein: eine transportneutrale Read-Core-Bibliothek (foreman/reads/) für Status, Überblick und Trend-Aggregation, eine Postgres-LISTEN/NOTIFY-Pipeline (ChangeSet-Verträge, transaktionale Benachrichtigungen, In-Worker-Hub mit Debounce, asyncpg-Listener mit Reconnect), einen gemultiplexten WebSocket-Endpoint mit Default-Deny-Autorisierung und Scope-basierter Rollenlogik, zwei HTTP-Dashboard-Read-Routen, eine User-Tabellen-Erweiterung um Scope-Spalten, Pydantic-Transport-Schemas und umfassende Unit- plus Integrations-Test-Coverage.

Changes

F5 Dashboard & Live-Push Backend

Layer / Datei(en) Zusammenfassung
DB-Schema: User-Scope-Spalten und Migration
src/foreman/db/models.py, migrations/versions/0008_user_subscription_scope.py, GROUND_TRUTH.md
User-Tabelle erhält assigned_line_ids und assigned_machine_ids als nicht-nullable bigint[] mit leerem Array als server_default (= default-deny für Restricted Rollen). Alembic-Migration 0008 implementiert upgrade/downgrade mit Scope-Semantik-Kopfkommentar.
Read-Core: Status, Overview und Trend
src/foreman/reads/__init__.py, src/foreman/reads/status.py, src/foreman/reads/overview.py, src/foreman/reads/trend.py, src/foreman/reads/queries.py
Neues foreman/reads/-Paket als transport-neutrale Read-Schicht. status.py definiert kanonischen MachineStatus-Typ und compose_status-Aggregation (Drift Active / Open Warning / Healthy). overview.py implementiert build_fleet_overview mit Maschinen-Laden, Alarm-Aggregation und Fleet-Rollup. trend.py implementiert build_trend und build_trend_by_id mit Metadaten und Zeitreihen-Buckets. Queries-Header wird auf Read-Core-Rolle umgestellt.
MCP auf Read-Core umstellen
src/foreman/mcp/schemas.py, src/foreman/mcp/tools.py
MachineStatus wird von lokalem Literal zu Import aus foreman.reads.status. _compose_status-Helper wird entfernt, compose_status wird importiert und in list_machines/get_machine genutzt.
Pydantic-Transport-Schemas für Dashboard
src/foreman/schemas/dashboard.py
Neue Modelle MachineStatusOut, FleetOverviewOut, TrendPointOut, MachineTrendOut mit from_attributes=True zur direkten Konvertierung aus Read-Core-Dataclasses. Reservierter profile_band-Slot in MachineTrendOut für Zukunft (aktuell None).
Realtime-Verträge: ChangeSet und Encoding
src/foreman/realtime/__init__.py, src/foreman/realtime/channels.py, src/foreman/realtime/topics.py
ChangeSet-Dataclass mit machines, data_points, kinds, broad; encode_change/decode_change mit JSON-Schema-Versionierung, Payload-Größen-Schutz (Fallback auf broad=True). Topic-Modell: OVERVIEW_TOPIC, machine_topic(id), trend_topic(id), Parser und topics_for_change für ChangeSet→Topics-Mapping.
Default-Deny-Autorisierung und Scope-Filterung
src/foreman/realtime/authz.py
Rollen-Konstanten (ROLE_WORKER, ROLE_SHIFT_LEAD, ROLE_TECHNICIAN, ROLE_MANAGER). can_subscribe(session, user, topic) prüft Topic-Art und erzwingt Scope-Regeln (overview nur Manager/Shift Lead, machine/trend mit Sichtbarkeitsprüfung pro Rolle). overview_scope filtert machine_ids nach assigned_line_ids oder None für Manager.
Transaktionales NOTIFY und Ingestion-Tracking
src/foreman/realtime/notify.py, src/foreman/ingestion/service.py, src/foreman/api/routers/readings.py
notify_changes(session, change) setzt per pg_notify auf DASHBOARD_CHANNEL. IngestionService akkumuliert _changed_machines, _changed_data_points, _changed_kinds und feuert konsolidiert vor jedem Commit und nach Final-Flush. Readings-Endpoint sendet nach nicht-leeren Batches ein NOTIFY mit ChangeSet(kind="reading").
Hub-Debounce und LISTEN-Konsumer-Schleife
src/foreman/realtime/hub.py, src/foreman/realtime/listener.py
DashboardHub mit Debounce-Timer: Dispatches koaleszieren über debounce_seconds, _flush() markiert betroffene Topics dirty für alle Subscriptions. DashboardListener baut asyncpg-LISTEN-Verbindung auf, decoded/dispatched Payloads, sendet broad=True beim Connect als Snapshot-Reload und reconnectet automatisch bei Verlust.
Wiring und App-Lifecycle-Integration
src/foreman/realtime/wiring.py, src/foreman/main.py
dsn_for_listen transformiert SQLAlchemy-URL auf asyncpg-Format. get_hub lädt Hub lazy aus app.state. start_dashboard_push/stop_dashboard_push orchestrieren Lifecycle. Routers dashboard und dashboard_ws werden in /api/v1 eingehängt, Lifespan-Start/Stop ergänzt.
HTTP-Dashboard-Routen mit Authz
src/foreman/api/routers/dashboard.py
GET /api/v1/overview und GET /api/v1/machines/{machine_id}/trend mit Scope-Authz (can_subscribe) und Datenladung aus Read-Core. Overview wendet overview_scope-Filter an. Trend clampt hours auf [1, 168], berechnet UTC-Zeitfenster, gibt 404 bei fehlendem Datenpunkt.
Gemultiplexter WebSocket-Endpoint
src/foreman/realtime/ws.py
/ws-Endpoint mit Query-Token-Auth vor accept(). Subscribe/Unsubscribe-Handler mit frischer Authz pro Topic. Push-Loop wartet auf dirty-Topics aus Hub, sendet re-autorisiert Payload oder Error-Frame. _load_topic löst Topics via parse_topic auf und ruft Read-Core-Funktionen auf.
Unit-Tests
tests/unit/test_realtime_topics.py, tests/unit/test_realtime_channels.py, tests/unit/test_realtime_hub.py, tests/unit/test_dashboard_schemas.py
Topic-String-Generierung/Parsing, ChangeSet-Roundtrip/Overflow/Versionsfallback, Hub-Routing/Debounce/Koaleszierung, Pydantic-Schema-Serialisierung (datetime ISO, null-Felder, profile_band).
Integrationstests für Read-Core
tests/integration/test_reads_overview.py, tests/integration/test_reads_trend.py
Fleet-Overview-Aggregation (Statusberechnung, Alarme nach Severity, Rollups, Scope-Filter), Trend-Laden mit Metadaten und aktuellen Zeitreihen-Buckets. Spezialfälle: DRIFT-Alarm, acknowledged-Flag, last_alarm_at.
Integrationstests für Realtime
tests/integration/test_realtime_authz.py, tests/integration/test_realtime_notify.py, tests/integration/test_realtime_listener.py, tests/integration/test_realtime_wiring.py
Default-Deny-Authz pro Rolle (manager/technician/worker/shift_lead), NOTIFY-Produktion über HTTP und Ingestion-Pfade mit ChangeSet-Validierung, LISTEN-Schleife und Reconnect, Hub-Idempotenz, Listener-Start/Stop.
WebSocket E2E-Tests
tests/integration/test_realtime_ws.py
Token-Validierung, Snapshot-Laden bei Subscribe, Live-Push nach NOTIFY, Fehler-Frames bei fehlender Authz, Token-Subject-Validierung.
Dashboard-Routen-Tests
tests/integration/test_dashboard_routes.py
Authz-Grenzen für Overview und Trend, Antwortkörper-Struktur, Metadata-Mitführung, 404-Behandlung.
Spezifikation und Dokumentation
GROUND_TRUTH.md, docs/WALKTHROUGH.md
F5-Architektur-Sektion mit HTTP/WS-Routen, Themenmodell, LISTEN/NOTIFY, Hub-Debounce, Authz-Regeln, Testpflichten. Walkthrough-Abschnitt zu Read-Core-Schichten, Change-Produktion, Listener-Hub, WS-Endpoint und HTTP-Pull-Fallback.

Sequence Diagram(s)

sequenceDiagram
  participant Ingestion as IngestionService<br/>& Readings
  participant notify_changes as notify_changes<br/>(NOTIFY)
  participant PostgreSQL
  participant Listener as DashboardListener<br/>(asyncpg)
  participant Hub as DashboardHub<br/>(Debounce)
  participant WebSocket as dashboard_ws<br/>(WS Endpoint)
  participant Client

  Client->>WebSocket: WS connect<br/>?token=<jwt>
  WebSocket->>WebSocket: _authenticate()<br/>→ User laden
  WebSocket->>Client: accept()
  
  Client->>WebSocket: {"action":"subscribe",<br/>"topic":"machine:42"}
  WebSocket->>WebSocket: can_subscribe<br/>(default-deny)
  WebSocket->>Hub: subscribe(sub,"machine:42")
  WebSocket->>WebSocket: _load_topic()<br/>→ ReadCore
  WebSocket->>Client: {"type":"update",...}

  Ingestion->>notify_changes: ChangeSet<br/>(machines={42})
  notify_changes->>PostgreSQL: pg_notify<br/>("foreman_dashboard",...)
  PostgreSQL-->>Listener: NOTIFY-Event
  Listener->>Hub: dispatch(ChangeSet)
  Hub->>Hub: debounce timer<br/>→ _flush()
  Hub->>Hub: mark_dirty<br/>("machine:42")
  Hub-->>WebSocket: queue.get()<br/>→ "machine:42"
  
  WebSocket->>WebSocket: _authorized_payload<br/>(re-check)
  WebSocket->>WebSocket: _load_topic()<br/>→ ReadCore
  WebSocket->>Client: {"type":"update",...}
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐇 Ein Hase lauscht auf Postgres-NOTIFY-Klang,
Ein Hub verteilt die Topics den ganzen Tag lang.
Der Read-Core liest, doch schreibt er nie —
Der WebSocket flüstert: "Frische Daten für dich!" ✨
Default-Deny schützt jede Rollen-Pforte,
Das Dashboard leuchtet nun an jedem Orte. 🚀

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/f5-dashboard-backend

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/foreman/realtime/channels.py`:
- Around line 72-81: The decode_change function does not validate the payload
version field `v`, which means incompatible NOTIFY payloads (such as during
mixed deploy versions) are processed without safe error handling. Add version
validation logic at the start of decode_change (after json.loads and before the
broad check) to verify that the payload version matches the expected version. If
the version field is missing or incompatible, return ChangeSet(broad=True) as a
safe fallback instead of proceeding to parse potentially incompatible machine,
data_point, and kinds fields. This ensures backward compatibility and prevents
silent data loss when payload contracts diverge between deployed versions.

In `@src/foreman/realtime/ws.py`:
- Around line 105-109: The code at line 109 where subject is converted to an
integer using int(subject) lacks error handling for cases where subject contains
non-numeric characters, which would raise an unhandled ValueError exception.
Wrap the int(subject) conversion in a try-except block to catch ValueError, and
when caught, close the websocket connection with the _WS_UNAUTHORIZED code (or
another appropriate code) before returning, similar to how the None case is
already handled just above this line.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4fd6b5e5-59ac-4b4e-b735-ed76c53ca6f8

📥 Commits

Reviewing files that changed from the base of the PR and between 2eebbbe and bf944ae.

📒 Files selected for processing (37)
  • GROUND_TRUTH.md
  • docs/WALKTHROUGH.md
  • migrations/versions/0008_user_subscription_scope.py
  • src/foreman/api/routers/dashboard.py
  • src/foreman/api/routers/readings.py
  • src/foreman/db/models.py
  • src/foreman/ingestion/service.py
  • src/foreman/main.py
  • src/foreman/mcp/schemas.py
  • src/foreman/mcp/tools.py
  • src/foreman/reads/__init__.py
  • src/foreman/reads/overview.py
  • src/foreman/reads/queries.py
  • src/foreman/reads/status.py
  • src/foreman/reads/trend.py
  • src/foreman/realtime/__init__.py
  • src/foreman/realtime/authz.py
  • src/foreman/realtime/channels.py
  • src/foreman/realtime/hub.py
  • src/foreman/realtime/listener.py
  • src/foreman/realtime/notify.py
  • src/foreman/realtime/topics.py
  • src/foreman/realtime/wiring.py
  • src/foreman/realtime/ws.py
  • src/foreman/schemas/dashboard.py
  • tests/integration/test_dashboard_routes.py
  • tests/integration/test_reads_overview.py
  • tests/integration/test_reads_trend.py
  • tests/integration/test_realtime_authz.py
  • tests/integration/test_realtime_listener.py
  • tests/integration/test_realtime_notify.py
  • tests/integration/test_realtime_wiring.py
  • tests/integration/test_realtime_ws.py
  • tests/unit/test_dashboard_schemas.py
  • tests/unit/test_realtime_channels.py
  • tests/unit/test_realtime_hub.py
  • tests/unit/test_realtime_topics.py

Comment thread src/foreman/realtime/channels.py
Comment thread src/foreman/realtime/ws.py Outdated
…match; WS-Auth härter gegen nicht-numerisches sub

- channels.decode_change: prüft Payload-Version v; unbekannte/inkompatible Version (gemischter Deploy) → fail-safe broad statt still verlorener IDs
- ws._authenticate: int(subject) in try/except → 4401-Close bei nicht-numerischem sub (manipuliertes Token) statt unbehandelter ValueError
- 2 Regressions-Tests (TDD)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@patricznr1

Copy link
Copy Markdown
Owner Author

CodeRabbit-Findings abgearbeitet (Commit 4dc43d4):

  • 🟠 channels.py decode_change prüft jetzt die Payload-Version v. Ein inkompatibler/unbekannter Payload (z. B. gemischter Deploy) degradiert fail-safe auf broad — der Hub lädt dann alle abonnierten Themen neu, statt IDs still zu verlieren. Passt zum bestehenden Overflow→broad-Muster. Test: test_unknown_payload_version_degrades_to_broad.
  • 🟡 ws.py _authenticate: int(subject) jetzt in try/except — ein nicht-numerisches sub (manipuliertes/fehlerhaftes Token) führt zu einem sauberen 4401-Close statt einer unbehandelten ValueError. Test: test_ws_rejects_token_with_non_integer_subject.

Beide TDD; lokal mypy --strict + ruff clean, betroffene Tests grün.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant