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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d5cf17ed290df4dfd9ab65b43e594a67c2050ed0b4c9b6b3d19c886f9a7191c
4
- data.tar.gz: 902f6b6c806ef2861d42fed64a9d0e1283c4165c2df44333a517d8c45f5c55cf
3
+ metadata.gz: ae461d658279ccfb2b4d1f6a9518ff278637c48beb703556363be63207273e1d
4
+ data.tar.gz: 5cc7d7fed584a4d05564f184262f8f298384683498b448e79f87efc45f72fbe5
5
5
  SHA512:
6
- metadata.gz: 749638cf39ec81aeb595b5b8104dae598253d02278ee68b22da1f7edd949434c2691d5415b02acecc69c486ef4c487dbae241dd96b80d7ee8ffba723e1b9b029
7
- data.tar.gz: c1c134bc2d1347cfe26348b1742a05fd44eba7cb567574690418c05ff53cb0af359f1695a099ef99f0ea05f56ab621f9a0b69e6bbb10a5c63dd098f3cc64354c
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 can replay any
12
- // broadcasts from the render-to-connect gap
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
- // - FIRST connect: use fetch() + ReadableStream to include ?since=
64
- // in the URL. Native EventSource cannot send Last-Event-ID on the
65
- // initial request, so we need fetch to carry the watermark.
66
- // - RECONNECT: switch to native EventSource, which sends Last-Event-ID
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
- const sinceId = this.getAttribute("since-id")
116
- this.lastEventId = sinceId && sinceId !== "" ? sinceId : null
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: this.abortController.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 (!this.closed) {
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 u}from"@hotwired/turbo-rails";var{connectStreamSource:h,disconnectStreamSource:v}=u,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 a extends HTMLElement{static get observedAttributes(){return["src","since-id"]}constructor(){super();this.abortController=null,this.eventSource=null,this.lastEventId=null,this.connectionId=null,this.closed=!1}get pgbusConnectionId(){return this.connectionId}get pgbusSeat(){return this.hasAttribute("presence")?m():null}connectedCallback(){this.closed=!1,h(this);let e=this.getAttribute("since-id");this.lastEventId=e&&e!==""?e:null,this.openFetchStream()}disconnectedCallback(){this.closed=!0,v(this),this.teardown()}teardown(){if(this.abortController)this.abortController.abort(),this.abortController=null;if(this.eventSource)this.eventSource.close(),this.eventSource=null}async openFetchStream(){this.resetConnectionId();let e=this.buildUrl({includeSince:!0});this.abortController=new AbortController;try{let t=await fetch(e,{headers:{Accept:"text/event-stream"},credentials:"same-origin",signal:this.abortController.signal});if(!t.ok){this.dispatchEvent(new CustomEvent("pgbus:close",{detail:{code:t.status,reason:t.statusText}}));return}this.setAttribute("connected",""),this.dispatchEvent(new CustomEvent("pgbus:open",{detail:{lastEventId:this.lastEventId,connectionId:this.connectionId}}));let n=t.body.getReader(),s=new TextDecoder("utf-8"),r="";while(!this.closed){let{value:i,done:d}=await n.read();if(d){this.removeAttribute("connected"),this.switchToEventSource();return}r+=s.decode(i,{stream:!0});let c=r.split(`
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
- `);r=c.pop();for(let l of c)this.handleBlock(l)}}catch(t){if(t.name!=="AbortError")this.removeAttribute("connected"),this.switchToEventSource()}}switchToEventSource(){if(this.closed)return;this.resetConnectionId();let e=this.buildUrl({includeSince:!0});this.eventSource=new EventSource(e,{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 n=this.safeJsonParse(t.data);this.dispatchEvent(new CustomEvent("pgbus:gap-detected",{detail:n}))}),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,(n)=>{this.rememberEventId(n.lastEventId),this.emitTypedEvent(t,n.data,n.lastEventId)})}handleBlock(e){if(!e||e.startsWith(":"))return;let t=null,n="message",s=[];for(let i of e.split(`
4
- `))if(i.startsWith("id:"))t=i.slice(3).trim();else if(i.startsWith("event:"))n=i.slice(6).trim();else if(i.startsWith("data:"))s.push(i.slice(5).replace(/^ /,""));let r=s.join(`
5
- `);if(this.rememberEventId(t),n==="turbo-stream")this.emitTurboStream(r,t);else if(n==="pgbus:connected")this.handleConnected(r);else if(n==="pgbus:gap-detected")this.dispatchEvent(new CustomEvent("pgbus:gap-detected",{detail:this.safeJsonParse(r)}));else if(n==="pgbus:shutdown")this.dispatchEvent(new CustomEvent("pgbus:close",{detail:{code:"shutdown",reason:"worker restart"}}));else this.emitTypedEvent(n,r,t)}handleConnected(e){let t=this.safeJsonParse(e),n=t&&t.connectionId;if(!n)return;this.connectionId=n,this.setAttribute("connection-id",n),this.dispatchEvent(new CustomEvent("pgbus:connected",{detail:{connectionId:n}}))}resetConnectionId(){this.connectionId=null,this.removeAttribute("connection-id")}emitTurboStream(e,t){let n=t===null||t===void 0||t===""?null:Number.isNaN(Number(t))?t:Number(t),s=new MessageEvent("message",{data:e,lastEventId:t==null?"":String(t)});this.dispatchEvent(s),this.dispatchEvent(new CustomEvent("pgbus:message",{detail:{msgId:n,data:e}}))}emitTypedEvent(e,t,n){let s=n===null||n===void 0||n===""?null:Number.isNaN(Number(n))?n:Number(n);this.dispatchEvent(new CustomEvent("pgbus:event",{detail:{event:e,data:t,msgId:s}})),this.dispatchEvent(new CustomEvent(`pgbus:${e}`,{detail:{data:t,msgId:s}}))}declaredTypedEvents(){let e=this.getAttribute("listen-events");if(!e)return[];return e.split(/[\s,]+/).filter((t)=>t&&t!=="turbo-stream")}buildUrl({includeSince:e}){let t=this.getAttribute("src"),n=e&&this.lastEventId?this.lastEventId:null,s=this.pgbusSeat;if(!n&&!s)return t;let r=new URL(t,window.location.origin);if(n)r.searchParams.set("since",n);if(s)r.searchParams.set("seat",s);return r.toString()}rememberEventId(e){if(e===null||e===void 0||e==="")return;if(String(e).startsWith("-"))return;this.lastEventId=e}safeJsonParse(e){try{return JSON.parse(e)}catch{return null}}}if(!customElements.get("pgbus-stream-source"))customElements.define("pgbus-stream-source",a);export{a as PgbusStreamSourceElement};
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":"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","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":"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","names":[]}
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":[]}