pgbus 0.23.1 → 0.23.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +1 -1
- data/app/assets/javascripts/pgbus/stream_source_element.js +55 -11
- data/app/assets/javascripts/pgbus/stream_source_element.min.js +4 -4
- data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -1
- data/app/frontend/pgbus/application.min.js.map +1 -1
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
- data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -1
- data/app/models/pgbus/batch_entry.rb +70 -29
- data/app/models/pgbus/batch_execution.rb +8 -6
- data/app/models/pgbus/blocked_execution.rb +17 -15
- data/app/models/pgbus/job_stat.rb +16 -13
- data/app/models/pgbus/semaphore.rb +16 -14
- data/app/models/pgbus/uniqueness_key.rb +58 -45
- data/lib/pgbus/active_job/adapter.rb +7 -5
- data/lib/pgbus/batch.rb +76 -37
- data/lib/pgbus/concurrency/blocked_execution.rb +28 -24
- data/lib/pgbus/concurrency/semaphore.rb +17 -9
- data/lib/pgbus/failed_event_recorder.rb +34 -25
- data/lib/pgbus/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae461d658279ccfb2b4d1f6a9518ff278637c48beb703556363be63207273e1d
|
|
4
|
+
data.tar.gz: 5cc7d7fed584a4d05564f184262f8f298384683498b448e79f87efc45f72fbe5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 10d5c9068040692f84b8aefb60d864bdfa9b35160fe3d733b66de36aeffe93653c50ab792f8603523cda3b4646974af6f0961e039a4ae7fc57e2f7ebe575f37c
|
|
7
|
+
data.tar.gz: 7797c3f4134d55036e0678faf4cb3fe0cb1724e0bd6ac3c2c0d70c10a70a2b35a54fb987ae8c7caa46193ccdaf0e92f934bf86065daed179edc4aa67e2876fe8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- **A `<pgbus-stream-source>` moved in the DOM resumes from the last durable id it received, so it no longer replays and re-applies frames it already applied (issue #644).** `connectedCallback` reset the resume cursor from the `since-id` attribute on every connect, and `since-id` is the render-time watermark, never updated afterwards. An element disconnected and reconnected as the same instance (Turbo Drive carrying a `data-turbo-permanent` element into the next page's body, or app code calling `appendChild` / `replaceWith` / `insertBefore` on it) reconnected with `?since=<render-time watermark>`, the server replayed every durable frame since the page rendered, and each `replace` / `update` ran again over whatever the target had become since. The element now keeps the cursor it earned on the same `src`: a connect sends `?since=` with the greater of that cursor and `since-id` (both durable msg_ids of one stream, compared as `BigInt`; when only one parses as an integer, that one), so a re-render that carries a newer watermark still wins. A cursor earned on another `src` (the element was re-pointed) is dropped and `since-id` is used alone. The old connection's read loop now stops on its own abort signal, checked between the frames of one network chunk too, so a `pgbus:message` listener that moves the element mid-chunk no longer has the rest of that chunk delivered by both the old loop and the new connection's replay. The first connect is unchanged, and an ephemeral frame still never moves the cursor (issue #621). The workaround of writing `since-id` from a `pgbus:message` listener is no longer needed. No new API. Refs #644.
|
|
6
|
+
|
|
7
|
+
- **pgbus returns its own ActiveRecord connection at the end of every job (issue #649).** The uniqueness release, the concurrency signal, the batch counters, the failed-event row and the job stat run after the Rails executor gave the job's lease back, and each went through a bare `Model.connection`: a lease nobody returned. In threads mode a job with a uniqueness key or a concurrency limit left one connection pinned to its pool thread for the worker's life (busy 12 of 20 after a drain of either on 12 threads), and a backend the server closed meanwhile was reused dead; the integration spec shows the same for a failed or dead-lettered job, and on Rails 7.1 for a batch member, whose relation calls lease through `connection` there. In async mode the job fiber ended owning it, and the pool's reaper reset it (`ROLLBACK`, `DISCARD ALL`, the session `SET`s) on the next checkout: 3 000 resets for 3 000 `limits_concurrency` jobs. Every such call now runs inside `connection_pool.with_connection`, which reuses a lease the caller already holds (a job's perform, a caller's transaction) and otherwise returns the one it took, on Rails 7.1 through 8.1; the stat table's first `table_exists?` probe, which pinned one connection per process even for a plain job, included. Async `limited` cell: 0 resets, 0 dead connections, about twice the jobs/s; threads-mode jobs/s inside the round-to-round spread and statements per job unchanged for every shape (the fleet `batch` row's first three rounds did not overlap and were run again: they do). The new `Pgbus/BareConnectionLease` cop flags a bare `connection` / `lease_connection`, or a `transaction` outside `connection_pool.with_connection`, in the job-path files. `rake bench:worker_profile` takes `WP_BENCH_JOB_SHAPE=plain|unique|limited|batch` and `WP_BENCH_EXECUTION_MODE=threads|async` (new) and prints the ActiveRecord pool's stat and reset count. `docs/performance.md` → "Connection leases on the bookkeeping paths (issue #649)". Refs #648.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Client build toolchain: bun 1.4.2 → 1.4.4.** `.bun-version` (which `rake frontend:js`, `scripts/build_frontend.js` and every `setup-bun` CI step read) and the docs image's `BUN_VERSION` move together. The rebuilt `.min.js` files are byte-identical; only the four `.min.js.map` files change. bun 1.4.4 maps a lone `else` on its own source line to that line (1.4.2 mapped it to the body's first line) and an `else if` to its `else` (1.4.2 pointed at the `if`), gives `finally` its own segment, starts a segment on its token instead of the character before it, and in `charts.js` maps the exported functions to the function instead of the `export` keyword and the ApexCharts import to `import(`. In Turbo's map, 69 generated positions now resolve to a different (earlier, `else`) line and 41 to a different column on the same line, so a stack trace or a debugger can report a different line for those positions. Both `bun.lock` files install `--frozen-lockfile` unchanged, and the `engines.bun` floor stays `>=1.4.0`.
|
|
12
|
+
|
|
13
|
+
- **A batch member completes in two statements instead of nine (issue #650).** On the migrated path (`pgbus_batch_executions`), `Batch.job_completed` / `job_discarded` with a `job_id` now run one statement that deletes the job's execution row and counts it into its batch (`RETURNING` the row), then the single-winner finish `UPDATE … RETURNING *` in its own short transaction, with the READ COMMITTED re-check once per batch as before; the lock-fetch-increment transaction and both refetches are gone, and the record the callbacks and `pgbus.batch_finished` see comes from `RETURNING`. When a batch finishes, which callbacks fire and the pre-migration path are unchanged. The batch row is no longer held locked from a `SELECT … FOR UPDATE` through the counter `UPDATE` and its `COMMIT` (three round trips), so members of one batch stop queueing on it: `WP_BENCH_JOB_SHAPE=batch` worker jobs/s 1 516–1 551 → 3 074–3 394 (YJIT), fleet statements/job 6.8–7.0 → 3.4, Postgres ms/job 3.8–20.6 → 0.14–0.25. `Pgbus::BatchEntry.finish_if_empty!` returns the finished row or nil instead of a row count, and `Batch.try_finish!` takes an optional `record:`. `docs/performance.md` → "Batch completion in two statements (issue #650)", within "Connection leases on the bookkeeping paths (issue #649)". Refs #648.
|
|
14
|
+
|
|
3
15
|
## [0.23.1] - 2026-11-04
|
|
4
16
|
|
|
5
17
|
### Fixed
|
data/README.md
CHANGED
|
@@ -1606,7 +1606,7 @@ Without the flag (default), Falcon uses the same `rack.hijack` path as Puma via
|
|
|
1606
1606
|
|
|
1607
1607
|
### How it works
|
|
1608
1608
|
|
|
1609
|
-
Stream broadcasts are stored in PGMQ queues named `#{queue_prefix}_<stream>` (e.g. `pgbus_chat_42`), the same namespace as job queues; the `pgbus_stream_queues` registry records which of those queues back streams so maintenance and wildcard workers can tell them apart. Each broadcast is assigned a monotonic `msg_id` by PGMQ. The `pgbus_stream_from` helper captures the current `MAX(msg_id)` at render time and embeds it in the HTML as `since-id`. When the SSE client connects, it sends that cursor as `?since=` on the first request and as `Last-Event-ID` on reconnects. The streamer replays from `pgmq.q_*` (live) UNION `pgmq.a_*` (archive) for any `msg_id > cursor`, then switches to LISTEN/NOTIFY for the live path. There is no message identity gap between the render and the subscribe — the cursor model guarantees every broadcast is delivered exactly once, in order, even across reconnects.
|
|
1609
|
+
Stream broadcasts are stored in PGMQ queues named `#{queue_prefix}_<stream>` (e.g. `pgbus_chat_42`), the same namespace as job queues; the `pgbus_stream_queues` registry records which of those queues back streams so maintenance and wildcard workers can tell them apart. Each broadcast is assigned a monotonic `msg_id` by PGMQ. The `pgbus_stream_from` helper captures the current `MAX(msg_id)` at render time and embeds it in the HTML as `since-id`. When the SSE client connects, it sends that cursor as `?since=` on the first request and as `Last-Event-ID` on reconnects. An element moved in the DOM (a `data-turbo-permanent` element Turbo Drive carries to the next page, or one app code re-inserts) reconnects from the newer of the last durable id it received and the render-time `since-id`, so it does not replay what it already applied; the received id is kept only while `src` is unchanged. The streamer replays from `pgmq.q_*` (live) UNION `pgmq.a_*` (archive) for any `msg_id > cursor`, then switches to LISTEN/NOTIFY for the live path. There is no message identity gap between the render and the subscribe — the cursor model guarantees every durable broadcast is delivered exactly once, in order, even across reconnects (an ephemeral broadcast is never archived, so one sent while a client is reconnecting is not replayed).
|
|
1610
1610
|
|
|
1611
1611
|
One Puma worker (or Falcon reactor) hosts one `Pgbus::Web::Streamer::Instance` singleton with three threads (Listener / Dispatcher / Heartbeat) and one dedicated PG connection for LISTEN. Hijacked SSE sockets are held outside the web server's thread pool on Puma (confirmed by an integration test that fires 20 concurrent hijacked connections and observes them complete in parallel on an 8-thread Puma server, [puma/puma#1009](https://github.com/puma/puma/issues/1009)) and inside a fiber on Falcon (one fiber per hijacked connection, scheduler-backed non-blocking IO).
|
|
1612
1612
|
|
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
// the URL-safe signed stream name
|
|
9
9
|
// since-id — PGMQ msg_id watermark at render time; the
|
|
10
10
|
// client sends this as ?since= on the FIRST
|
|
11
|
-
// connect so the server
|
|
12
|
-
// broadcasts from the
|
|
13
|
-
// (rails/rails#52420 fix)
|
|
11
|
+
// connect of this element instance so the server
|
|
12
|
+
// can replay any broadcasts from the
|
|
13
|
+
// render-to-connect gap (rails/rails#52420 fix).
|
|
14
|
+
// A reconnect, including after a DOM move,
|
|
15
|
+
// resumes from the last durable id received
|
|
16
|
+
// instead (issue #644)
|
|
14
17
|
// signed-stream-name — present for parity with turbo-rails; unused
|
|
15
18
|
// by this element because the signed name is
|
|
16
19
|
// already in the URL path
|
|
@@ -60,10 +63,12 @@
|
|
|
60
63
|
// from `element.pgbusSeat`.
|
|
61
64
|
//
|
|
62
65
|
// Transport strategy:
|
|
63
|
-
// -
|
|
64
|
-
// in the URL. Native EventSource
|
|
65
|
-
// initial request, so we need
|
|
66
|
-
//
|
|
66
|
+
// - CONNECT (first, or again after a DOM move): use fetch() +
|
|
67
|
+
// ReadableStream to include ?since= in the URL. Native EventSource
|
|
68
|
+
// cannot send Last-Event-ID on the initial request, so we need
|
|
69
|
+
// fetch to carry the watermark.
|
|
70
|
+
// - RECONNECT (the network dropped): switch to native EventSource,
|
|
71
|
+
// which sends Last-Event-ID
|
|
67
72
|
// automatically based on the last id: we observed. The native
|
|
68
73
|
// client is more battle-tested for reconnection backoff.
|
|
69
74
|
|
|
@@ -92,6 +97,7 @@ class PgbusStreamSourceElement extends HTMLElement {
|
|
|
92
97
|
this.abortController = null
|
|
93
98
|
this.eventSource = null
|
|
94
99
|
this.lastEventId = null
|
|
100
|
+
this.cursorSrc = null
|
|
95
101
|
this.connectionId = null
|
|
96
102
|
this.closed = false
|
|
97
103
|
}
|
|
@@ -112,11 +118,42 @@ class PgbusStreamSourceElement extends HTMLElement {
|
|
|
112
118
|
connectedCallback() {
|
|
113
119
|
this.closed = false
|
|
114
120
|
connectStreamSource(this)
|
|
115
|
-
|
|
116
|
-
this.
|
|
121
|
+
this.lastEventId = this.resumeCursor()
|
|
122
|
+
this.cursorSrc = this.getAttribute("src")
|
|
117
123
|
this.openFetchStream()
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
// The ?since= for this connect (issue #644). A move in the DOM (Turbo
|
|
127
|
+
// carrying a data-turbo-permanent element into the new body, or app code
|
|
128
|
+
// re-inserting it) disconnects and reconnects the same instance; resuming
|
|
129
|
+
// from the render-time since-id then would replay, and re-apply, every
|
|
130
|
+
// durable frame since the page rendered. So the cursor this instance
|
|
131
|
+
// earned on the same src is kept, unless since-id is ahead of it (a
|
|
132
|
+
// re-render carried a newer watermark): both are durable msg_ids of one
|
|
133
|
+
// stream, so the greater is the safe resume point; when only one parses
|
|
134
|
+
// as an integer, that one is used. A cursor earned on another src (the
|
|
135
|
+
// element was re-pointed) is dropped.
|
|
136
|
+
resumeCursor() {
|
|
137
|
+
const attribute = this.getAttribute("since-id") || null
|
|
138
|
+
const kept = this.cursorSrc === this.getAttribute("src") ? this.lastEventId : null
|
|
139
|
+
const keptId = this.parseCursor(kept)
|
|
140
|
+
const attributeId = this.parseCursor(attribute)
|
|
141
|
+
if (keptId === null) return attribute
|
|
142
|
+
if (attributeId === null) return kept
|
|
143
|
+
return keptId > attributeId ? kept : attribute
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A msg_id as a BigInt (PGMQ ids are bigints), or null when absent or not
|
|
147
|
+
// an integer.
|
|
148
|
+
parseCursor(id) {
|
|
149
|
+
if (id === null) return null
|
|
150
|
+
try {
|
|
151
|
+
return BigInt(id)
|
|
152
|
+
} catch {
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
120
157
|
disconnectedCallback() {
|
|
121
158
|
this.closed = true
|
|
122
159
|
disconnectStreamSource(this)
|
|
@@ -146,12 +183,18 @@ class PgbusStreamSourceElement extends HTMLElement {
|
|
|
146
183
|
this.resetConnectionId()
|
|
147
184
|
const url = this.buildUrl({ includeSince: true })
|
|
148
185
|
this.abortController = new AbortController()
|
|
186
|
+
// This connection's own signal. A DOM move tears it down and connects
|
|
187
|
+
// again before this loop resumes, so this.closed is already false by
|
|
188
|
+
// then; the abort is what stops the loop, checked between the frames of
|
|
189
|
+
// one chunk too, since a pgbus:message listener can move the element
|
|
190
|
+
// mid-chunk and the new connection resumes after that frame (issue #644).
|
|
191
|
+
const { signal } = this.abortController
|
|
149
192
|
|
|
150
193
|
try {
|
|
151
194
|
const response = await fetch(url, {
|
|
152
195
|
headers: { Accept: "text/event-stream" },
|
|
153
196
|
credentials: "same-origin",
|
|
154
|
-
signal
|
|
197
|
+
signal
|
|
155
198
|
})
|
|
156
199
|
|
|
157
200
|
if (!response.ok) {
|
|
@@ -170,7 +213,7 @@ class PgbusStreamSourceElement extends HTMLElement {
|
|
|
170
213
|
const decoder = new TextDecoder("utf-8")
|
|
171
214
|
let buffer = ""
|
|
172
215
|
|
|
173
|
-
while (!
|
|
216
|
+
while (!signal.aborted) {
|
|
174
217
|
const { value, done } = await reader.read()
|
|
175
218
|
if (done) {
|
|
176
219
|
this.removeAttribute("connected")
|
|
@@ -183,6 +226,7 @@ class PgbusStreamSourceElement extends HTMLElement {
|
|
|
183
226
|
buffer = events.pop() // last chunk may be incomplete
|
|
184
227
|
|
|
185
228
|
for (const block of events) {
|
|
229
|
+
if (signal.aborted) return
|
|
186
230
|
this.handleBlock(block)
|
|
187
231
|
}
|
|
188
232
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import{Turbo as
|
|
1
|
+
import{Turbo as h}from"@hotwired/turbo-rails";var{connectStreamSource:v,disconnectStreamSource:b}=h,o=null;function m(){if(!o)o=globalThis.crypto&&crypto.randomUUID?crypto.randomUUID():`${Date.now().toString(36)}-${Math.random().toString(36).slice(2,12)}`;return o}class u extends HTMLElement{static get observedAttributes(){return["src","since-id"]}constructor(){super();this.abortController=null,this.eventSource=null,this.lastEventId=null,this.cursorSrc=null,this.connectionId=null,this.closed=!1}get pgbusConnectionId(){return this.connectionId}get pgbusSeat(){return this.hasAttribute("presence")?m():null}connectedCallback(){this.closed=!1,v(this),this.lastEventId=this.resumeCursor(),this.cursorSrc=this.getAttribute("src"),this.openFetchStream()}resumeCursor(){let s=this.getAttribute("since-id")||null,t=this.cursorSrc===this.getAttribute("src")?this.lastEventId:null,e=this.parseCursor(t),n=this.parseCursor(s);if(e===null)return s;if(n===null)return t;return e>n?t:s}parseCursor(s){if(s===null)return null;try{return BigInt(s)}catch{return null}}disconnectedCallback(){this.closed=!0,b(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;let{signal:t}=this.abortController;try{let e=await fetch(s,{headers:{Accept:"text/event-stream"},credentials:"same-origin",signal:t});if(!e.ok){this.dispatchEvent(new CustomEvent("pgbus:close",{detail:{code:e.status,reason:e.statusText}}));return}this.setAttribute("connected",""),this.dispatchEvent(new CustomEvent("pgbus:open",{detail:{lastEventId:this.lastEventId,connectionId:this.connectionId}}));let n=e.body.getReader(),r=new TextDecoder("utf-8"),i="";while(!t.aborted){let{value:a,done:l}=await n.read();if(l){this.removeAttribute("connected"),this.switchToEventSource();return}i+=r.decode(a,{stream:!0});let c=i.split(`
|
|
2
2
|
|
|
3
|
-
`);
|
|
4
|
-
`))if(i.startsWith("id:"))t=i.slice(3).trim();else if(i.startsWith("event:"))
|
|
5
|
-
`);if(this.rememberEventId(t),
|
|
3
|
+
`);i=c.pop();for(let d of c){if(t.aborted)return;this.handleBlock(d)}}}catch(e){if(e.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.rememberEventId(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)=>{this.rememberEventId(e.lastEventId),this.emitTypedEvent(t,e.data,e.lastEventId)})}handleBlock(s){if(!s||s.startsWith(":"))return;let t=null,e="message",n=[];for(let i of s.split(`
|
|
4
|
+
`))if(i.startsWith("id:"))t=i.slice(3).trim();else if(i.startsWith("event:"))e=i.slice(6).trim();else if(i.startsWith("data:"))n.push(i.slice(5).replace(/^ /,""));let r=n.join(`
|
|
5
|
+
`);if(this.rememberEventId(t),e==="turbo-stream")this.emitTurboStream(r,t);else if(e==="pgbus:connected")this.handleConnected(r);else if(e==="pgbus:gap-detected")this.dispatchEvent(new CustomEvent("pgbus:gap-detected",{detail:this.safeJsonParse(r)}));else if(e==="pgbus:shutdown")this.dispatchEvent(new CustomEvent("pgbus:close",{detail:{code:"shutdown",reason:"worker restart"}}));else this.emitTypedEvent(e,r,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 r=new URL(t,window.location.origin);if(e)r.searchParams.set("since",e);if(n)r.searchParams.set("seat",n);return r.toString()}rememberEventId(s){if(s===null||s===void 0||s==="")return;if(String(s).startsWith("-"))return;this.lastEventId=s}safeJsonParse(s){try{return JSON.parse(s)}catch{return null}}}if(!customElements.get("pgbus-stream-source"))customElements.define("pgbus-stream-source",u);export{u as PgbusStreamSourceElement};
|
|
6
6
|
//# sourceMappingURL=stream_source_element.min.js.map
|
|
@@ -1 +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.rememberEventId(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 this.rememberEventId(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 this.rememberEventId(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 // The resume cursor (?since= on the next connect) is the last DURABLE\n // msg_id. An ephemeral frame's id is negative (it has no archive\n // position), so it is handed to listeners as the frame's revision but never\n // kept here: sent back as ?since= it would be rejected (issue #621). A\n // native EventSource still echoes it as Last-Event-ID on its own\n // reconnects; the server skips a negative Last-Event-ID and uses ?since=,\n // which is the cursor as of the switch to EventSource, so durable frames\n // received after that switch can be delivered once more.\n rememberEventId(id) {\n if (id === null || id === undefined || id === \"\") return\n if (String(id).startsWith(\"-\")) return\n this.lastEventId = id\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":"AAqEA,gBAAS,8BACT,IAAQ,sBAAqB,0BAA2B,EAEpD,EAAe,KAGnB,SAAS,CAAe,EAAG,CACzB,GAAI,CAAC,EACH,EAAe,WAAW,QAAU,OAAO,WACvC,OAAO,WAAW,EAClB,GAAG,KAAK,IAAI,EAAE,SAAS,EAAE,KAAK,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,EAAG,EAAE,IAE1E,OAAO,EAGT,MAAM,UAAiC,WAAY,WACtC,mBAAkB,EAAG,CAC9B,MAAO,CAAC,MAAO,UAAU,EAG3B,WAAW,EAAG,CACZ,MAAM,EACN,KAAK,gBAAkB,KACvB,KAAK,YAAc,KACnB,KAAK,YAAc,KACnB,KAAK,aAAe,KACpB,KAAK,OAAS,MAMZ,kBAAiB,EAAG,CACtB,OAAO,KAAK,gBAKV,UAAS,EAAG,CACd,OAAO,KAAK,aAAa,UAAU,EAAI,EAAgB,EAAI,KAG7D,iBAAiB,EAAG,CAClB,KAAK,OAAS,GACd,EAAoB,IAAI,EACxB,IAAM,EAAU,KAAK,aAAa,UAAU,EAC5C,KAAK,YAAc,GAAW,IAAY,GAAK,EAAU,KACzD,KAAK,gBAAgB,EAGvB,oBAAoB,EAAG,CACrB,KAAK,OAAS,GACd,EAAuB,IAAI,EAC3B,KAAK,SAAS,EAGhB,QAAQ,EAAG,CACT,GAAI,KAAK,gBACP,KAAK,gBAAgB,MAAM,EAC3B,KAAK,gBAAkB,KAEzB,GAAI,KAAK,YACP,KAAK,YAAY,MAAM,EACvB,KAAK,YAAc,UAOjB,gBAAe,EAAG,CAMtB,KAAK,kBAAkB,EACvB,IAAM,EAAM,KAAK,SAAS,CAAE,aAAc,EAAK,CAAC,EAChD,KAAK,gBAAkB,IAAI,gBAE3B,GAAI,CACF,IAAM,EAAW,MAAM,MAAM,EAAK,CAChC,QAAS,CAAE,OAAQ,mBAAoB,EACvC,YAAa,cACb,OAAQ,KAAK,gBAAgB,MAC/B,CAAC,EAED,GAAI,CAAC,EAAS,GAAI,CAChB,KAAK,cAAc,IAAI,YAAY,cAAe,CAChD,OAAQ,CAAE,KAAM,EAAS,OAAQ,OAAQ,EAAS,UAAW,CAC/D,CAAC,CAAC,EACF,OAGF,KAAK,aAAa,YAAa,EAAE,EACjC,KAAK,cAAc,IAAI,YAAY,aAAc,CAC/C,OAAQ,CAAE,YAAa,KAAK,YAAa,aAAc,KAAK,YAAa,CAC3E,CAAC,CAAC,EAEF,IAAM,EAAS,EAAS,KAAK,UAAU,EACjC,EAAU,IAAI,YAAY,OAAO,EACnC,EAAS,GAEb,MAAO,CAAC,KAAK,OAAQ,CACnB,IAAQ,QAAO,QAAS,MAAM,EAAO,KAAK,EAC1C,GAAI,EAAM,CACR,KAAK,gBAAgB,WAAW,EAChC,KAAK,oBAAoB,EACzB,OAGF,GAAU,EAAQ,OAAO,EAAO,CAAE,OAAQ,EAAK,CAAC,EAChD,IAAM,EAAS,EAAO,MAAM;AAAA;AAAA,CAAM,EAClC,EAAS,EAAO,IAAI,EAEpB,QAAW,KAAS,EAClB,KAAK,YAAY,CAAK,GAG1B,MAAO,EAAK,CACZ,GAAI,EAAI,OAAS,aACf,KAAK,gBAAgB,WAAW,EAGhC,KAAK,oBAAoB,GAO/B,mBAAmB,EAAG,CACpB,GAAI,KAAK,OAAQ,OAKjB,KAAK,kBAAkB,EACvB,IAAM,EAAM,KAAK,SAAS,CAAE,aAAc,EAAK,CAAC,EAChD,KAAK,YAAc,IAAI,YAAY,EAAK,CAAE,gBAAiB,EAAK,CAAC,EAEjE,KAAK,YAAY,iBAAiB,OAAQ,IAAM,CAC9C,KAAK,aAAa,YAAa,EAAE,EACjC,KAAK,cAAc,IAAI,YAAY,aAAc,CAC/C,OAAQ,CAAE,YAAa,KAAK,YAAa,aAAc,KAAK,YAAa,CAC3E,CAAC,CAAC,EACH,EAED,KAAK,YAAY,iBAAiB,QAAS,IAAM,CAC/C,KAAK,gBAAgB,WAAW,EACjC,EAED,KAAK,YAAY,iBAAiB,eAAgB,CAAC,IAAU,CAC3D,KAAK,gBAAgB,EAAM,WAAW,EACtC,KAAK,gBAAgB,EAAM,KAAM,EAAM,WAAW,EACnD,EAED,KAAK,YAAY,iBAAiB,kBAAmB,CAAC,IAAU,CAC9D,KAAK,gBAAgB,EAAM,IAAI,EAChC,EAED,KAAK,YAAY,iBAAiB,qBAAsB,CAAC,IAAU,CACjE,IAAM,EAAS,KAAK,cAAc,EAAM,IAAI,EAC5C,KAAK,cAAc,IAAI,YAAY,qBAAsB,CAAE,QAAO,CAAC,CAAC,EACrE,EAED,KAAK,YAAY,iBAAiB,iBAAkB,IAAM,CACxD,KAAK,cAAc,IAAI,YAAY,cAAe,CAChD,OAAQ,CAAE,KAAM,WAAY,OAAQ,gBAAiB,CACvD,CAAC,CAAC,EACH,EAID,QAAW,KAAQ,KAAK,oBAAoB,EAC1C,KAAK,YAAY,iBAAiB,EAAM,CAAC,IAAU,CACjD,KAAK,gBAAgB,EAAM,WAAW,EACtC,KAAK,eAAe,EAAM,EAAM,KAAM,EAAM,WAAW,EACxD,EAKL,WAAW,CAAC,EAAO,CACjB,GAAI,CAAC,GAAS,EAAM,WAAW,GAAG,EAAG,OAErC,IAAI,EAAK,KACL,EAAQ,UACN,EAAY,CAAC,EAEnB,QAAW,KAAQ,EAAM,MAAM;AAAA,CAAI,EACjC,GAAI,EAAK,WAAW,KAAK,EAAG,EAAK,EAAK,MAAM,CAAC,EAAE,KAAK,EAC/C,QAAI,EAAK,WAAW,QAAQ,EAAG,EAAQ,EAAK,MAAM,CAAC,EAAE,KAAK,EAI1D,QAAI,EAAK,WAAW,OAAO,EAAG,EAAU,KAAK,EAAK,MAAM,CAAC,EAAE,QAAQ,KAAM,EAAE,CAAC,EAEnF,IAAM,EAAO,EAAU,KAAK;AAAA,CAAI,EAIhC,GAFA,KAAK,gBAAgB,CAAE,EAEnB,IAAU,eACZ,KAAK,gBAAgB,EAAM,CAAE,EACxB,QAAI,IAAU,kBACnB,KAAK,gBAAgB,CAAI,EACpB,QAAI,IAAU,qBACnB,KAAK,cAAc,IAAI,YAAY,qBAAsB,CACvD,OAAQ,KAAK,cAAc,CAAI,CACjC,CAAC,CAAC,EACG,QAAI,IAAU,iBACnB,KAAK,cAAc,IAAI,YAAY,cAAe,CAChD,OAAQ,CAAE,KAAM,WAAY,OAAQ,gBAAiB,CACvD,CAAC,CAAC,EAGF,UAAK,eAAe,EAAO,EAAM,CAAE,EASvC,eAAe,CAAC,EAAM,CACpB,IAAM,EAAS,KAAK,cAAc,CAAI,EAChC,EAAe,GAAU,EAAO,aACtC,GAAI,CAAC,EAAc,OAEnB,KAAK,aAAe,EACpB,KAAK,aAAa,gBAAiB,CAAY,EAC/C,KAAK,cAAc,IAAI,YAAY,kBAAmB,CACpD,OAAQ,CAAE,cAAa,CACzB,CAAC,CAAC,EAOJ,iBAAiB,EAAG,CAClB,KAAK,aAAe,KACpB,KAAK,gBAAgB,eAAe,EAuBtC,eAAe,CAAC,EAAM,EAAI,CACxB,IAAM,EAAQ,IAAO,MAAQ,IAAO,QAAa,IAAO,GAAK,KACxD,OAAO,MAAM,OAAO,CAAE,CAAC,EAAI,EAAK,OAAO,CAAE,EAExC,EAAU,IAAI,aAAa,UAAW,CAAE,OAAM,YAAa,GAAM,KAAO,GAAK,OAAO,CAAE,CAAE,CAAC,EAC/F,KAAK,cAAc,CAAO,EAE1B,KAAK,cAAc,IAAI,YAAY,gBAAiB,CAClD,OAAQ,CAAE,QAAO,MAAK,CACxB,CAAC,CAAC,EAUJ,cAAc,CAAC,EAAO,EAAM,EAAI,CAC9B,IAAM,EAAQ,IAAO,MAAQ,IAAO,QAAa,IAAO,GAAK,KACxD,OAAO,MAAM,OAAO,CAAE,CAAC,EAAI,EAAK,OAAO,CAAE,EAE9C,KAAK,cAAc,IAAI,YAAY,cAAe,CAChD,OAAQ,CAAE,QAAO,OAAM,OAAM,CAC/B,CAAC,CAAC,EACF,KAAK,cAAc,IAAI,YAAY,SAAS,IAAS,CACnD,OAAQ,CAAE,OAAM,OAAM,CACxB,CAAC,CAAC,EASJ,mBAAmB,EAAG,CACpB,IAAM,EAAM,KAAK,aAAa,eAAe,EAC7C,GAAI,CAAC,EAAK,MAAO,CAAC,EAClB,OAAO,EAAI,MAAM,QAAQ,EAAE,OAAO,CAAC,IAAS,GAAQ,IAAS,cAAc,EAG7E,QAAQ,EAAG,gBAAgB,CACzB,IAAM,EAAM,KAAK,aAAa,KAAK,EAC7B,EAAQ,GAAgB,KAAK,YAAc,KAAK,YAAc,KAC9D,EAAO,KAAK,UAClB,GAAI,CAAC,GAAS,CAAC,EAAM,OAAO,EAE5B,IAAM,EAAM,IAAI,IAAI,EAAK,OAAO,SAAS,MAAM,EAC/C,GAAI,EAAO,EAAI,aAAa,IAAI,QAAS,CAAK,EAC9C,GAAI,EAAM,EAAI,aAAa,IAAI,OAAQ,CAAI,EAC3C,OAAO,EAAI,SAAS,EAWtB,eAAe,CAAC,EAAI,CAClB,GAAI,IAAO,MAAQ,IAAO,QAAa,IAAO,GAAI,OAClD,GAAI,OAAO,CAAE,EAAE,WAAW,GAAG,EAAG,OAChC,KAAK,YAAc,EAGrB,aAAa,CAAC,EAAK,CACjB,GAAI,CAAE,OAAO,KAAK,MAAM,CAAG,EAAI,KAAM,CAAE,OAAO,MAElD,CAEA,GAAI,CAAC,eAAe,IAAI,qBAAqB,EAC3C,eAAe,OAAO,sBAAuB,CAAwB","names":[]}
|
|
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 of this element instance so the server\n// can replay any broadcasts from the\n// render-to-connect gap (rails/rails#52420 fix).\n// A reconnect, including after a DOM move,\n// resumes from the last durable id received\n// instead (issue #644)\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// - CONNECT (first, or again after a DOM move): use fetch() +\n// ReadableStream to include ?since= in the URL. Native EventSource\n// cannot send Last-Event-ID on the initial request, so we need\n// fetch to carry the watermark.\n// - RECONNECT (the network dropped): switch to native EventSource,\n// 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.cursorSrc = 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 this.lastEventId = this.resumeCursor()\n this.cursorSrc = this.getAttribute(\"src\")\n this.openFetchStream()\n }\n\n // The ?since= for this connect (issue #644). A move in the DOM (Turbo\n // carrying a data-turbo-permanent element into the new body, or app code\n // re-inserting it) disconnects and reconnects the same instance; resuming\n // from the render-time since-id then would replay, and re-apply, every\n // durable frame since the page rendered. So the cursor this instance\n // earned on the same src is kept, unless since-id is ahead of it (a\n // re-render carried a newer watermark): both are durable msg_ids of one\n // stream, so the greater is the safe resume point; when only one parses\n // as an integer, that one is used. A cursor earned on another src (the\n // element was re-pointed) is dropped.\n resumeCursor() {\n const attribute = this.getAttribute(\"since-id\") || null\n const kept = this.cursorSrc === this.getAttribute(\"src\") ? this.lastEventId : null\n const keptId = this.parseCursor(kept)\n const attributeId = this.parseCursor(attribute)\n if (keptId === null) return attribute\n if (attributeId === null) return kept\n return keptId > attributeId ? kept : attribute\n }\n\n // A msg_id as a BigInt (PGMQ ids are bigints), or null when absent or not\n // an integer.\n parseCursor(id) {\n if (id === null) return null\n try {\n return BigInt(id)\n } catch {\n return null\n }\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 // This connection's own signal. A DOM move tears it down and connects\n // again before this loop resumes, so this.closed is already false by\n // then; the abort is what stops the loop, checked between the frames of\n // one chunk too, since a pgbus:message listener can move the element\n // mid-chunk and the new connection resumes after that frame (issue #644).\n const { signal } = this.abortController\n\n try {\n const response = await fetch(url, {\n headers: { Accept: \"text/event-stream\" },\n credentials: \"same-origin\",\n 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 (!signal.aborted) {\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 if (signal.aborted) return\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.rememberEventId(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 this.rememberEventId(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 this.rememberEventId(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 // The resume cursor (?since= on the next connect) is the last DURABLE\n // msg_id. An ephemeral frame's id is negative (it has no archive\n // position), so it is handed to listeners as the frame's revision but never\n // kept here: sent back as ?since= it would be rejected (issue #621). A\n // native EventSource still echoes it as Last-Event-ID on its own\n // reconnects; the server skips a negative Last-Event-ID and uses ?since=,\n // which is the cursor as of the switch to EventSource, so durable frames\n // received after that switch can be delivered once more.\n rememberEventId(id) {\n if (id === null || id === undefined || id === \"\") return\n if (String(id).startsWith(\"-\")) return\n this.lastEventId = id\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":[]}
|
|
@@ -1 +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 focus:outline-none focus:ring-2\";\n if (isDelete) {\n confirmBtn.classList.add(\"bg-red-600\", \"text-white\", \"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 // White on yellow fails contrast; yellow buttons carry dark text (ButtonHelper's warning variant).\n confirmBtn.classList.add(\"bg-yellow-400\", \"text-yellow-950\", \"hover:bg-yellow-300\", \"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":"AAAA,wBAGM,SAAO,MAAM,QAAU,CAAC,EAAS,IAAY,CACjD,IAAM,EAAS,SAAS,eAAe,sBAAsB,EACvD,EAAY,SAAS,eAAe,uBAAuB,EAC3D,EAAU,SAAS,eAAe,qBAAqB,EACvD,EAAa,SAAS,eAAe,mBAAmB,EACxD,EAAS,SAAS,eAAe,oBAAoB,EAGrD,EADc,EAAQ,aAAa,mBAAmB,IAC3B,SAE3B,EAAO,SAAS,eAAe,YAAY,EAOjD,GANA,EAAQ,YAAc,EACjB,GAAM,QAAQ,aAAe,SAC7B,GAAM,QAAQ,cAAgB,gBACnC,EAAU,YAAc,EAExB,EAAW,UAAY,2EACnB,EACF,EAAW,UAAU,IAAI,aAAc,aAAc,mBAAoB,oBAAoB,EAC7F,EAAW,YAAc,GAAM,QAAQ,aAAe,SACtD,EAAO,UAAY,
|
|
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 focus:outline-none focus:ring-2\";\n if (isDelete) {\n confirmBtn.classList.add(\"bg-red-600\", \"text-white\", \"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 // White on yellow fails contrast; yellow buttons carry dark text (ButtonHelper's warning variant).\n confirmBtn.classList.add(\"bg-yellow-400\", \"text-yellow-950\", \"hover:bg-yellow-300\", \"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 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["src/charts.js"],"sourcesContent":["// Charts module for the Pgbus Insights page, the workflow timeline and the\n// workflow insights.\n// Loaded only on those pages 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 // The workflow timeline's segments (issue #542): the waits in neutral\n // tones, ran coloured by how the attempt ended.\n timeline: {\n waited: isDark ? \"#4b5563\" : \"#d1d5db\",\n queued: isDark ? \"#64748b\" : \"#94a3b8\",\n waiting: isDark ? \"#fbbf24\" : \"#f59e0b\",\n paused: isDark ? \"#fb923c\" : \"#ea580c\",\n ran_ok: \"#10b981\",\n ran_failed: \"#ef4444\",\n ran_open: isDark ? \"#818cf8\" : \"#6366f1\",\n ran_cancelled: isDark ? \"#9ca3af\" : \"#6b7280\",\n },\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\nconst TIMELINE_SERIES = [\"waited\", \"queued\", \"waiting\", \"ran_ok\", \"ran_failed\", \"ran_open\", \"ran_cancelled\", \"paused\"];\n\n// One range bar per segment, grouped by series so each kind keeps its colour.\n// Rows are the attempts (oldest first) plus a \"Paused\" row for the pauses.\nfunction timelineSeries(data, i18n) {\n const points = Object.fromEntries(TIMELINE_SERIES.map((name) => [name, []]));\n const point = (label, segment, row) => ({\n x: label,\n y: [segment.from, segment.to],\n duration: segment.duration,\n status: row && i18n.statuses ? i18n.statuses[row.status] || row.status : null,\n error: row ? row.error_class : null,\n });\n\n (data.rows || []).forEach((row) => {\n row.segments.forEach((segment) => {\n if (points[segment.series]) points[segment.series].push(point(row.label, segment, row));\n });\n });\n (data.paused || []).forEach((segment) => points.paused.push(point(i18n.paused_row, segment, null)));\n\n return TIMELINE_SERIES.filter((name) => points[name].length > 0).map((name) => ({\n name: (i18n.series && i18n.series[name]) || name,\n key: name,\n data: points[name],\n }));\n}\n\n// The tooltip: the segment's kind and duration, then the attempt's status and\n// error class. Text only (textContent-safe): every value is escaped.\nfunction escapeText(value) {\n const div = document.createElement(\"div\");\n div.textContent = value == null ? \"\" : String(value);\n return div.innerHTML;\n}\n\nfunction timelineTooltip({ seriesIndex, dataPointIndex, w }) {\n const series = w.config.series[seriesIndex];\n const p = series.data[dataPointIndex];\n const lines = [`${escapeText(series.name)}: ${escapeText(p.duration)}`];\n if (p.status) lines.push(escapeText(p.status));\n if (p.error) lines.push(escapeText(p.error));\n return `<div class=\"apexcharts-tooltip-title\">${escapeText(p.x)}</div><div style=\"padding: 4px 10px\">${lines.join(\"<br>\")}</div>`;\n}\n\nexport async function renderTimeline(data, i18n) {\n const ApexCharts = await loadApexCharts();\n const colors = palette(document.documentElement.classList.contains(\"dark\"));\n const series = timelineSeries(data, i18n);\n const rows = (data.rows || []).length + ((data.paused || []).length > 0 ? 1 : 0);\n const base = baseOptions(\"workflow-timeline-chart\", \"rangeBar\", colors);\n\n draw(ApexCharts, \"workflow-timeline-chart\", {\n ...base,\n chart: {\n ...base.chart,\n height: Math.max(160, rows * 36 + 90),\n // Waits can span days: zoom and pan along time, reset to see it all.\n zoom: { enabled: true, type: \"x\" },\n toolbar: { show: true, tools: { download: false, selection: false, zoom: true, zoomin: true, zoomout: true, pan: true, reset: true } },\n },\n series,\n colors: series.map((s) => colors.timeline[s.key] || colors.other),\n plotOptions: { bar: { horizontal: true, rangeBarGroupRows: true, barHeight: \"60%\" } },\n xaxis: timeAxis(colors),\n yaxis: { labels: { style: { colors: colors.text } } },\n legend: { position: \"top\", labels: { colors: colors.text } },\n tooltip: { theme: colors.mode, custom: timelineTooltip },\n annotations: { xaxis: waitTimeoutMarks(data, i18n, colors) },\n });\n}\n\n// A dashed line where a wait passed its deadline (issue #565): the waiting\n// colour, labelled in the page's text colour on the card, so it reads in\n// both themes. The page lists the same moments as text.\nfunction waitTimeoutMarks(data, i18n, colors) {\n return (data.wait_timeouts || []).map((mark) => ({\n x: mark.at,\n borderColor: colors.timeline.waiting,\n strokeDashArray: 4,\n label: {\n text: i18n.wait_timed_out,\n orientation: \"horizontal\",\n borderColor: colors.timeline.waiting,\n style: { color: colors.text, background: colors.card },\n },\n }));\n}\n\n// The workflow insights (issue #549): runs started, finished and cancelled\n// per bucket (cancelled in the neutral grey the dashboard uses for it, not\n// the failure red), and the end-to-end duration histogram of the finished\n// runs. The bucket times arrive as the dashboard zone's wall clock written\n// as UTC, which the UTC time axis prints as is.\nexport async function renderWorkflowInsights(data, i18n) {\n const ApexCharts = await loadApexCharts();\n const colors = palette(document.documentElement.classList.contains(\"dark\"));\n const throughput = data.throughput || [];\n\n draw(ApexCharts, \"workflow-throughput-chart\", {\n ...baseOptions(\"workflow-throughput-chart\", \"line\", colors),\n series: [\n { name: i18n.started, data: points(throughput, \"started\") },\n { name: i18n.finished, data: points(throughput, \"finished\") },\n { name: i18n.cancelled, data: points(throughput, \"cancelled\") },\n ],\n stroke: { curve: \"smooth\", width: [2, 2, 2], dashArray: [0, 0, 5] },\n colors: [colors.throughput, colors.status.success, colors.other],\n xaxis: timeAxis(colors),\n yaxis: valueAxis(colors, (v) => Math.round(v)),\n legend: { position: \"top\", labels: { colors: colors.text } },\n });\n\n const durations = data.durations || [];\n draw(ApexCharts, \"workflow-duration-chart\", {\n ...baseOptions(\"workflow-duration-chart\", \"bar\", colors),\n series: [{ name: i18n.runs, data: durations.map((bin) => bin.count) }],\n colors: [colors.throughput],\n plotOptions: { bar: { columnWidth: \"70%\", borderRadius: 2 } },\n xaxis: {\n categories: durations.map((bin) => bin.label),\n labels: { style: { colors: colors.text } },\n axisBorder: { color: colors.axis },\n axisTicks: { color: colors.axis },\n },\n yaxis: valueAxis(colors, (v) => Math.round(v)),\n });\n}\n\nlet themeObserver = null;\n\n// The page toggles dark mode with a class on <html>; redraw in the new palette.\n// render is the page's draw function (renderCharts on Insights, renderTimeline\n// on a workflow, renderWorkflowInsights on a workflow class).\nexport function observeThemeChanges(getDataFn, i18n, render = renderCharts) {\n if (themeObserver) themeObserver.disconnect();\n\n themeObserver = new MutationObserver(() => {\n const data = typeof getDataFn === \"function\" ? getDataFn() : getDataFn;\n if (data) render(data, i18n);\n });\n themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["src/charts.js"],"sourcesContent":["// Charts module for the Pgbus Insights page, the workflow timeline and the\n// workflow insights.\n// Loaded only on those pages 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 // The workflow timeline's segments (issue #542): the waits in neutral\n // tones, ran coloured by how the attempt ended.\n timeline: {\n waited: isDark ? \"#4b5563\" : \"#d1d5db\",\n queued: isDark ? \"#64748b\" : \"#94a3b8\",\n waiting: isDark ? \"#fbbf24\" : \"#f59e0b\",\n paused: isDark ? \"#fb923c\" : \"#ea580c\",\n ran_ok: \"#10b981\",\n ran_failed: \"#ef4444\",\n ran_open: isDark ? \"#818cf8\" : \"#6366f1\",\n ran_cancelled: isDark ? \"#9ca3af\" : \"#6b7280\",\n },\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\nconst TIMELINE_SERIES = [\"waited\", \"queued\", \"waiting\", \"ran_ok\", \"ran_failed\", \"ran_open\", \"ran_cancelled\", \"paused\"];\n\n// One range bar per segment, grouped by series so each kind keeps its colour.\n// Rows are the attempts (oldest first) plus a \"Paused\" row for the pauses.\nfunction timelineSeries(data, i18n) {\n const points = Object.fromEntries(TIMELINE_SERIES.map((name) => [name, []]));\n const point = (label, segment, row) => ({\n x: label,\n y: [segment.from, segment.to],\n duration: segment.duration,\n status: row && i18n.statuses ? i18n.statuses[row.status] || row.status : null,\n error: row ? row.error_class : null,\n });\n\n (data.rows || []).forEach((row) => {\n row.segments.forEach((segment) => {\n if (points[segment.series]) points[segment.series].push(point(row.label, segment, row));\n });\n });\n (data.paused || []).forEach((segment) => points.paused.push(point(i18n.paused_row, segment, null)));\n\n return TIMELINE_SERIES.filter((name) => points[name].length > 0).map((name) => ({\n name: (i18n.series && i18n.series[name]) || name,\n key: name,\n data: points[name],\n }));\n}\n\n// The tooltip: the segment's kind and duration, then the attempt's status and\n// error class. Text only (textContent-safe): every value is escaped.\nfunction escapeText(value) {\n const div = document.createElement(\"div\");\n div.textContent = value == null ? \"\" : String(value);\n return div.innerHTML;\n}\n\nfunction timelineTooltip({ seriesIndex, dataPointIndex, w }) {\n const series = w.config.series[seriesIndex];\n const p = series.data[dataPointIndex];\n const lines = [`${escapeText(series.name)}: ${escapeText(p.duration)}`];\n if (p.status) lines.push(escapeText(p.status));\n if (p.error) lines.push(escapeText(p.error));\n return `<div class=\"apexcharts-tooltip-title\">${escapeText(p.x)}</div><div style=\"padding: 4px 10px\">${lines.join(\"<br>\")}</div>`;\n}\n\nexport async function renderTimeline(data, i18n) {\n const ApexCharts = await loadApexCharts();\n const colors = palette(document.documentElement.classList.contains(\"dark\"));\n const series = timelineSeries(data, i18n);\n const rows = (data.rows || []).length + ((data.paused || []).length > 0 ? 1 : 0);\n const base = baseOptions(\"workflow-timeline-chart\", \"rangeBar\", colors);\n\n draw(ApexCharts, \"workflow-timeline-chart\", {\n ...base,\n chart: {\n ...base.chart,\n height: Math.max(160, rows * 36 + 90),\n // Waits can span days: zoom and pan along time, reset to see it all.\n zoom: { enabled: true, type: \"x\" },\n toolbar: { show: true, tools: { download: false, selection: false, zoom: true, zoomin: true, zoomout: true, pan: true, reset: true } },\n },\n series,\n colors: series.map((s) => colors.timeline[s.key] || colors.other),\n plotOptions: { bar: { horizontal: true, rangeBarGroupRows: true, barHeight: \"60%\" } },\n xaxis: timeAxis(colors),\n yaxis: { labels: { style: { colors: colors.text } } },\n legend: { position: \"top\", labels: { colors: colors.text } },\n tooltip: { theme: colors.mode, custom: timelineTooltip },\n annotations: { xaxis: waitTimeoutMarks(data, i18n, colors) },\n });\n}\n\n// A dashed line where a wait passed its deadline (issue #565): the waiting\n// colour, labelled in the page's text colour on the card, so it reads in\n// both themes. The page lists the same moments as text.\nfunction waitTimeoutMarks(data, i18n, colors) {\n return (data.wait_timeouts || []).map((mark) => ({\n x: mark.at,\n borderColor: colors.timeline.waiting,\n strokeDashArray: 4,\n label: {\n text: i18n.wait_timed_out,\n orientation: \"horizontal\",\n borderColor: colors.timeline.waiting,\n style: { color: colors.text, background: colors.card },\n },\n }));\n}\n\n// The workflow insights (issue #549): runs started, finished and cancelled\n// per bucket (cancelled in the neutral grey the dashboard uses for it, not\n// the failure red), and the end-to-end duration histogram of the finished\n// runs. The bucket times arrive as the dashboard zone's wall clock written\n// as UTC, which the UTC time axis prints as is.\nexport async function renderWorkflowInsights(data, i18n) {\n const ApexCharts = await loadApexCharts();\n const colors = palette(document.documentElement.classList.contains(\"dark\"));\n const throughput = data.throughput || [];\n\n draw(ApexCharts, \"workflow-throughput-chart\", {\n ...baseOptions(\"workflow-throughput-chart\", \"line\", colors),\n series: [\n { name: i18n.started, data: points(throughput, \"started\") },\n { name: i18n.finished, data: points(throughput, \"finished\") },\n { name: i18n.cancelled, data: points(throughput, \"cancelled\") },\n ],\n stroke: { curve: \"smooth\", width: [2, 2, 2], dashArray: [0, 0, 5] },\n colors: [colors.throughput, colors.status.success, colors.other],\n xaxis: timeAxis(colors),\n yaxis: valueAxis(colors, (v) => Math.round(v)),\n legend: { position: \"top\", labels: { colors: colors.text } },\n });\n\n const durations = data.durations || [];\n draw(ApexCharts, \"workflow-duration-chart\", {\n ...baseOptions(\"workflow-duration-chart\", \"bar\", colors),\n series: [{ name: i18n.runs, data: durations.map((bin) => bin.count) }],\n colors: [colors.throughput],\n plotOptions: { bar: { columnWidth: \"70%\", borderRadius: 2 } },\n xaxis: {\n categories: durations.map((bin) => bin.label),\n labels: { style: { colors: colors.text } },\n axisBorder: { color: colors.axis },\n axisTicks: { color: colors.axis },\n },\n yaxis: valueAxis(colors, (v) => Math.round(v)),\n });\n}\n\nlet themeObserver = null;\n\n// The page toggles dark mode with a class on <html>; redraw in the new palette.\n// render is the page's draw function (renderCharts on Insights, renderTimeline\n// on a workflow, renderWorkflowInsights on a workflow class).\nexport function observeThemeChanges(getDataFn, i18n, render = renderCharts) {\n if (themeObserver) themeObserver.disconnect();\n\n themeObserver = new MutationObserver(() => {\n const data = typeof getDataFn === \"function\" ? getDataFn() : getDataFn;\n if (data) render(data, i18n);\n });\n themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n}\n"],"mappings":"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","names":[]}
|