feat: wire MQTT → SSE bridge so the event timeline updates live (closes #1) #6

Merged
alee merged 2 commits from fix/issue-1-sse-bridge into main 2026-04-05 16:55:27 +00:00
Owner

Closes #1. Stacked on top of #5 (test-isolation fixture).

Problem

The Flask event-timeline was dead. broadcast_sse_event existed in vigilar/web/blueprints/events.py but had zero call sites. Clients subscribed to /events/stream, received the initial `{"type": "connected"}` message, and then only keepalives — a page refresh was required to see new events. (Web Push via VAPID was independent and already worked.)

Root cause: a process-boundary gap. The events subsystem runs in its own OS process and emits classified events to MQTT. The Flask app runs in a separate process with no MQTT client of its own, so the classified events never reached any SSE queue.

Approach

Thin bridge, classification stays in one place:

  1. EventProcessor._handle_event now publishes a classified summary (id, ts, type, severity, source_id, payload) to a new topic Topics.EVENTS_PUBLISHED = "vigilar/events/published" immediately after insert_event().
  2. New module vigilar/web/sse_bridge.py provides:
    • forward_event(topic, payload) — the MQTT handler that calls broadcast_sse_event(payload).
    • start_sse_bridge(cfg) — creates a MessageBus, subscribes forward_event to EVENTS_PUBLISHED, connects, returns the bus.
  3. vigilar/main.py:_run_web starts the bridge after create_app(cfg) and disconnects it on shutdown. Bridge failure is logged but does not kill the web process — the UI still works without live updates.

Design choices

  • create_app is deliberately NOT changed. Keeping the bridge out of the app factory means no existing test triggers a real MQTT connection, and the bridge stays a production-only concern wired by the supervisor process.
  • Payload format matches what the frontend already expects (app.js:74-99 reads type, source_id, ts, severity off the SSE data) so no client-side changes are needed.
  • Graceful degradation: if start_sse_bridge raises, the exception is logged and the web server still boots.

Tests (TDD, RED verified before GREEN)

  • tests/unit/test_events.py::TestEventsPublishedBroadcast
    • test_handle_event_publishes_classified_payload — motion event goes to EVENTS_PUBLISHED with the right classified fields.
    • test_unclassified_topic_does_not_publish — heartbeats are NOT forwarded (regression guard for the event_type is None early return).
  • tests/unit/test_sse_bridge.py
    • test_forward_event_delivers_payload_to_sse_subscribers — forwarding reaches the real SSE queue layer via broadcast_sse_event.
    • test_start_sse_bridge_subscribes_to_events_published — verified against a fake MessageBus, no real MQTT.

Full suite: 364 passed (was 360; +2 processor tests, +2 bridge tests).

Docs

Updates docs/operator-guide.md (drops the "event timeline is not live" known limitation) and docs/architecture/subsystems/web.md (describes the new bridge).

Stacking

This PR is stacked on fix/issue-3-test-isolation. Merge #5 first; this one will auto-rebase onto main.

Closes #1. Stacked on top of #5 (test-isolation fixture). ## Problem The Flask event-timeline was dead. `broadcast_sse_event` existed in `vigilar/web/blueprints/events.py` but had **zero call sites**. Clients subscribed to `/events/stream`, received the initial \`{"type": "connected"}\` message, and then only keepalives — a page refresh was required to see new events. (Web Push via VAPID was independent and already worked.) Root cause: a process-boundary gap. The events subsystem runs in its own OS process and emits classified events to MQTT. The Flask app runs in a separate process with no MQTT client of its own, so the classified events never reached any SSE queue. ## Approach Thin bridge, classification stays in one place: 1. `EventProcessor._handle_event` now publishes a classified summary (`id`, `ts`, `type`, `severity`, `source_id`, `payload`) to a new topic `Topics.EVENTS_PUBLISHED = "vigilar/events/published"` immediately after `insert_event()`. 2. New module `vigilar/web/sse_bridge.py` provides: - `forward_event(topic, payload)` — the MQTT handler that calls `broadcast_sse_event(payload)`. - `start_sse_bridge(cfg)` — creates a `MessageBus`, subscribes `forward_event` to `EVENTS_PUBLISHED`, connects, returns the bus. 3. `vigilar/main.py:_run_web` starts the bridge after `create_app(cfg)` and disconnects it on shutdown. Bridge failure is logged but does not kill the web process — the UI still works without live updates. ## Design choices - **`create_app` is deliberately NOT changed.** Keeping the bridge out of the app factory means no existing test triggers a real MQTT connection, and the bridge stays a production-only concern wired by the supervisor process. - **Payload format matches what the frontend already expects** (`app.js:74-99` reads `type`, `source_id`, `ts`, `severity` off the SSE `data`) so no client-side changes are needed. - **Graceful degradation:** if `start_sse_bridge` raises, the exception is logged and the web server still boots. ## Tests (TDD, RED verified before GREEN) - `tests/unit/test_events.py::TestEventsPublishedBroadcast` - `test_handle_event_publishes_classified_payload` — motion event goes to `EVENTS_PUBLISHED` with the right classified fields. - `test_unclassified_topic_does_not_publish` — heartbeats are NOT forwarded (regression guard for the `event_type is None` early return). - `tests/unit/test_sse_bridge.py` - `test_forward_event_delivers_payload_to_sse_subscribers` — forwarding reaches the real SSE queue layer via `broadcast_sse_event`. - `test_start_sse_bridge_subscribes_to_events_published` — verified against a fake `MessageBus`, no real MQTT. Full suite: **364 passed** (was 360; +2 processor tests, +2 bridge tests). ## Docs Updates `docs/operator-guide.md` (drops the "event timeline is not live" known limitation) and `docs/architecture/subsystems/web.md` (describes the new bridge). ## Stacking This PR is stacked on `fix/issue-3-test-isolation`. Merge #5 first; this one will auto-rebase onto main.
alee added 2 commits 2026-04-05 15:07:47 +00:00
Some web endpoint handlers call _save_and_reload(), which resolves the
target path via VIGILAR_CONFIG env var with a fallback to the relative
"config/vigilar.toml". Any test exercising such an endpoint without
setting the env var rewrites the repo's committed config file via a
Pydantic model_dump round-trip, stripping comments and non-default
fields. The culprit discovered was test_reset_pin_correct_passphrase
in tests/unit/test_system_pin.py.

Add an autouse session-scoped fixture in tests/conftest.py that points
VIGILAR_CONFIG at a path inside pytest's session tmp dir so no test
can touch the real file. Restore the previous env var value on teardown.

Fixes #3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes #1.

The Flask event-timeline was dead: `broadcast_sse_event` existed in
`vigilar/web/blueprints/events.py` but had zero call sites. Clients
subscribed to `/events/stream`, received the initial "connected"
message, and then only keepalives — a page refresh was required to
see new events. (Web Push via VAPID was independent and already worked.)

The root cause was a process-boundary gap: the events subsystem runs
in its own OS process and emits to MQTT, while the Flask app runs in a
separate process with no MQTT client of its own.

This change adds a thin bridge:

- EventProcessor._handle_event now publishes a classified summary
  (id, ts, type, severity, source_id, payload) to a new topic
  `Topics.EVENTS_PUBLISHED = "vigilar/events/published"` right after
  `insert_event()`. Classification logic stays in one place.

- A new module `vigilar/web/sse_bridge.py` provides `forward_event`
  (MQTT handler) and `start_sse_bridge(cfg)` (creates a MessageBus,
  subscribes forward_event to EVENTS_PUBLISHED, connects, returns the
  bus).

- `vigilar/main.py:_run_web` starts the bridge after `create_app(cfg)`
  and disconnects it on shutdown. Bridge failure is logged but does
  not kill the web process — the UI still works without live updates.

- `create_app` is deliberately NOT changed. Keeping the bridge out of
  the app factory means no existing test triggers a real MQTT
  connection, and the bridge stays a production-only concern wired by
  the supervisor.

Tests (all added with TDD, RED verified before GREEN):

- tests/unit/test_events.py::TestEventsPublishedBroadcast — asserts
  `_handle_event` publishes the classified payload for a motion event
  and does NOT publish for unclassified topics (heartbeats).
- tests/unit/test_sse_bridge.py — asserts `forward_event` reaches SSE
  subscribers, and `start_sse_bridge` wires the handler to
  `Topics.EVENTS_PUBLISHED` on a connected bus (fake bus, no real
  MQTT in tests).

Also refreshes the docs that previously flagged the dead SSE as a
known limitation (operator guide, web architecture doc).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
alee merged commit 09b59e3bb5 into main 2026-04-05 16:55:27 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: alee/vigilar#6