pgbus 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +55 -9
  4. data/Rakefile +26 -0
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +37 -4
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
  8. data/app/controllers/pgbus/frontends_controller.rb +25 -11
  9. data/app/frontend/pgbus/application.min.js +2 -0
  10. data/app/frontend/pgbus/application.min.js.map +1 -0
  11. data/app/frontend/pgbus/modules/charts.js +24 -6
  12. data/app/frontend/pgbus/modules/charts.min.js +2 -0
  13. data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
  14. data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
  15. data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
  16. data/app/views/layouts/pgbus/application.html.erb +0 -1
  17. data/config/routes.rb +1 -1
  18. data/lib/generators/pgbus/add_presence_generator.rb +2 -1
  19. data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
  20. data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
  21. data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
  22. data/lib/pgbus/configuration/presence_settings.rb +103 -0
  23. data/lib/pgbus/configuration/stream_authorization.rb +36 -0
  24. data/lib/pgbus/configuration.rb +16 -1
  25. data/lib/pgbus/engine.rb +13 -7
  26. data/lib/pgbus/generators/migration_detector.rb +9 -1
  27. data/lib/pgbus/streams/envelope.rb +3 -2
  28. data/lib/pgbus/streams/presence.rb +317 -81
  29. data/lib/pgbus/version.rb +1 -1
  30. data/lib/pgbus/web/stream_app.rb +43 -14
  31. data/lib/pgbus/web/streamer/connection.rb +26 -9
  32. data/lib/pgbus/web/streamer/falcon_connection.rb +23 -9
  33. data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
  34. data/lib/pgbus/web/streamer/instance.rb +25 -8
  35. data/lib/pgbus/web/streamer/peer_close_watcher.rb +183 -0
  36. data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
  37. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +26 -39
  38. data/lib/pgbus.rb +2 -0
  39. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4fd9db0886380c5daee4231c3b3ae18740de1d75b7e763d159e0a55511e40fda
4
- data.tar.gz: 77e1ce68911f70dc2aa2d515cb1f621932d49942acda00672c7c3c8418c7a0a8
3
+ metadata.gz: 5ecd5c93d6e4cd514d0733f95f5f064d84d16393e104047e4ce4920c69926939
4
+ data.tar.gz: ef893f0ded94410bb8759ff30486c7fbd2ea0abe2ad156b47b96c4e600f31e18
5
5
  SHA512:
6
- metadata.gz: 4ad2af9f7cdb993bf5f6577e2ab94cd4f3df8833e813b32e75751e694e0d7bb188ac49fd3ae11fdb46c9070a35b674579f756472519dae46cda8efc55f40afea
7
- data.tar.gz: c9fd9452c8bff020cc581bb2fcd5e3cf1980d6c51b1ddf694b2bc5aeb20b29552c678b3ce3deb85f24b994b0a35e6cceba9b5cb15cb480d72bdd3b49f0a83afa
6
+ metadata.gz: 04d7ddc5cfa15881f2911ed7a88f559ed8b037eaf734ae7cee41cdde4ebca917e537a84f758b439477827e5824d8eb311fe1d373c4c511a859cae01a0bd3faba
7
+ data.tar.gz: 339b3a25781d0342b0414022cabe303c9f5b45332ee1b4d4cb83cf55c3635bec0a80cd528c794c6425eb0b58db7ccbe5a5a49a95854ba72aa41576f1619695df
data/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ### Added
4
4
 
5
+ - **`config.streams_authorize`: the engine's stream endpoint can supply a connection context.** The engine mounts `Pgbus::Web::StreamApp.new` with no `authorize:` hook, so every connection was authorized with no context. Connection-driven presence (`streams_presence_patterns`) then joined nobody, and `visible_to:` predicates that need a context (they receive `nil`, so `user&.admin?` is falsy) hid every broadcast, unless the app mounted its own `StreamApp`. `config.streams_authorize = ->(env, stream_name) { … }` takes the same contract as `StreamApp.new(authorize:)`: a falsy return answers 403, `true` authorizes with no context, any other value (the current user, say) becomes the connection context. It is read per request, so the order of initializers and route loading doesn't matter. An explicit `StreamApp.new(authorize:)` wins; any `StreamApp` without one, the engine's included, uses the configured hook, and nil (the default) keeps today's behaviour. `validate!` rejects a non-callable. Closes #522.
6
+
7
+ - **Streams presence keeps one row per tab, with per-tab state, a change hook and automatic expiry (experimental).** Connection-driven presence (`streams_presence_patterns`) kept one row per member, so a user with two tabs open vanished from the room when either tab closed. On a migrated table it now keeps one row per SSE connection: `pgbus_presence_members` gains `connection_id`, `seat` (the client's per-tab id) and `state jsonb`. `<pgbus-stream-source presence>` (`pgbus_stream_from @room, presence: true`) mints one seat per document and sends it as `?seat=`; the stream app stores it on the connection and the dispatcher joins with `connection_id:` and `seat:`. New API: `Presence#join(member_id:, connection_id:, seat:, metadata:)`, `#leave(connection_id:)`, `#touch(connection_id:)` (one statement per stream, returning the ids it found), `#set_state(connection_id:, state:)` (merges into that row) and `.seats_supported?`; `#members` rows carry `connection_id`, `seat` and `state`. Member-keyed calls keep working on their own row. New settings: `streams_presence_on_change = ->(stream_name, change) { }`, called after each seat join, leave and expiry with `{ type:, connection_id:, seat:, member_id: }` (errors logged and swallowed), and `streams_presence_ttl` (seconds, or a pattern => seconds Hash with `:default`; 60 by default). The heartbeat touches live seats each tick, re-joins a live connection whose row was expired, and at most every ttl / 2 expires silent seats across all streams, including rows of crashed processes, reporting each as a `:leave`. On a seat table `sweep!` only expires member-keyed rows, and pgbus warns at boot when a ttl does not exceed `streams_heartbeat_interval`. **Upgrade:** `rails generate pgbus:add_presence_seats` (queued by `pgbus:update`; fresh `pgbus:add_presence` tables already have the columns). It replaces the `(stream_name, member_id)` unique index with partial unique indexes on `(stream_name, member_id) WHERE connection_id IS NULL` and `(stream_name, connection_id) WHERE connection_id IS NOT NULL`, and is reversible. Until it runs, presence behaves as before; a running process notices the migrated table within 30 seconds (at once on a member-keyed `join` outside a transaction). Closes #515.
8
+
5
9
  - **The home page and Insights explain themselves.** The home page (`/pgbus`) showed numbers without words ("Failed / DLQ 5 / 3", a Throughput card that was always `0.0` on the web, Queue Health percentages coloured against thresholds it never stated). It now opens with a health verdict strip — the OK / DEGRADED / STALLED verdict and reasons `/readyz` and the MCP compute (`HealthAnalyzer`), with links to the page that explains them. Eight cards each say what their number means and link to the page that explains it: Queues (paused, dead-letter, holding jobs nothing drains), Enqueued (claimable now vs scheduled, running or retrying), Processes (healthy · stalled · stale), Recurring, Retrying (the Jobs page's Retrying count, when the next attempt runs, how many are due now, the newest error), Dead-lettered (the newest message's reason, read as the DLQ page reads it), Throughput (jobs/min over the last 5 minutes from job stats, or "needs job stats") and Parked. The queues table gains a Status column in words (the queue page's summary line: paused and why, nothing drains it, what is waiting). Queue Health states each threshold ("12% on pgmq.q_pgbus_events — vacuum due (warns above 10%, critical above 20%)"; the MVCC card names both its own 30 s / 60 s warnings and the verdict's 300 s) and its table always renders, with an empty state. Recent failures show the job and attempt and link to the failure. New `Web::DataSource#dashboard_signals` and `#queue_status_lines` (in `DataSource::DashboardSignals`) and the pure presenters `Web::HealthVerdict` and `Web::InsightsCharts`; `summary_stats`, `/pgbus/api/stats`, `/pgbus/api/metrics` and the AppSignal probe are unchanged. On Insights each chart has a caption sentence (also the chart's accessible name) and a `<details>` data table rendered on the server (at most 60 rows: long ranges roll minutes up), empty series show an empty state, and the page draws from series it embeds instead of fetching `/pgbus/api/insights` a second time (that endpoint is unchanged). Closes #492.
6
10
 
7
11
  - **The Processes page says what each process is doing, why its status is what it is, and what happens next.** `/pgbus/processes` showed kind · host · pid · a Healthy/Stalled/Stale badge · "45s ago" · a dump of raw metadata chips; the thresholds behind the badge lived only in Ruby, and nothing said what the process was doing or what would happen to it. Each row now has Process · Status · Activity · Recycle · Heartbeat cells: the Status cell says why ("Loop last ticked 2m ago (limit 1m 30s)", "No heartbeat for 7m 0s (limit 5m 0s)", "Heartbeat 5s ago (limit 5m 0s) · stall detection off") and what happens next ("the supervisor's watchdog restarts it", "no supervisor to restart it", "probably dead; removed at the dispatcher's next sweep (within 5m 0s)", "no live dispatcher to remove it", or what stops advancing when the dispatcher or scheduler stalls); Activity says what it drains or consumes, busy threads (or async slots), buffered claims, jobs/s and failures/s, what a dispatcher, scheduler or outbox poller does, and what a supervisor supervises; Recycle states progress against each configured limit ("1,240 / 10,000 jobs · 310 / 512 MB · up 42m 0s / 1h 0m"); Heartbeat adds uptime. Children are listed under their supervisor ("under supervisor 100"), grouped only when that supervisor's row is on the same host, so a standalone `pgbus worker` is never claimed by a stranger. Clicking a process expands its start time and the metadata nothing else renders. The home page's Processes panel puts the same sentence under each badge. Status derivation moved from `DataSource` into the pure presenter `Web::ProcessState` (with `Web::ProcessActivity` for the activity and recycle words), so the badge and its sentence come from the same moment and limits; `DataSource#processes` keeps its row shape and adds `supervisor_pid` and `started_at`. **New heartbeat keys**, written once per 60 s beat and never per job: every process `started_at`; forked kinds `supervisor_pid`; workers and consumers the pool's `capacity`/`busy`, `buffered`, `memory_mb` (an RSS read through `MemoryUsage`'s 5 s cache — a `ps` subprocess per minute on macOS) and the recycle limits the process resolved (a capsule's own when set); consumers gain a metadata supplier with their handled count. No schema change. Because `capacity`/`busy` are now persisted, `pgbus_worker_pool_capacity` and `pgbus_worker_pool_busy` on `/pgbus/api/metrics` emit for the first time. A process from before this release shows "—" where a fact is missing. Closes #493.
@@ -36,6 +40,8 @@
36
40
 
37
41
  ### Changed
38
42
 
43
+ - **perf: pgbus ships minified JavaScript with linked sourcemaps, and loads ApexCharts only on Insights.** The dashboard served readable sources and an unminified vendored Turbo 8.0.12, and loaded ApexCharts (575 KB) on every page. `scripts/build_frontend.js` (bun 1.4.2, run by `bundle exec rake frontend:js`) now writes a committed `<name>.min.js` + `<name>.min.js.map` (with `sourcesContent`) beside the stream element, `application.js`, `modules/charts.js` and `vendor/turbo.js`; Turbo keeps its license banners. The dashboard serves only the `.min.js` modules, and each one's sourcemap at `/pgbus/frontend/modules/<version>/<id>.map`. `charts.js` imports ApexCharts on its first draw through the dashboard importmap, so no other page downloads it, and the global `<script src=apexcharts>` and its `/pgbus/frontend/static/<version>/apexcharts.js` URL are gone. Host apps: the importmap pin for `pgbus/stream_source_element` now resolves to `pgbus/stream_source_element.min.js` (14.3 KB → 4.7 KB), whose `.min.js.map` is precompiled too; `javascript_include_tag "pgbus/stream_source_element"` still serves the readable source. No host app needs bun or Node. Measured with Lighthouse (desktop preset, `rake dummy:server`, main vs this branch on the same machine): `/pgbus` script 773,005 → 99,846 bytes (3 → 2 requests), page weight 870,302 → 197,143 bytes; `/pgbus/insights` script 778,330 → 678,436 bytes, page weight 871,128 → 771,228 bytes. Lighthouse's `unminified-javascript` now passes and is raised from `warn` to `error` in `lighthouserc.dashboard.json`. CI's lint job runs `rake frontend:js_check`, which fails when a committed `.min.js` is stale. Closes #517.
44
+
39
45
  - **The Jobs page leaves event-handler queues to the Events page.** `/pgbus/jobs` without a queue filter no longer lists EventBus handler-queue messages (they appeared on both pages), and its Retry All, Discard All and Discard All Enqueued skip handler queues: Retry All would have re-sent a copy of a failed event whose message still sits in its handler queue, running the handler twice. A handler queue's own queue page (`/pgbus/queues/:name`, `/pgbus/jobs?queue=`) still lists its messages. `DataSource#jobs` and the MCP `jobs` tool are unchanged. Refs #494.
40
46
 
41
47
  - **feat(web): the queue page shows the unified Jobs list and explains the queue in words.** `/pgbus/queues/:name` used to render its own message table: raw `vt`, timestamps and `read_ct` columns, no state, no reason, no pager, and Retry/Discard hidden inside the expansion. It now renders the same list as `/pgbus/jobs` (state tabs with counts, state badge, attempts, reason, row expansion, bulk discard, pager, auto-refresh), scoped to that physical queue. On a priority sub-table (`_pN`) the scope follows `DataSource#job_rows` as it is: blocked jobs of the logical queue appear on every level's page (they are parked before a level is chosen), and a failed event whose message sits in a sibling level can show as "no longer in queue". Tabs, the pager and the auto-refresh source stay on the queue's own URL. Above the list, a summary says why the queue is in its state: paused by an operator (with the reason) or by the circuit breaker (failures, when it resumes, trip number); which worker capsules drain it, or that none does; whether a healthy worker is alive; how many jobs are claimable now, how long the oldest has waited and how many are parked; and, for a `_pN` table, its priority level, with links to the other levels. The header's `|`-separated raw seconds become labelled metrics (`pgbus_duration`). A dead-letter queue page shows its summary and a link to the Dead Letter page instead of a list. "Discard All Enqueued" acts on every queue, so it is hidden on a queue page and on `/pgbus/jobs?queue=…`; Purge Queue covers that case. Retry, Discard, Discard Selected and Discard All Enqueued on the Jobs list now redirect back to the page they were clicked on. New `Web::DataSource#queue_pause_state` / `#queue_drainers` (in `DataSource::QueueSummary`) and the pure presenter `Web::QueueSummary`. `DataSource#jobs`, `#queue_detail`, `#queue_paused?` and `#queue_health_detail` and the MCP tools are unchanged. Closes #491.
@@ -50,6 +56,10 @@
50
56
 
51
57
  ### Fixed
52
58
 
59
+ - **`pgbus:connected` now fires only once the connection is registered and its presence join has run.** `StreamApp` wrote the frame at hijack time, before the streamer's dispatcher had handled the connect, so a page that acted on its connection id as soon as it heard the event — `Pgbus.stream(x).presence.set_state(connection_id:)` from an action, for instance — found no row (`set_state` returned nil) and had to retry. The dispatcher now writes the frame at the end of the connect, after the archive replay, `registry.register` and the presence join attempt (a stream without presence, or a member resolver that returns nil, has no seat to join; a join that raises is logged and the frame still follows), through the connection's mutex-serialised writer (`Connection#write_connected` / `FalconConnection#write_connected`). The event name, its `connectionId` payload and the element's `connection-id` attribute are unchanged; listeners hear it after any replayed frames instead of before them, and a connection that dies during the replay never gets one. A repeated disconnect for an already-unregistered connection no longer records a second `disconnect` stream stat. Closes #521.
60
+
61
+ - **A closed tab leaves its presence seat at once, not on the next heartbeat.** The server never reads a hijacked SSE socket after the request, so a client that went away was noticed only when a heartbeat write failed: up to `streams_heartbeat_interval` later (15 s by default), and often a beat later still, because the first write after the client's FIN succeeds. Every viewer of a presence roster kept the closed tab for that long. A new `PeerCloseWatcher` thread per streamer waits on every live hijacked socket with `IO.select` and probes a readable one with a non-blocking read: EOF or a reset marks the connection dead, closes it and posts the same `DisconnectMessage` the heartbeat posts, so the seat leaves (and `streams_presence_on_change` fires once) within milliseconds on Puma and on Falcon's default hijack path; a connection closed within its first `streams_peer_close_interval` after opening is noticed within that interval. A TLS client that vanishes without close_notify (OpenSSL's or Puma MiniSSL's SSL error on read) counts as closed, the probe skips a socket while a write holds its mutex, and `#stop` wakes the select through a self-pipe so shutdown does not wait out the interval. The client sends nothing after its request, so a readable socket means a close; stray bytes are read and discarded. Falcon's experimental streaming-body path has no socket and keeps the heartbeat. New setting `streams_peer_close_interval` (1.0 s by default): how long a newly opened connection waits to join the watched set; `nil` turns the watcher off. Closes #519.
62
+
53
63
  - **Insights charts are readable in dark mode.** The charts were themed only through `foreColor`: in dark mode the donut kept white slice separators, axis borders and the crosshair label stayed light, and the "No data" fallback was `text-gray-400 dark:text-gray-500` (below WCAG AA, and invisible to the contrast guard because it lived in JS). `charts.js` now themes every chart from one `palette(isDark)` (theme mode, axis and grid colours, donut separators in the card colour, legend and tooltip, animations off under `prefers-reduced-motion`) and redraws on the dark-mode toggle without a reload. The latency headers and chart titles no longer carry "(ms)" / "(jobs/min)"; the P95 latency card's yellow is now AA. The static contrast guard scans the dashboard's JS modules too. Refs #492.
54
64
 
55
65
  - **The dashboard home page reads the process rows and the stream registry once per render.** `DataSource` memoizes the raw process rows, `stream_queue_names` and `queue_health_stats` per instance (cleared by `reset_cache!`, as `queues_with_metrics` already was), so the stats, the verdict and the panels share one query each. `HealthApp` now calls `reset_cache!` on an injected DataSource before every `/readyz` check, so a long-lived one never answers with the first check's facts (it already did that for queues). Refs #492.
data/README.md CHANGED
@@ -1405,6 +1405,8 @@ Swap `turbo_stream_from` for `pgbus_stream_from` in your view:
1405
1405
  <%= pgbus_stream_from @order %>
1406
1406
  ```
1407
1407
 
1408
+ The helper imports the `<pgbus-stream-source>` element as `pgbus/stream_source_element`. With importmap-rails, pgbus pins it for you to a minified build (`pgbus/stream_source_element.min.js`, about 5 KB) whose linked sourcemap carries the readable source, so devtools shows the original file. Propshaft rewrites the `sourceMappingURL` to the digested map; Sprockets serves the comment as written, so there the browser may not find the map (the element works either way). The element is an ES module that imports `@hotwired/turbo-rails` by its bare name, so it needs an importmap or your app's JavaScript bundler; a plain `javascript_include_tag` cannot run it. The readable source stays in the asset path for apps that pin or bundle it themselves. Neither needs bun or Node in your app: the minified files ship in the gem.
1409
+
1408
1410
  Everything else stays the same. The model concern keeps working unchanged:
1409
1411
 
1410
1412
  ```ruby
@@ -1441,6 +1443,7 @@ Pgbus.configure do |c|
1441
1443
  "presence_room" => 30 # 30 seconds for presence
1442
1444
  }
1443
1445
  c.streams_heartbeat_interval = 15 # seconds
1446
+ c.streams_peer_close_interval = 1.0 # notice a closed client at once; nil = heartbeat only
1444
1447
  c.streams_max_connections = 2_000 # per web-server process (Puma worker or Falcon process)
1445
1448
  c.streams_idle_timeout = 3_600 # close idle connections after 1h
1446
1449
  c.streams_listen_health_check_ms = 250 # PG LISTEN keepalive + ensure_listening ack budget
@@ -1615,16 +1618,26 @@ Pgbus::Streams.filters.register(:workspace_member) do |user, stream_name|
1615
1618
  end
1616
1619
  ```
1617
1620
 
1618
- The authorize hook on `Pgbus::Web::StreamApp` doubles as a context provider — return any non-boolean value (typically a `User` model) and pgbus will pass it to the filter predicate when evaluating broadcasts:
1621
+ The authorize hook doubles as a context provider — return any non-boolean value (typically a `User` model) and pgbus will pass it to the filter predicate when evaluating broadcasts. The engine's stream endpoint reads it from `config.streams_authorize`, so no custom mount is needed:
1619
1622
 
1620
1623
  ```ruby
1621
- Pgbus::Web::StreamApp.new(authorize: ->(env, _stream_name) {
1622
- user = User.find_by(id: env["rack.session"][:user_id])
1623
- return false unless user
1624
- user # ← context attached to the connection
1625
- })
1624
+ Pgbus.configure do |c|
1625
+ c.streams_authorize = ->(env, stream_name) {
1626
+ user = User.find_by(id: env["rack.session"]&.dig("user_id"))
1627
+ next unless user
1628
+
1629
+ # Check THIS user against THIS stream: here "room:42" names a room.
1630
+ room_id = stream_name.delete_prefix("room:") if stream_name.start_with?("room:")
1631
+ room = Room.find_by(id: room_id) if room_id
1632
+ user if room&.member?(user) # nil → 403; the user becomes the context
1633
+ }
1634
+ end
1626
1635
  ```
1627
1636
 
1637
+ Authorize per stream here. The signed stream name proves pgbus minted it, not that this user may read it: a user removed from a room keeps a working signed name until the signing key rotates (`Turbo.signed_stream_verifier_key` when turbo-rails is loaded, `streams_signed_name_secret` otherwise), so a hook that only checks "logged in" lets them keep listening. Rotating the key only refuses new connections: an SSE connection already open stays open until it closes or its worker restarts.
1638
+
1639
+ The hook gets the Rack env, not a controller (the stream endpoint is a bare Rack app), so read the session (`env["rack.session"]`) or a signed cookie from it. A falsy return answers 403, `true` authorizes with no context. An explicit `Pgbus::Web::StreamApp.new(authorize: …)` wins; every `StreamApp` built without one, the engine's mount included, uses `streams_authorize`. `validate!` rejects a non-callable value at boot; with `eager_validation = false` it is not checked there, and every stream request then fails closed with a 500.
1640
+
1628
1641
  Then label broadcasts with the filter you want to apply:
1629
1642
 
1630
1643
  ```ruby
@@ -1648,7 +1661,7 @@ An actor who just triggered a change already applied it via the HTTP response of
1648
1661
  Pgbus.stream(room).broadcast(html, exclude: connection_id)
1649
1662
  ```
1650
1663
 
1651
- Every server-minted SSE connection exposes its id to the page: right after the open handshake, pgbus sends a `pgbus:connected` frame carrying the connection id, and `<pgbus-stream-source>` captures it onto its `connection-id` attribute and re-dispatches it as a `pgbus:connected` event (also present in `pgbus:open`'s detail). The page reads that id and sends it back as the `X-Pgbus-Connection` header on the action request that triggers the broadcast:
1664
+ Every server-minted SSE connection exposes its id to the page: once the streamer has registered the connection (after the connect replay, and after its presence seat has joined on a presence stream), pgbus sends a `pgbus:connected` frame carrying the connection id, and `<pgbus-stream-source>` captures it onto its `connection-id` attribute and re-dispatches it as a `pgbus:connected` event. A write keyed by the connection id right after the event, such as `presence.set_state(connection_id:)`, finds the connection's row. The page reads that id and sends it back as the `X-Pgbus-Connection` header on the action request that triggers the broadcast:
1652
1665
 
1653
1666
  ```js
1654
1667
  document.addEventListener("pgbus:connected", (event) => {
@@ -1793,7 +1806,7 @@ Pgbus.configure do |c|
1793
1806
  end
1794
1807
  ```
1795
1808
 
1796
- Identity comes from the connection's authorize-hook context (the value your `StreamApp` `authorize:` callable returns). The built-in extractor handles the common shapes without any configuration: a `Hash` with `:member_id` (or `:id`) and optional `:metadata`, or any object responding to `#id` (e.g. a `User` model). For anything else, provide a custom extractor — a `->(context) { { id:, metadata: } }` callable returning `nil` for a context with no derivable identity (anonymous connections are simply skipped, not an error):
1809
+ Identity comes from the connection's authorize-hook context (the value `config.streams_authorize`, or your own `StreamApp` `authorize:` callable, returns). Without a hook every connection is anonymous and nobody joins. The built-in extractor handles the common shapes without any configuration: a `Hash` with `:member_id` (or `:id`) and optional `:metadata`, or any object responding to `#id` (e.g. a `User` model). For anything else, provide a custom extractor — a `->(context) { { id:, metadata: } }` callable returning `nil` for a context with no derivable identity (anonymous connections are simply skipped, not an error):
1797
1810
 
1798
1811
  ```ruby
1799
1812
  Pgbus.configure do |c|
@@ -1804,7 +1817,40 @@ Pgbus.configure do |c|
1804
1817
  end
1805
1818
  ```
1806
1819
 
1807
- Membership work runs on the dispatcher thread (which already releases AR connections each pass); the heartbeat posts a batched touch per tick. Presence failures are logged and swallowed so a presence-table hiccup can't knock a live SSE connection out of the registry.
1820
+ Membership work runs on the dispatcher thread (which already releases AR connections each pass); the heartbeat posts a batched touch per tick. A closed tab leaves at once (within `streams_peer_close_interval` for a connection that just opened): a watcher thread notices the client's close on the socket (Puma and Falcon's hijack path) instead of waiting for the next heartbeat write to fail. Falcon's experimental streaming-body path still leaves on the heartbeat. Presence failures are logged and swallowed so a presence-table hiccup can't knock a live SSE connection out of the registry.
1821
+
1822
+ #### Seats: one row per tab (experimental)
1823
+
1824
+ On a table migrated with seats (`rails generate pgbus:add_presence_seats`, which `pgbus:update` queues for you; fresh `pgbus:add_presence` tables already have them), connection-driven presence keeps **one row per SSE connection** instead of one per member. Two tabs of one user are two rows; closing one tab leaves the user present. Each row carries the connection id, the tab's `seat` and a `state` jsonb:
1825
+
1826
+ ```erb
1827
+ <%= pgbus_stream_from @room, presence: true %> <%# sends ?seat=<per-tab id> %>
1828
+ ```
1829
+
1830
+ The `presence` attribute makes `<pgbus-stream-source>` mint one seat per document (shared by every presence element on the page, kept across Turbo Drive visits and reconnects, readable as `element.pgbusSeat`) and send it as `?seat=`.
1831
+
1832
+ ```ruby
1833
+ presence = Pgbus.stream(@room).presence
1834
+ presence.set_state(connection_id: conn_id, state: { selected: 3 }) # merges into that tab's row only
1835
+ presence.members
1836
+ # => [{ "id" => "7", "connection_id" => "a1b2…", "seat" => "5a94…", "state" => { "selected" => 3 },
1837
+ # "metadata" => {...}, "joined_at" => ..., "last_seen_at" => ... }, …]
1838
+ ```
1839
+
1840
+ `members` and `count` return one entry per stored row: one per seat, plus any row written by `join(member_id:)` (no `connection_id:`), which keeps its own one-row-per-member key and behaves exactly as before. Group by `"id"` for one entry per member.
1841
+
1842
+ The heartbeat touches live seats every tick and expires silent ones after their stream's ttl, including rows left behind by a crashed process. Every seat join, leave and expiry is reported to a hook (member-keyed `join`, `leave` and `sweep!` are not):
1843
+
1844
+ ```ruby
1845
+ Pgbus.configure do |c|
1846
+ c.streams_presence_ttl = { /^doc:/ => 5.minutes, default: 60 } # or one number for every stream
1847
+ c.streams_presence_on_change = ->(stream_name, change) {
1848
+ # change: { type: :join | :leave, connection_id:, seat:, member_id: } (an expiry is a :leave)
1849
+ }
1850
+ end
1851
+ ```
1852
+
1853
+ Keep each ttl above `streams_heartbeat_interval` (pgbus warns at boot otherwise): a shorter one expires live seats between touches. On a seat table `sweep!` only expires member-keyed rows; seats expire through the heartbeat. A process that probed the table before the migration re-checks within 30 seconds (and at once if a member-keyed `join` outside a transaction hits the new index). A raising hook is logged, not propagated. Library authors can probe for seat support with `Pgbus::Streams::Presence.instance_method(:join).parameters.include?([:key, :connection_id])` (the gem) and `Pgbus::Streams::Presence.seats_supported?` (the table).
1808
1854
 
1809
1855
  ### Stream stats (opt-in)
1810
1856
 
data/Rakefile CHANGED
@@ -187,6 +187,32 @@ namespace :frontend do
187
187
  "-i app/frontend/pgbus/tailwind.css " \
188
188
  "-o app/frontend/pgbus/style.css --minify")
189
189
  end
190
+
191
+ # The minified JavaScript (<name>.min.js + .min.js.map beside each source) is a
192
+ # committed build artifact too: the dashboard and the host's importmap only
193
+ # ever load the .min.js, so editing a source changes nothing until you run
194
+ # this. scripts/build_frontend.js lists the sources; CI runs js_check.
195
+ desc "Minify the dashboard JS and the stream element into committed .min.js files"
196
+ task :js do
197
+ wanted = File.read(".bun-version").strip
198
+ found = `bun --version 2>/dev/null`.strip
199
+ abort "frontend:js needs bun #{wanted} (.bun-version); found #{found.empty? ? "none" : found}" if found != wanted
200
+
201
+ sh("bun run scripts/build_frontend.js")
202
+ end
203
+
204
+ desc "Fail when a committed .min.js is stale (CI drift guard)"
205
+ task js_check: :js do
206
+ # status, not diff, so an output never added to git is stale too. Only the
207
+ # work-tree column counts: a freshly rebuilt, staged output is in sync.
208
+ changed = IO.popen(["git", "status", "--porcelain", "--",
209
+ "app/assets/javascripts/pgbus/*.min.js*", "app/frontend/pgbus/*.min.js*"], &:read)
210
+ .lines.reject { |line| line[1] == " " }.join
211
+ next if changed.empty?
212
+
213
+ puts changed
214
+ abort "Minified JS is stale — run `bundle exec rake frontend:js` and commit the result"
215
+ end
190
216
  end
191
217
 
192
218
  namespace :dummy do
@@ -15,6 +15,9 @@
15
15
  // by this element because the signed name is
16
16
  // already in the URL path
17
17
  // channel — compatibility shim; ignored
18
+ // presence — boolean; when present the element sends this
19
+ // document's presence seat as ?seat= on every
20
+ // connect (issue #515)
18
21
  //
19
22
  // Events (dispatched on the element):
20
23
  // message (MessageEvent) data = turbo-stream HTML;
@@ -34,8 +37,9 @@
34
37
  // pgbus:close { code, reason }
35
38
  //
36
39
  // Connection id (issue #165 — actor-echo suppression): the server sends a
37
- // `pgbus:connected` frame right after the open handshake carrying the
38
- // server-minted connection id. This element captures it, reflects it onto
40
+ // `pgbus:connected` frame carrying the server-minted connection id once the
41
+ // streamer has registered the connection and joined its presence seat
42
+ // (issue #521), after the connect replay. This element captures it, reflects it onto
39
43
  // the `connection-id` attribute, and re-dispatches it as `pgbus:connected`.
40
44
  // A page reads it (from the element or a `<meta name="pgbus-connection-id">`
41
45
  // the app mirrors it to) and sends it back as the `X-Pgbus-Connection`
@@ -47,6 +51,14 @@
47
51
  // disconnectStreamSource + dispatching MessageEvent("message") so Turbo
48
52
  // Stream HTML is automatically consumed by the existing StreamObserver.
49
53
  //
54
+ // Presence seat (issue #515): with the `presence` attribute, the element
55
+ // appends `?seat=<id>` to the SSE request. The seat is minted once per
56
+ // document (shared by every presence element on the page and kept across
57
+ // Turbo Drive visits, so it names the browser tab) and stays the same across
58
+ // reconnects. The server stores it on the connection's presence row, which
59
+ // lets a page tell its own tab apart from the member's other tabs. Read it
60
+ // from `element.pgbusSeat`.
61
+ //
50
62
  // Transport strategy:
51
63
  // - FIRST connect: use fetch() + ReadableStream to include ?since=
52
64
  // in the URL. Native EventSource cannot send Last-Event-ID on the
@@ -58,6 +70,18 @@
58
70
  import { Turbo } from "@hotwired/turbo-rails"
59
71
  const { connectStreamSource, disconnectStreamSource } = Turbo
60
72
 
73
+ let documentSeat = null
74
+
75
+ // One seat per document, minted on first use (see "Presence seat" above).
76
+ function seatForDocument() {
77
+ if (!documentSeat) {
78
+ documentSeat = globalThis.crypto && crypto.randomUUID
79
+ ? crypto.randomUUID()
80
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
81
+ }
82
+ return documentSeat
83
+ }
84
+
61
85
  class PgbusStreamSourceElement extends HTMLElement {
62
86
  static get observedAttributes() {
63
87
  return ["src", "since-id"]
@@ -79,6 +103,12 @@ class PgbusStreamSourceElement extends HTMLElement {
79
103
  return this.connectionId
80
104
  }
81
105
 
106
+ // This document's presence seat when the element has the `presence`
107
+ // attribute, otherwise null.
108
+ get pgbusSeat() {
109
+ return this.hasAttribute("presence") ? seatForDocument() : null
110
+ }
111
+
82
112
  connectedCallback() {
83
113
  this.closed = false
84
114
  connectStreamSource(this)
@@ -348,10 +378,13 @@ class PgbusStreamSourceElement extends HTMLElement {
348
378
 
349
379
  buildUrl({ includeSince }) {
350
380
  const src = this.getAttribute("src")
351
- if (!includeSince || !this.lastEventId) return src
381
+ const since = includeSince && this.lastEventId ? this.lastEventId : null
382
+ const seat = this.pgbusSeat
383
+ if (!since && !seat) return src
352
384
 
353
385
  const url = new URL(src, window.location.origin)
354
- url.searchParams.set("since", this.lastEventId)
386
+ if (since) url.searchParams.set("since", since)
387
+ if (seat) url.searchParams.set("seat", seat)
355
388
  return url.toString()
356
389
  }
357
390
 
@@ -0,0 +1,6 @@
1
+ import{Turbo as u}from"@hotwired/turbo-rails";var{connectStreamSource:h,disconnectStreamSource:v}=u,r=null;function p(){if(!r)r=globalThis.crypto&&crypto.randomUUID?crypto.randomUUID():`${Date.now().toString(36)}-${Math.random().toString(36).slice(2,12)}`;return r}class a extends HTMLElement{static get observedAttributes(){return["src","since-id"]}constructor(){super();this.abortController=null,this.eventSource=null,this.lastEventId=null,this.connectionId=null,this.closed=!1}get pgbusConnectionId(){return this.connectionId}get pgbusSeat(){return this.hasAttribute("presence")?p():null}connectedCallback(){this.closed=!1,h(this);let s=this.getAttribute("since-id");this.lastEventId=s&&s!==""?s:null,this.openFetchStream()}disconnectedCallback(){this.closed=!0,v(this),this.teardown()}teardown(){if(this.abortController)this.abortController.abort(),this.abortController=null;if(this.eventSource)this.eventSource.close(),this.eventSource=null}async openFetchStream(){this.resetConnectionId();let s=this.buildUrl({includeSince:!0});this.abortController=new AbortController;try{let t=await fetch(s,{headers:{Accept:"text/event-stream"},credentials:"same-origin",signal:this.abortController.signal});if(!t.ok){this.dispatchEvent(new CustomEvent("pgbus:close",{detail:{code:t.status,reason:t.statusText}}));return}this.setAttribute("connected",""),this.dispatchEvent(new CustomEvent("pgbus:open",{detail:{lastEventId:this.lastEventId,connectionId:this.connectionId}}));let e=t.body.getReader(),n=new TextDecoder("utf-8"),i="";while(!this.closed){let{value:o,done:d}=await e.read();if(d){this.removeAttribute("connected"),this.switchToEventSource();return}i+=n.decode(o,{stream:!0});let c=i.split(`
2
+
3
+ `);i=c.pop();for(let l of c)this.handleBlock(l)}}catch(t){if(t.name!=="AbortError")this.removeAttribute("connected"),this.switchToEventSource()}}switchToEventSource(){if(this.closed)return;this.resetConnectionId();let s=this.buildUrl({includeSince:!0});this.eventSource=new EventSource(s,{withCredentials:!0}),this.eventSource.addEventListener("open",()=>{this.setAttribute("connected",""),this.dispatchEvent(new CustomEvent("pgbus:open",{detail:{lastEventId:this.lastEventId,connectionId:this.connectionId}}))}),this.eventSource.addEventListener("error",()=>{this.removeAttribute("connected")}),this.eventSource.addEventListener("turbo-stream",(t)=>{this.lastEventId=t.lastEventId,this.emitTurboStream(t.data,t.lastEventId)}),this.eventSource.addEventListener("pgbus:connected",(t)=>{this.handleConnected(t.data)}),this.eventSource.addEventListener("pgbus:gap-detected",(t)=>{let e=this.safeJsonParse(t.data);this.dispatchEvent(new CustomEvent("pgbus:gap-detected",{detail:e}))}),this.eventSource.addEventListener("pgbus:shutdown",()=>{this.dispatchEvent(new CustomEvent("pgbus:close",{detail:{code:"shutdown",reason:"worker restart"}}))});for(let t of this.declaredTypedEvents())this.eventSource.addEventListener(t,(e)=>{if(e.lastEventId)this.lastEventId=e.lastEventId;this.emitTypedEvent(t,e.data,e.lastEventId)})}handleBlock(s){if(!s||s.startsWith(":"))return;let t=null,e="message",n=[];for(let o of s.split(`
4
+ `))if(o.startsWith("id:"))t=o.slice(3).trim();else if(o.startsWith("event:"))e=o.slice(6).trim();else if(o.startsWith("data:"))n.push(o.slice(5).replace(/^ /,""));let i=n.join(`
5
+ `);if(t!==null)this.lastEventId=t;if(e==="turbo-stream")this.emitTurboStream(i,t);else if(e==="pgbus:connected")this.handleConnected(i);else if(e==="pgbus:gap-detected")this.dispatchEvent(new CustomEvent("pgbus:gap-detected",{detail:this.safeJsonParse(i)}));else if(e==="pgbus:shutdown")this.dispatchEvent(new CustomEvent("pgbus:close",{detail:{code:"shutdown",reason:"worker restart"}}));else this.emitTypedEvent(e,i,t)}handleConnected(s){let t=this.safeJsonParse(s),e=t&&t.connectionId;if(!e)return;this.connectionId=e,this.setAttribute("connection-id",e),this.dispatchEvent(new CustomEvent("pgbus:connected",{detail:{connectionId:e}}))}resetConnectionId(){this.connectionId=null,this.removeAttribute("connection-id")}emitTurboStream(s,t){let e=t===null||t===void 0||t===""?null:Number.isNaN(Number(t))?t:Number(t),n=new MessageEvent("message",{data:s,lastEventId:t==null?"":String(t)});this.dispatchEvent(n),this.dispatchEvent(new CustomEvent("pgbus:message",{detail:{msgId:e,data:s}}))}emitTypedEvent(s,t,e){let n=e===null||e===void 0||e===""?null:Number.isNaN(Number(e))?e:Number(e);this.dispatchEvent(new CustomEvent("pgbus:event",{detail:{event:s,data:t,msgId:n}})),this.dispatchEvent(new CustomEvent(`pgbus:${s}`,{detail:{data:t,msgId:n}}))}declaredTypedEvents(){let s=this.getAttribute("listen-events");if(!s)return[];return s.split(/[\s,]+/).filter((t)=>t&&t!=="turbo-stream")}buildUrl({includeSince:s}){let t=this.getAttribute("src"),e=s&&this.lastEventId?this.lastEventId:null,n=this.pgbusSeat;if(!e&&!n)return t;let i=new URL(t,window.location.origin);if(e)i.searchParams.set("since",e);if(n)i.searchParams.set("seat",n);return i.toString()}safeJsonParse(s){try{return JSON.parse(s)}catch{return null}}}if(!customElements.get("pgbus-stream-source"))customElements.define("pgbus-stream-source",a);export{a as PgbusStreamSourceElement};
6
+ //# sourceMappingURL=stream_source_element.min.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["src/stream_source_element.js"],"sourcesContent":["// <pgbus-stream-source> — the browser-side counterpart to\n// Pgbus::StreamsHelper#pgbus_stream_from. Drop-in replacement for\n// turbo-rails' <turbo-cable-stream-source> that speaks SSE + pgbus\n// instead of WebSocket + ActionCable.\n//\n// Attributes (set by the view helper):\n// src — absolute path to the SSE endpoint, including\n// the URL-safe signed stream name\n// since-id — PGMQ msg_id watermark at render time; the\n// client sends this as ?since= on the FIRST\n// connect so the server can replay any\n// broadcasts from the render-to-connect gap\n// (rails/rails#52420 fix)\n// signed-stream-name — present for parity with turbo-rails; unused\n// by this element because the signed name is\n// already in the URL path\n// channel — compatibility shim; ignored\n// presence — boolean; when present the element sends this\n// document's presence seat as ?seat= on every\n// connect (issue #515)\n//\n// Events (dispatched on the element):\n// message (MessageEvent) data = turbo-stream HTML;\n// lastEventId = the frame's msg_id (revision)\n// pgbus:message { msgId, data } — same frame, for optimistic-UI\n// reconciliation (skip morph if a newer rev for the\n// target was already applied). See #168.\n// pgbus:event { event, data, msgId } — a typed broadcast (any SSE\n// event name other than turbo-stream). See #170.\n// pgbus:<event> { data, msgId } — the same typed broadcast, named\n// for ergonomic addEventListener(\"pgbus:presence\").\n// pgbus:open { lastEventId, connectionId }\n// pgbus:connected { connectionId }\n// pgbus:replay-start { fromId, toId }\n// pgbus:replay-end {}\n// pgbus:gap-detected { lastSeenId, archiveOldestId }\n// pgbus:close { code, reason }\n//\n// Connection id (issue #165 — actor-echo suppression): the server sends a\n// `pgbus:connected` frame carrying the server-minted connection id once the\n// streamer has registered the connection and joined its presence seat\n// (issue #521), after the connect replay. This element captures it, reflects it onto\n// the `connection-id` attribute, and re-dispatches it as `pgbus:connected`.\n// A page reads it (from the element or a `<meta name=\"pgbus-connection-id\">`\n// the app mirrors it to) and sends it back as the `X-Pgbus-Connection`\n// header on action requests. The broadcaster then passes\n// `exclude: request.headers[\"X-Pgbus-Connection\"]` so the actor does not\n// receive the echo of its own broadcast.\n//\n// The element integrates with Turbo via connectStreamSource /\n// disconnectStreamSource + dispatching MessageEvent(\"message\") so Turbo\n// Stream HTML is automatically consumed by the existing StreamObserver.\n//\n// Presence seat (issue #515): with the `presence` attribute, the element\n// appends `?seat=<id>` to the SSE request. The seat is minted once per\n// document (shared by every presence element on the page and kept across\n// Turbo Drive visits, so it names the browser tab) and stays the same across\n// reconnects. The server stores it on the connection's presence row, which\n// lets a page tell its own tab apart from the member's other tabs. Read it\n// from `element.pgbusSeat`.\n//\n// Transport strategy:\n// - FIRST connect: use fetch() + ReadableStream to include ?since=\n// in the URL. Native EventSource cannot send Last-Event-ID on the\n// initial request, so we need fetch to carry the watermark.\n// - RECONNECT: switch to native EventSource, which sends Last-Event-ID\n// automatically based on the last id: we observed. The native\n// client is more battle-tested for reconnection backoff.\n\nimport { Turbo } from \"@hotwired/turbo-rails\"\nconst { connectStreamSource, disconnectStreamSource } = Turbo\n\nlet documentSeat = null\n\n// One seat per document, minted on first use (see \"Presence seat\" above).\nfunction seatForDocument() {\n if (!documentSeat) {\n documentSeat = globalThis.crypto && crypto.randomUUID\n ? crypto.randomUUID()\n : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`\n }\n return documentSeat\n}\n\nclass PgbusStreamSourceElement extends HTMLElement {\n static get observedAttributes() {\n return [\"src\", \"since-id\"]\n }\n\n constructor() {\n super()\n this.abortController = null\n this.eventSource = null\n this.lastEventId = null\n this.connectionId = null\n this.closed = false\n }\n\n // The server-minted connection id for this SSE connection, or null\n // until the `pgbus:connected` frame arrives. Public read accessor so\n // a reactive runtime can grab it without poking at attributes.\n get pgbusConnectionId() {\n return this.connectionId\n }\n\n // This document's presence seat when the element has the `presence`\n // attribute, otherwise null.\n get pgbusSeat() {\n return this.hasAttribute(\"presence\") ? seatForDocument() : null\n }\n\n connectedCallback() {\n this.closed = false\n connectStreamSource(this)\n const sinceId = this.getAttribute(\"since-id\")\n this.lastEventId = sinceId && sinceId !== \"\" ? sinceId : null\n this.openFetchStream()\n }\n\n disconnectedCallback() {\n this.closed = true\n disconnectStreamSource(this)\n this.teardown()\n }\n\n teardown() {\n if (this.abortController) {\n this.abortController.abort()\n this.abortController = null\n }\n if (this.eventSource) {\n this.eventSource.close()\n this.eventSource = null\n }\n }\n\n // First connect: use fetch() so we can include ?since=<watermark> on\n // the URL. Parses the SSE event stream by hand because EventSource\n // doesn't expose custom query strings uniformly across browsers.\n async openFetchStream() {\n // Each transport open is a fresh connection: the server mints a new id\n // and sends a new pgbus:connected frame. Clear the previous id so\n // pgbus:open (which fires before that frame arrives) can't surface a\n // stale connection id — a stale id would produce a wrong X-Pgbus-Connection\n // exclude header during reconnect windows.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.abortController = new AbortController()\n\n try {\n const response = await fetch(url, {\n headers: { Accept: \"text/event-stream\" },\n credentials: \"same-origin\",\n signal: this.abortController.signal\n })\n\n if (!response.ok) {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: response.status, reason: response.statusText }\n }))\n return\n }\n\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n\n const reader = response.body.getReader()\n const decoder = new TextDecoder(\"utf-8\")\n let buffer = \"\"\n\n while (!this.closed) {\n const { value, done } = await reader.read()\n if (done) {\n this.removeAttribute(\"connected\")\n this.switchToEventSource()\n return\n }\n\n buffer += decoder.decode(value, { stream: true })\n const events = buffer.split(\"\\n\\n\")\n buffer = events.pop() // last chunk may be incomplete\n\n for (const block of events) {\n this.handleBlock(block)\n }\n }\n } catch (err) {\n if (err.name !== \"AbortError\") {\n this.removeAttribute(\"connected\")\n // Fall through to reconnect via native EventSource, which\n // will carry Last-Event-ID from this.lastEventId forward.\n this.switchToEventSource()\n }\n }\n }\n\n // Reconnect path: native EventSource with Last-Event-ID baked into\n // the initial handshake by the browser.\n switchToEventSource() {\n if (this.closed) return\n\n // Fresh connection on reconnect — drop the previous connection id so\n // pgbus:open can't emit a stale one before the new pgbus:connected\n // frame lands. See openFetchStream.\n this.resetConnectionId()\n const url = this.buildUrl({ includeSince: true })\n this.eventSource = new EventSource(url, { withCredentials: true })\n\n this.eventSource.addEventListener(\"open\", () => {\n this.setAttribute(\"connected\", \"\")\n this.dispatchEvent(new CustomEvent(\"pgbus:open\", {\n detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }\n }))\n })\n\n this.eventSource.addEventListener(\"error\", () => {\n this.removeAttribute(\"connected\")\n })\n\n this.eventSource.addEventListener(\"turbo-stream\", (event) => {\n this.lastEventId = event.lastEventId\n this.emitTurboStream(event.data, event.lastEventId)\n })\n\n this.eventSource.addEventListener(\"pgbus:connected\", (event) => {\n this.handleConnected(event.data)\n })\n\n this.eventSource.addEventListener(\"pgbus:gap-detected\", (event) => {\n const detail = this.safeJsonParse(event.data)\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", { detail }))\n })\n\n this.eventSource.addEventListener(\"pgbus:shutdown\", () => {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n })\n\n // Typed broadcasts (issue #170): EventSource only fires listeners\n // registered by name, so we register one per declared typed event.\n for (const name of this.declaredTypedEvents()) {\n this.eventSource.addEventListener(name, (event) => {\n if (event.lastEventId) this.lastEventId = event.lastEventId\n this.emitTypedEvent(name, event.data, event.lastEventId)\n })\n }\n }\n\n // Parses a single SSE event block (: comment | id: ... | event: ... | data: ...)\n handleBlock(block) {\n if (!block || block.startsWith(\":\")) return // comment\n\n let id = null\n let event = \"message\"\n const dataLines = []\n\n for (const line of block.split(\"\\n\")) {\n if (line.startsWith(\"id:\")) id = line.slice(3).trim()\n else if (line.startsWith(\"event:\")) event = line.slice(6).trim()\n // Per the SSE spec: strip only a single leading space after the colon\n // (never trim — payload whitespace is significant, issue #392) and\n // rejoin consecutive data: lines with \\n, matching native EventSource.\n else if (line.startsWith(\"data:\")) dataLines.push(line.slice(5).replace(/^ /, \"\"))\n }\n const data = dataLines.join(\"\\n\")\n\n if (id !== null) this.lastEventId = id\n\n if (event === \"turbo-stream\") {\n this.emitTurboStream(data, id)\n } else if (event === \"pgbus:connected\") {\n this.handleConnected(data)\n } else if (event === \"pgbus:gap-detected\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:gap-detected\", {\n detail: this.safeJsonParse(data)\n }))\n } else if (event === \"pgbus:shutdown\") {\n this.dispatchEvent(new CustomEvent(\"pgbus:close\", {\n detail: { code: \"shutdown\", reason: \"worker restart\" }\n }))\n } else {\n // A typed broadcast (issue #170): event: presence | reactive | ...\n this.emitTypedEvent(event, data, id)\n }\n }\n\n // Captures the server-minted connection id from a `pgbus:connected`\n // frame: stores it, reflects it onto the `connection-id` attribute (so\n // it's visible in the DOM / to MutationObservers), and re-dispatches it\n // as a `pgbus:connected` CustomEvent. Idempotent across reconnects — the\n // server mints a fresh id per connection, so a reconnect updates it.\n handleConnected(data) {\n const detail = this.safeJsonParse(data)\n const connectionId = detail && detail.connectionId\n if (!connectionId) return\n\n this.connectionId = connectionId\n this.setAttribute(\"connection-id\", connectionId)\n this.dispatchEvent(new CustomEvent(\"pgbus:connected\", {\n detail: { connectionId }\n }))\n }\n\n // Clears the cached connection id and its reflected attribute. Called at\n // the start of every transport open so a reconnect doesn't carry the\n // previous connection's id into pgbus:open before the new pgbus:connected\n // frame arrives.\n resetConnectionId() {\n this.connectionId = null\n this.removeAttribute(\"connection-id\")\n }\n\n // Delivers a turbo-stream frame to two audiences (issue #168):\n //\n // 1. Turbo's StreamObserver, via a `message` MessageEvent whose `data`\n // is the turbo-stream HTML. The MessageEvent's standard\n // `lastEventId` field carries the frame's msg_id, so a reactive\n // runtime that listens for `message` can read the revision without\n // any pgbus-specific API. (Turbo ignores lastEventId.)\n //\n // 2. A reactive runtime doing optimistic-UI reconciliation, via a\n // `pgbus:message` CustomEvent carrying { msgId, data }. msgId is the\n // monotonic per-stream revision (a negative value marks an ephemeral\n // frame that was never persisted). Pattern: track the highest msgId\n // you have applied per target; when a frame arrives, skip the morph\n // if you have already applied a newer revision for that target —\n // this stops a late echo from clobbering a newer optimistic edit.\n // Complements #165: exclude handles the actor; this handles\n // out-of-order delivery for everyone else.\n //\n // msgId is parsed to a Number when numeric so consumers can compare\n // revisions with `>` directly; left as-is otherwise.\n emitTurboStream(data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n const message = new MessageEvent(\"message\", { data, lastEventId: id == null ? \"\" : String(id) })\n this.dispatchEvent(message)\n\n this.dispatchEvent(new CustomEvent(\"pgbus:message\", {\n detail: { msgId, data }\n }))\n }\n\n // Delivers a typed broadcast (issue #170) — a frame whose SSE event name\n // is something other than turbo-stream (e.g. \"presence\", \"reactive\").\n // The payload is still whatever the broadcaster sent (usually a Turbo\n // Stream); the typed name lets a client route without sniffing the HTML.\n // Dispatched two ways for ergonomics:\n // - a generic `pgbus:event` { event, data, msgId } (one listener for all)\n // - a named `pgbus:<event>` { data, msgId } (addEventListener by name)\n emitTypedEvent(event, data, id) {\n const msgId = id === null || id === undefined || id === \"\" ? null\n : (Number.isNaN(Number(id)) ? id : Number(id))\n\n this.dispatchEvent(new CustomEvent(\"pgbus:event\", {\n detail: { event, data, msgId }\n }))\n this.dispatchEvent(new CustomEvent(`pgbus:${event}`, {\n detail: { data, msgId }\n }))\n }\n\n // Typed event names the EventSource (reconnect) path should listen for,\n // declared by the app via the `listen-events` attribute (comma- or\n // space-separated). EventSource only invokes listeners registered by\n // name, so unlike the fetch path it cannot route unknown typed events\n // generically; declaring them here keeps typed delivery working across\n // reconnects. The fetch (initial) path always routes typed events.\n declaredTypedEvents() {\n const raw = this.getAttribute(\"listen-events\")\n if (!raw) return []\n return raw.split(/[\\s,]+/).filter((name) => name && name !== \"turbo-stream\")\n }\n\n buildUrl({ includeSince }) {\n const src = this.getAttribute(\"src\")\n const since = includeSince && this.lastEventId ? this.lastEventId : null\n const seat = this.pgbusSeat\n if (!since && !seat) return src\n\n const url = new URL(src, window.location.origin)\n if (since) url.searchParams.set(\"since\", since)\n if (seat) url.searchParams.set(\"seat\", seat)\n return url.toString()\n }\n\n safeJsonParse(str) {\n try { return JSON.parse(str) } catch { return null }\n }\n}\n\nif (!customElements.get(\"pgbus-stream-source\")) {\n customElements.define(\"pgbus-stream-source\", PgbusStreamSourceElement)\n}\n\nexport { PgbusStreamSourceElement }\n"],"mappings":"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","names":[]}
@@ -10,20 +10,21 @@ module Pgbus
10
10
  STATIC_ASSETS = {
11
11
  css: {
12
12
  style: FRONTEND_ROOT.join("style.css")
13
- },
14
- js: {
15
- apexcharts: FRONTEND_ROOT.join("vendor", "apexcharts.js")
16
13
  }
17
14
  }.freeze
18
15
 
16
+ # Modules are served minified (rake frontend:js), each with its .min.js.map
17
+ # beside it. ApexCharts ships minified already; charts.js imports it on
18
+ # demand, so only Insights downloads it.
19
19
  MODULE_OVERRIDES = {
20
- application: FRONTEND_ROOT.join("application.js"),
21
- turbo: FRONTEND_ROOT.join("vendor", "turbo.js")
20
+ application: FRONTEND_ROOT.join("application.min.js"),
21
+ turbo: FRONTEND_ROOT.join("vendor", "turbo.min.js"),
22
+ apexcharts: FRONTEND_ROOT.join("vendor", "apexcharts.js")
22
23
  }.freeze
23
24
 
24
25
  def self.js_modules
25
- @js_modules ||= FRONTEND_ROOT.join("modules").children.select(&:file?)
26
- .to_h { |f| [File.basename(f.basename.to_s, ".js").to_sym, f] }
26
+ @js_modules ||= FRONTEND_ROOT.join("modules").glob("*.min.js").sort
27
+ .to_h { |f| [f.basename(".min.js").to_s.to_sym, f] }
27
28
  .merge(MODULE_OVERRIDES)
28
29
  end
29
30
 
@@ -43,18 +44,31 @@ module Pgbus
43
44
  render file: file
44
45
  end
45
46
 
47
+ # GET modules/:version/<id>.js serves the module; <id>.map its sourcemap,
48
+ # the URL each minified module's sourceMappingURL comment resolves to.
46
49
  def module
47
50
  validate_version!
48
- raise ActionController::RoutingError, "Not Found" if params[:format] != "js"
49
-
50
51
  file = self.class.js_modules[params[:id]&.to_sym]
51
- raise ActionController::RoutingError, "Not Found" unless file&.exist?
52
52
 
53
- render file: file
53
+ case params[:format]
54
+ when "js" then serve(file)
55
+ when "map" then serve(sourcemap_for(file), content_type: "application/json")
56
+ else raise ActionController::RoutingError, "Not Found"
57
+ end
54
58
  end
55
59
 
56
60
  private
57
61
 
62
+ def serve(file, **)
63
+ raise ActionController::RoutingError, "Not Found" unless file&.exist?
64
+
65
+ render(file: file, **)
66
+ end
67
+
68
+ def sourcemap_for(file)
69
+ Pathname.new("#{file}.map") if file.to_s.end_with?(".min.js")
70
+ end
71
+
58
72
  def validate_version!
59
73
  return if params[:version] == CURRENT_VERSION
60
74
 
@@ -0,0 +1,2 @@
1
+ import*as m from"turbo";m.config.forms.confirm=(e,o)=>{let a=document.getElementById("pgbus-confirm-dialog"),n=document.getElementById("pgbus-confirm-message"),d=document.getElementById("pgbus-confirm-title"),t=document.getElementById("pgbus-confirm-btn"),c=document.getElementById("pgbus-confirm-icon"),r=o.getAttribute("data-turbo-method")==="delete",s=document.getElementById("pgbus-i18n");if(d.textContent=r?s?.dataset.deleteTitle||"Delete":s?.dataset.confirmTitle||"Are you sure?",n.textContent=e,t.className="rounded-md px-4 py-2 text-sm font-medium text-white focus:outline-none focus:ring-2",r)t.classList.add("bg-red-600","hover:bg-red-500","focus:ring-red-500"),t.textContent=s?.dataset.deleteLabel||"Delete",c.className="flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-red-100 dark:bg-red-900/30";else t.classList.add("bg-yellow-500","hover:bg-yellow-400","focus:ring-yellow-500"),t.textContent=s?.dataset.confirmLabel||"Confirm",c.className="flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-yellow-100 dark:bg-yellow-900/30";return a.showModal(),new Promise((g)=>{a.addEventListener("close",()=>{g(a.returnValue==="confirm")},{once:!0})})};function f(e,o="success"){let a=document.getElementById("pgbus-toast-container");if(!a)return;let n=document.createElement("div"),d={success:"bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800",error:"bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-300 border-red-200 dark:border-red-800",info:"bg-blue-50 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-200 dark:border-blue-800"};n.className=`rounded-md border p-3 text-sm shadow-lg transition-all duration-300 ${d[o]||d.info}`,n.textContent=e,a.appendChild(n),setTimeout(()=>{n.style.opacity="0",n.style.transform="translateX(100%)",setTimeout(()=>n.remove(),300)},5000)}function b(){document.querySelectorAll("template[data-pgbus-toast]").forEach((e)=>{f(e.content.textContent.trim(),e.dataset.pgbusToast),e.remove()})}b();document.addEventListener("turbo:load",b);function u(){document.querySelectorAll("[data-bulk-select-all]").forEach((e)=>{if(e.dataset.bulkInitialized==="true")return;e.dataset.bulkInitialized="true";let o=e.closest("[data-bulk-scope]")||document,a=()=>o.querySelectorAll("input[data-bulk-item]"),n=o.closest("[data-bulk-page]")||o.closest("turbo-frame")||o.parentElement?.closest("div")||document,d=n.querySelector("[data-bulk-count]"),t=n.querySelector("[data-bulk-actions]");function c(){let r=o.querySelectorAll("input[data-bulk-item]:checked");if(d)d.textContent=r.length;if(t)t.classList.toggle("hidden",r.length===0);let s=a();e.checked=s.length>0&&r.length===s.length,e.indeterminate=r.length>0&&r.length<s.length}let l=!1;e.addEventListener("change",()=>{l=!0,a().forEach((r)=>{r.checked=e.checked,r.dispatchEvent(new Event("change",{bubbles:!1}))}),l=!1,c()}),o.addEventListener("change",(r)=>{if(r.target.matches("input[data-bulk-item]"))c()})})}u();document.addEventListener("turbo:load",u);document.addEventListener("turbo:frame-load",u);var i=parseInt(document.body?.dataset.pgbusRefreshInterval||"0",10);if(i>0){let o=function(){if(document.querySelector("input[data-bulk-item]:checked, input[data-bulk-select-all]:checked"))return!0;if(document.querySelector("turbo-frame[data-auto-refresh] details[open]"))return!0;return!1},a=function(){if(document.hidden)return;if(o())return;document.querySelectorAll("turbo-frame[data-auto-refresh]").forEach((t)=>{try{if(!t.src&&t.dataset.src)t.src=t.dataset.src;if(t.src)t.reload()}catch(c){}})},n=function(){e=setInterval(a,i)},d=function(){clearInterval(e)},e;document.addEventListener("visibilitychange",()=>document.hidden?d():n()),n()}
2
+ //# sourceMappingURL=application.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["src/application.js"],"sourcesContent":["import * as Turbo from \"turbo\";\n\n// -- Custom confirm dialog (replaces browser confirm) --\nTurbo.config.forms.confirm = (message, element) => {\n const dialog = document.getElementById(\"pgbus-confirm-dialog\");\n const messageEl = document.getElementById(\"pgbus-confirm-message\");\n const titleEl = document.getElementById(\"pgbus-confirm-title\");\n const confirmBtn = document.getElementById(\"pgbus-confirm-btn\");\n const iconEl = document.getElementById(\"pgbus-confirm-icon\");\n\n const turboMethod = element.getAttribute(\"data-turbo-method\");\n const isDelete = turboMethod === \"delete\";\n\n const i18n = document.getElementById(\"pgbus-i18n\");\n titleEl.textContent = isDelete\n ? (i18n?.dataset.deleteTitle || \"Delete\")\n : (i18n?.dataset.confirmTitle || \"Are you sure?\");\n messageEl.textContent = message;\n\n confirmBtn.className = \"rounded-md px-4 py-2 text-sm font-medium text-white focus:outline-none focus:ring-2\";\n if (isDelete) {\n confirmBtn.classList.add(\"bg-red-600\", \"hover:bg-red-500\", \"focus:ring-red-500\");\n confirmBtn.textContent = i18n?.dataset.deleteLabel || \"Delete\";\n iconEl.className = \"flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-red-100 dark:bg-red-900/30\";\n } else {\n confirmBtn.classList.add(\"bg-yellow-500\", \"hover:bg-yellow-400\", \"focus:ring-yellow-500\");\n confirmBtn.textContent = i18n?.dataset.confirmLabel || \"Confirm\";\n iconEl.className = \"flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-yellow-100 dark:bg-yellow-900/30\";\n }\n\n dialog.showModal();\n\n return new Promise((resolve) => {\n dialog.addEventListener(\"close\", () => {\n resolve(dialog.returnValue === \"confirm\");\n }, { once: true });\n });\n};\n\n// -- Toast notifications --\nfunction showToast(message, type = \"success\") {\n const container = document.getElementById(\"pgbus-toast-container\");\n if (!container) return;\n const toast = document.createElement(\"div\");\n\n const colors = {\n success: \"bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800\",\n error: \"bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-300 border-red-200 dark:border-red-800\",\n info: \"bg-blue-50 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-200 dark:border-blue-800\",\n };\n\n toast.className = `rounded-md border p-3 text-sm shadow-lg transition-all duration-300 ${colors[type] || colors.info}`;\n toast.textContent = message;\n container.appendChild(toast);\n\n setTimeout(() => {\n toast.style.opacity = \"0\";\n toast.style.transform = \"translateX(100%)\";\n setTimeout(() => toast.remove(), 300);\n }, 5000);\n}\n\nfunction renderFlashToasts() {\n document.querySelectorAll(\"template[data-pgbus-toast]\").forEach(tpl => {\n showToast(tpl.content.textContent.trim(), tpl.dataset.pgbusToast);\n tpl.remove();\n });\n}\nrenderFlashToasts();\ndocument.addEventListener(\"turbo:load\", renderFlashToasts);\n\n// -- Bulk checkbox selection --\nfunction initBulkSelect() {\n document.querySelectorAll(\"[data-bulk-select-all]\").forEach(selectAll => {\n if (selectAll.dataset.bulkInitialized === \"true\") return;\n selectAll.dataset.bulkInitialized = \"true\";\n\n const scope = selectAll.closest(\"[data-bulk-scope]\") || document;\n const checkboxes = () => scope.querySelectorAll(\"input[data-bulk-item]\");\n // Look for bulk-actions in the parent page container (outside the table scope)\n const pageScope = scope.closest(\"[data-bulk-page]\") || scope.closest(\"turbo-frame\") || scope.parentElement?.closest(\"div\") || document;\n const countEl = pageScope.querySelector(\"[data-bulk-count]\");\n const actions = pageScope.querySelector(\"[data-bulk-actions]\");\n\n function updateUI() {\n const checked = scope.querySelectorAll(\"input[data-bulk-item]:checked\");\n if (countEl) countEl.textContent = checked.length;\n if (actions) actions.classList.toggle(\"hidden\", checked.length === 0);\n\n const all = checkboxes();\n selectAll.checked = all.length > 0 && checked.length === all.length;\n selectAll.indeterminate = checked.length > 0 && checked.length < all.length;\n }\n\n let inSelectAll = false;\n selectAll.addEventListener(\"change\", () => {\n inSelectAll = true;\n checkboxes().forEach(cb => {\n cb.checked = selectAll.checked;\n // Dispatch change event so inline onchange handlers fire (e.g., hidden input sync)\n cb.dispatchEvent(new Event(\"change\", { bubbles: false }));\n });\n inSelectAll = false;\n updateUI();\n });\n\n scope.addEventListener(\"change\", (e) => {\n if (e.target.matches(\"input[data-bulk-item]\")) updateUI();\n });\n });\n\n}\ninitBulkSelect();\ndocument.addEventListener(\"turbo:load\", initBulkSelect);\ndocument.addEventListener(\"turbo:frame-load\", initBulkSelect);\n\n// -- Auto-refresh (pauses when user is interacting) --\nconst refreshInterval = parseInt(document.body?.dataset.pgbusRefreshInterval || \"0\", 10);\nif (refreshInterval > 0) {\n let timer;\n\n function hasUserInteraction() {\n // Pause when any checkbox is checked\n const checked = document.querySelector(\"input[data-bulk-item]:checked, input[data-bulk-select-all]:checked\");\n if (checked) return true;\n // Pause when any job detail row is expanded\n const openDetails = document.querySelector(\"turbo-frame[data-auto-refresh] details[open]\");\n if (openDetails) return true;\n return false;\n }\n\n function refreshFrames() {\n if (document.hidden) return;\n if (hasUserInteraction()) return;\n document.querySelectorAll(\"turbo-frame[data-auto-refresh]\")\n .forEach(frame => {\n try {\n if (!frame.src && frame.dataset.src) frame.src = frame.dataset.src;\n if (frame.src) frame.reload();\n } catch (_) { /* Turbo may abort in-flight fetches during navigation */ }\n });\n }\n function start() { timer = setInterval(refreshFrames, refreshInterval); }\n function stop() { clearInterval(timer); }\n document.addEventListener(\"visibilitychange\", () => document.hidden ? stop() : start());\n start();\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,7 @@
1
1
  // Charts module for the Pgbus Insights page.
2
2
  // Loaded only on the insights page via an inline <script type="module">, which
3
- // passes it the series the page embeds. The page renders each chart's caption,
3
+ // passes it the series the page embeds. ApexCharts is fetched here, on first
4
+ // draw, so no other dashboard page downloads it (issue #517). The page renders each chart's caption,
4
5
  // data table and empty state; this module only draws into the containers that
5
6
  // exist, and redraws them when <html class="dark"> toggles.
6
7
 
@@ -71,9 +72,25 @@ function points(series, key) {
71
72
  return series.map((p) => ({ x: new Date(p.time).getTime(), y: p[key] }));
72
73
  }
73
74
 
75
+ // vendor/apexcharts.js is a UMD bundle: run as a module it defines the global.
76
+ // Memoized, so the theme-toggle redraws reuse the one download. Every caller
77
+ // awaits the same promise, and its callbacks run in call order, so a redraw
78
+ // can't overtake the first render. A failed import clears the memo, so the
79
+ // next draw (a theme toggle) retries instead of awaiting the same rejection.
80
+ let apexCharts = null;
81
+ function loadApexCharts() {
82
+ apexCharts ||= import("apexcharts")
83
+ .then(() => globalThis.ApexCharts)
84
+ .catch((error) => {
85
+ apexCharts = null;
86
+ throw error;
87
+ });
88
+ return apexCharts;
89
+ }
90
+
74
91
  const charts = {};
75
92
 
76
- function draw(id, options) {
93
+ function draw(ApexCharts, id, options) {
77
94
  if (charts[id]) charts[id].destroy();
78
95
  delete charts[id];
79
96
 
@@ -84,10 +101,11 @@ function draw(id, options) {
84
101
  charts[id].render();
85
102
  }
86
103
 
87
- export function renderCharts(data, i18n) {
104
+ export async function renderCharts(data, i18n) {
105
+ const ApexCharts = await loadApexCharts();
88
106
  const colors = palette(document.documentElement.classList.contains("dark"));
89
107
 
90
- draw("throughput-chart", {
108
+ draw(ApexCharts, "throughput-chart", {
91
109
  ...baseOptions("throughput-chart", "area", colors),
92
110
  series: [{ name: i18n.seriesName, data: points(data.throughput || [], "count") }],
93
111
  stroke: { curve: "smooth", width: 2 },
@@ -98,7 +116,7 @@ export function renderCharts(data, i18n) {
98
116
  });
99
117
 
100
118
  const statuses = Object.keys(data.status_counts || {});
101
- draw("status-chart", {
119
+ draw(ApexCharts, "status-chart", {
102
120
  ...baseOptions("status-chart", "donut", colors),
103
121
  series: statuses.map((s) => data.status_counts[s]),
104
122
  labels: statuses.map((s) => (i18n.statuses && i18n.statuses[s]) || s),
@@ -120,7 +138,7 @@ export function renderCharts(data, i18n) {
120
138
  },
121
139
  });
122
140
 
123
- draw("latency-chart", {
141
+ draw(ApexCharts, "latency-chart", {
124
142
  ...baseOptions("latency-chart", "line", colors),
125
143
  series: [
126
144
  { name: i18n.latencyAvg, data: points(data.latency_trend || [], "avg_ms") },
@@ -0,0 +1,2 @@
1
+ function f(t){return{mode:t?"dark":"light",text:t?"#d1d5db":"#4b5563",grid:t?"#374151":"#e5e7eb",axis:t?"#4b5563":"#d1d5db",card:t?"#1f2937":"#ffffff",throughput:t?"#818cf8":"#6366f1",latencyAvg:t?"#818cf8":"#6366f1",latencyP95:t?"#fbbf24":"#f59e0b",status:{success:"#10b981",failed:"#ef4444",dead_lettered:"#f97316"},other:t?"#9ca3af":"#6b7280"}}function p(){return window.matchMedia("(prefers-reduced-motion: reduce)").matches}function c(t,e,s){return{chart:{id:t,type:e,height:280,toolbar:{show:!1},background:"transparent",foreColor:s.text,animations:{enabled:!p()}},theme:{mode:s.mode},tooltip:{theme:s.mode},grid:{borderColor:s.grid},legend:{labels:{colors:s.text}},dataLabels:{enabled:!1}}}function h(t){return{type:"datetime",labels:{style:{colors:t.text}},axisBorder:{color:t.axis},axisTicks:{color:t.axis},tooltip:{enabled:!1}}}function m(t,e){return{labels:{style:{colors:t.text},formatter:e},axisBorder:{show:!0,color:t.axis}}}function u(t,e){return t.map((s)=>({x:new Date(s.time).getTime(),y:s[e]}))}var i=null;function b(){return i||=import("apexcharts").then(()=>globalThis.ApexCharts).catch((t)=>{throw i=null,t}),i}var r={};function d(t,e,s){if(r[e])r[e].destroy();delete r[e];let o=document.getElementById(e);if(!o)return;r[e]=new t(o,s),r[e].render()}async function x(t,e){let s=await b(),o=f(document.documentElement.classList.contains("dark"));d(s,"throughput-chart",{...c("throughput-chart","area",o),series:[{name:e.seriesName,data:u(t.throughput||[],"count")}],stroke:{curve:"smooth",width:2},fill:{type:"gradient",gradient:{shadeIntensity:1,opacityFrom:0.4,opacityTo:0.05,stops:[0,100]}},colors:[o.throughput],xaxis:h(o),yaxis:m(o,(a)=>Math.round(a))});let l=Object.keys(t.status_counts||{});d(s,"status-chart",{...c("status-chart","donut",o),series:l.map((a)=>t.status_counts[a]),labels:l.map((a)=>e.statuses&&e.statuses[a]||a),colors:l.map((a)=>o.status[a]||o.other),stroke:{colors:[o.card]},legend:{position:"bottom",labels:{colors:o.text}},plotOptions:{pie:{donut:{size:"60%",labels:{show:!0,name:{color:o.text},value:{color:o.text},total:{show:!0,color:o.text}}}}}}),d(s,"latency-chart",{...c("latency-chart","line",o),series:[{name:e.latencyAvg,data:u(t.latency_trend||[],"avg_ms")},{name:e.latencyP95,data:u(t.latency_trend||[],"p95_ms")}],stroke:{curve:"smooth",width:[2,2],dashArray:[0,5]},colors:[o.latencyAvg,o.latencyP95],xaxis:h(o),yaxis:m(o,(a)=>e.milliseconds.replace("%n",Math.round(a))),legend:{position:"top",labels:{colors:o.text}}})}var n=null;function y(t,e){if(n)n.disconnect();n=new MutationObserver(()=>{let s=typeof t==="function"?t():t;if(s)x(s,e)}),n.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]})}export{y as observeThemeChanges,x as renderCharts};
2
+ //# sourceMappingURL=charts.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["src/charts.js"],"sourcesContent":["// Charts module for the Pgbus Insights page.\n// Loaded only on the insights page via an inline <script type=\"module\">, which\n// passes it the series the page embeds. ApexCharts is fetched here, on first\n// draw, so no other dashboard page downloads it (issue #517). The page renders each chart's caption,\n// data table and empty state; this module only draws into the containers that\n// exist, and redraws them when <html class=\"dark\"> toggles.\n\n// Colours for both themes in one place (issue #492). card is the chart card's\n// own background (white / gray-800): the donut's slice separators use it so\n// they read as gaps, not white lines, in dark mode.\nfunction palette(isDark) {\n return {\n mode: isDark ? \"dark\" : \"light\",\n text: isDark ? \"#d1d5db\" : \"#4b5563\",\n grid: isDark ? \"#374151\" : \"#e5e7eb\",\n axis: isDark ? \"#4b5563\" : \"#d1d5db\",\n card: isDark ? \"#1f2937\" : \"#ffffff\",\n throughput: isDark ? \"#818cf8\" : \"#6366f1\",\n latencyAvg: isDark ? \"#818cf8\" : \"#6366f1\",\n latencyP95: isDark ? \"#fbbf24\" : \"#f59e0b\",\n status: {\n success: \"#10b981\",\n failed: \"#ef4444\",\n dead_lettered: \"#f97316\",\n },\n other: isDark ? \"#9ca3af\" : \"#6b7280\",\n };\n}\n\nfunction reducedMotion() {\n return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\n// Options every chart shares: theme, text, axes, grid, tooltip, animation.\nfunction baseOptions(id, type, colors) {\n return {\n chart: {\n id,\n type,\n height: 280,\n toolbar: { show: false },\n background: \"transparent\",\n foreColor: colors.text,\n animations: { enabled: !reducedMotion() },\n },\n theme: { mode: colors.mode },\n tooltip: { theme: colors.mode },\n grid: { borderColor: colors.grid },\n legend: { labels: { colors: colors.text } },\n dataLabels: { enabled: false },\n };\n}\n\nfunction timeAxis(colors) {\n return {\n type: \"datetime\",\n labels: { style: { colors: colors.text } },\n axisBorder: { color: colors.axis },\n axisTicks: { color: colors.axis },\n tooltip: { enabled: false },\n };\n}\n\nfunction valueAxis(colors, formatter) {\n return {\n labels: { style: { colors: colors.text }, formatter },\n axisBorder: { show: true, color: colors.axis },\n };\n}\n\nfunction points(series, key) {\n return series.map((p) => ({ x: new Date(p.time).getTime(), y: p[key] }));\n}\n\n// vendor/apexcharts.js is a UMD bundle: run as a module it defines the global.\n// Memoized, so the theme-toggle redraws reuse the one download. Every caller\n// awaits the same promise, and its callbacks run in call order, so a redraw\n// can't overtake the first render. A failed import clears the memo, so the\n// next draw (a theme toggle) retries instead of awaiting the same rejection.\nlet apexCharts = null;\nfunction loadApexCharts() {\n apexCharts ||= import(\"apexcharts\")\n .then(() => globalThis.ApexCharts)\n .catch((error) => {\n apexCharts = null;\n throw error;\n });\n return apexCharts;\n}\n\nconst charts = {};\n\nfunction draw(ApexCharts, id, options) {\n if (charts[id]) charts[id].destroy();\n delete charts[id];\n\n const el = document.getElementById(id);\n if (!el) return;\n\n charts[id] = new ApexCharts(el, options);\n charts[id].render();\n}\n\nexport async function renderCharts(data, i18n) {\n const ApexCharts = await loadApexCharts();\n const colors = palette(document.documentElement.classList.contains(\"dark\"));\n\n draw(ApexCharts, \"throughput-chart\", {\n ...baseOptions(\"throughput-chart\", \"area\", colors),\n series: [{ name: i18n.seriesName, data: points(data.throughput || [], \"count\") }],\n stroke: { curve: \"smooth\", width: 2 },\n fill: { type: \"gradient\", gradient: { shadeIntensity: 1, opacityFrom: 0.4, opacityTo: 0.05, stops: [0, 100] } },\n colors: [colors.throughput],\n xaxis: timeAxis(colors),\n yaxis: valueAxis(colors, (v) => Math.round(v)),\n });\n\n const statuses = Object.keys(data.status_counts || {});\n draw(ApexCharts, \"status-chart\", {\n ...baseOptions(\"status-chart\", \"donut\", colors),\n series: statuses.map((s) => data.status_counts[s]),\n labels: statuses.map((s) => (i18n.statuses && i18n.statuses[s]) || s),\n colors: statuses.map((s) => colors.status[s] || colors.other),\n stroke: { colors: [colors.card] },\n legend: { position: \"bottom\", labels: { colors: colors.text } },\n plotOptions: {\n pie: {\n donut: {\n size: \"60%\",\n labels: {\n show: true,\n name: { color: colors.text },\n value: { color: colors.text },\n total: { show: true, color: colors.text },\n },\n },\n },\n },\n });\n\n draw(ApexCharts, \"latency-chart\", {\n ...baseOptions(\"latency-chart\", \"line\", colors),\n series: [\n { name: i18n.latencyAvg, data: points(data.latency_trend || [], \"avg_ms\") },\n { name: i18n.latencyP95, data: points(data.latency_trend || [], \"p95_ms\") },\n ],\n stroke: { curve: \"smooth\", width: [2, 2], dashArray: [0, 5] },\n colors: [colors.latencyAvg, colors.latencyP95],\n xaxis: timeAxis(colors),\n yaxis: valueAxis(colors, (v) => i18n.milliseconds.replace(\"%n\", Math.round(v))),\n legend: { position: \"top\", labels: { colors: colors.text } },\n });\n}\n\nlet themeObserver = null;\n\n// The page toggles dark mode with a class on <html>; redraw in the new palette.\nexport function observeThemeChanges(getDataFn, i18n) {\n if (themeObserver) themeObserver.disconnect();\n\n themeObserver = new MutationObserver(() => {\n const data = typeof getDataFn === \"function\" ? getDataFn() : getDataFn;\n if (data) renderCharts(data, i18n);\n });\n themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n}\n"],"mappings":"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","names":[]}