pgbus 0.17.2 → 0.17.3

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +35 -0
  4. data/Rakefile +26 -0
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
  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.rb +4 -0
  24. data/lib/pgbus/engine.rb +13 -7
  25. data/lib/pgbus/generators/migration_detector.rb +9 -1
  26. data/lib/pgbus/streams/presence.rb +317 -81
  27. data/lib/pgbus/version.rb +1 -1
  28. data/lib/pgbus/web/stream_app.rb +18 -6
  29. data/lib/pgbus/web/streamer/connection.rb +7 -1
  30. data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
  31. data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
  32. data/lib/pgbus/web/streamer/instance.rb +2 -1
  33. data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
  34. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
  35. metadata +13 -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: 54e3acb0aefac1a5a7e6a4dfabc972dc9fa830c8d738fbc029bfc667cf4635b2
4
+ data.tar.gz: 37b30a6e3f8625d938f499457c4cae88c6bc759d5017274bd425f1f9f59151e9
5
5
  SHA512:
6
- metadata.gz: 4ad2af9f7cdb993bf5f6577e2ab94cd4f3df8833e813b32e75751e694e0d7bb188ac49fd3ae11fdb46c9070a35b674579f756472519dae46cda8efc55f40afea
7
- data.tar.gz: c9fd9452c8bff020cc581bb2fcd5e3cf1980d6c51b1ddf694b2bc5aeb20b29552c678b3ce3deb85f24b994b0a35e6cceba9b5cb15cb480d72bdd3b49f0a83afa
6
+ metadata.gz: 288d8ee976c4cf222f19fc3d3eab68523e1317bedbc94b49ddf1d6bde3fc22e05308e54fc0d4a0980ea9e7dc295452f224edf6088622b9acbc4d3f39dd595d45
7
+ data.tar.gz: 98b95204d3e73817c5b432ef85128ee1de8040aad5dacb1bba424e76fe88436028ed7259ae70f2636a9ddec6d18f2df615217497c9b887537dafde0a2ceebfc3
data/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ### Added
4
4
 
5
+ - **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.
6
+
5
7
  - **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
8
 
7
9
  - **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 +38,8 @@
36
38
 
37
39
  ### Changed
38
40
 
41
+ - **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.
42
+
39
43
  - **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
44
 
41
45
  - **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.
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
@@ -1806,6 +1808,39 @@ end
1806
1808
 
1807
1809
  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.
1808
1810
 
1811
+ #### Seats: one row per tab (experimental)
1812
+
1813
+ 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:
1814
+
1815
+ ```erb
1816
+ <%= pgbus_stream_from @room, presence: true %> <%# sends ?seat=<per-tab id> %>
1817
+ ```
1818
+
1819
+ 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=`.
1820
+
1821
+ ```ruby
1822
+ presence = Pgbus.stream(@room).presence
1823
+ presence.set_state(connection_id: conn_id, state: { selected: 3 }) # merges into that tab's row only
1824
+ presence.members
1825
+ # => [{ "id" => "7", "connection_id" => "a1b2…", "seat" => "5a94…", "state" => { "selected" => 3 },
1826
+ # "metadata" => {...}, "joined_at" => ..., "last_seen_at" => ... }, …]
1827
+ ```
1828
+
1829
+ `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.
1830
+
1831
+ 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):
1832
+
1833
+ ```ruby
1834
+ Pgbus.configure do |c|
1835
+ c.streams_presence_ttl = { /^doc:/ => 5.minutes, default: 60 } # or one number for every stream
1836
+ c.streams_presence_on_change = ->(stream_name, change) {
1837
+ # change: { type: :join | :leave, connection_id:, seat:, member_id: } (an expiry is a :leave)
1838
+ }
1839
+ end
1840
+ ```
1841
+
1842
+ 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).
1843
+
1809
1844
  ### Stream stats (opt-in)
1810
1845
 
1811
1846
  Pgbus can record one row in `pgbus_stream_stats` per broadcast, connect, and disconnect so the `/pgbus/insights` dashboard shows stream throughput alongside job throughput. This is **disabled by default** because stream event volume can dwarf job volume in chat-style apps — enable it deliberately when you want the observability.
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;
@@ -47,6 +50,14 @@
47
50
  // disconnectStreamSource + dispatching MessageEvent("message") so Turbo
48
51
  // Stream HTML is automatically consumed by the existing StreamObserver.
49
52
  //
53
+ // Presence seat (issue #515): with the `presence` attribute, the element
54
+ // appends `?seat=<id>` to the SSE request. The seat is minted once per
55
+ // document (shared by every presence element on the page and kept across
56
+ // Turbo Drive visits, so it names the browser tab) and stays the same across
57
+ // reconnects. The server stores it on the connection's presence row, which
58
+ // lets a page tell its own tab apart from the member's other tabs. Read it
59
+ // from `element.pgbusSeat`.
60
+ //
50
61
  // Transport strategy:
51
62
  // - FIRST connect: use fetch() + ReadableStream to include ?since=
52
63
  // in the URL. Native EventSource cannot send Last-Event-ID on the
@@ -58,6 +69,18 @@
58
69
  import { Turbo } from "@hotwired/turbo-rails"
59
70
  const { connectStreamSource, disconnectStreamSource } = Turbo
60
71
 
72
+ let documentSeat = null
73
+
74
+ // One seat per document, minted on first use (see "Presence seat" above).
75
+ function seatForDocument() {
76
+ if (!documentSeat) {
77
+ documentSeat = globalThis.crypto && crypto.randomUUID
78
+ ? crypto.randomUUID()
79
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
80
+ }
81
+ return documentSeat
82
+ }
83
+
61
84
  class PgbusStreamSourceElement extends HTMLElement {
62
85
  static get observedAttributes() {
63
86
  return ["src", "since-id"]
@@ -79,6 +102,12 @@ class PgbusStreamSourceElement extends HTMLElement {
79
102
  return this.connectionId
80
103
  }
81
104
 
105
+ // This document's presence seat when the element has the `presence`
106
+ // attribute, otherwise null.
107
+ get pgbusSeat() {
108
+ return this.hasAttribute("presence") ? seatForDocument() : null
109
+ }
110
+
82
111
  connectedCallback() {
83
112
  this.closed = false
84
113
  connectStreamSource(this)
@@ -348,10 +377,13 @@ class PgbusStreamSourceElement extends HTMLElement {
348
377
 
349
378
  buildUrl({ includeSince }) {
350
379
  const src = this.getAttribute("src")
351
- if (!includeSince || !this.lastEventId) return src
380
+ const since = includeSince && this.lastEventId ? this.lastEventId : null
381
+ const seat = this.pgbusSeat
382
+ if (!since && !seat) return src
352
383
 
353
384
  const url = new URL(src, window.location.origin)
354
- url.searchParams.set("since", this.lastEventId)
385
+ if (since) url.searchParams.set("since", since)
386
+ if (seat) url.searchParams.set("seat", seat)
355
387
  return url.toString()
356
388
  }
357
389
 
@@ -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 right after the open handshake carrying the\n// server-minted connection id. 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":[]}