rails_event_viewer 0.1.0 → 0.1.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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +59 -374
  4. data/app/assets/javascripts/rails_event_viewer/application.js +28 -0
  5. data/app/assets/javascripts/rails_event_viewer/chart.umd.min.js +19 -0
  6. data/app/assets/javascripts/rails_event_viewer/chartjs-adapter-date-fns.bundle.min.js +7 -0
  7. data/app/assets/javascripts/rails_event_viewer/chartkick.min.js +2 -0
  8. data/app/assets/stylesheets/rails_event_viewer/application.css +1 -32
  9. data/app/assets/stylesheets/rails_event_viewer/tailwind.css +19 -44
  10. data/app/controllers/rails_event_viewer/application_controller.rb +3 -9
  11. data/app/controllers/rails_event_viewer/event_types_controller.rb +2 -2
  12. data/app/controllers/rails_event_viewer/events_controller.rb +2 -2
  13. data/app/controllers/rails_event_viewer/groups_controller.rb +2 -2
  14. data/app/helpers/rails_event_viewer/application_helper.rb +4 -0
  15. data/app/models/rails_event_viewer/page.rb +35 -0
  16. data/app/views/layouts/rails_event_viewer/application.html.erb +5 -9
  17. data/app/views/rails_event_viewer/analytics/overview.html.erb +3 -3
  18. data/app/views/rails_event_viewer/dashboard/index.html.erb +3 -3
  19. data/app/views/rails_event_viewer/event_types/show.html.erb +3 -3
  20. data/app/views/rails_event_viewer/events/_event_row.html.erb +1 -1
  21. data/app/views/rails_event_viewer/events/index.html.erb +2 -2
  22. data/app/views/rails_event_viewer/events/show.html.erb +1 -1
  23. data/app/views/rails_event_viewer/groups/show.html.erb +1 -1
  24. data/app/views/rails_event_viewer/shared/_pagination.html.erb +13 -13
  25. data/config/routes.rb +2 -2
  26. data/lib/rails_event_viewer/engine.rb +7 -2
  27. data/lib/rails_event_viewer/version.rb +1 -1
  28. data/lib/rails_event_viewer.rb +0 -2
  29. metadata +7 -17
  30. data/lib/rails_event_viewer/pagy_countable.rb +0 -31
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 323623721d960260a7ea2077cf30edc17c4708b7c8d96ab5d9fc7320b45f8113
4
- data.tar.gz: 7afbd0a2835c53520bbbb8d1f323b1643297bc8e641948c614ddc407d6791edd
3
+ metadata.gz: ef30fa1ce21c1dc5c6eaa4634707801f2d32b67ab561231865cc67c0eb2c557f
4
+ data.tar.gz: 9a423c1745dfb3a088137fbe4c912c069a1e65ca68afc0e4d1dac2961819baac
5
5
  SHA512:
6
- metadata.gz: f28f9dff6c4085b5c319744f19c71ae1f373dede9add4a8f5a0bda335bb088ec283e353c151cd28eb5ae71ba2d9b4a6f8c13f45bb7a03d62b687ba706b405ff9
7
- data.tar.gz: 544807b6dbee7af798bce690b6b51e0ada6f168b7681d867d2b57ce634aa3995b1878ba3f3e3dbe3976d6ce55edf9bb6f069f1b4b2722988154b97b86164103c
6
+ metadata.gz: 3b25d996bb14c0bada11737d07a98f93fbd10b8f4781ea3163b79185e31a2b6fe30a99b74e69f63e1d6e768a1fa137124bab9f3a47da07f5940466de1933e2ff
7
+ data.tar.gz: 828ddff25e0930d10bc34c75ee58e789f361f6dc14c3bf24c56b463e8b1327b454cf15ce7536f7d32969716f4c6db82bd9640c3bfcada4a8c9aed072132bc158
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 (2026-09-30)
4
+
5
+ - Removed the `pagy` dependency. Pagination is handled by a small built-in class, so host apps on any Pagy version, or none, can install the gem.
6
+ - Page links now keep the active filters, search query, and `per_page`. Previously every page link dropped them, so page 2 of a filtered list showed unfiltered results.
7
+ - A page number past the end now shows the last page instead of an empty one.
8
+
9
+ ## 0.1.1 (2026-09-30)
10
+
11
+ - The dashboard now works under a strict Content Security Policy. Tailwind CSS is compiled into the gem, and Chart.js, its date adapter, and Chartkick are served from the host app with CSP nonces instead of from CDNs.
12
+ - Replaced inline `onclick` handlers and inline `style` attributes with data attributes and a small bundled script, so clickable rows, the payload Copy button, chart sizing, and progress bars work without `unsafe-inline`.
13
+ - The dashboard no longer loads the host app's importmap JavaScript.
14
+ - Fixed event type and group pages for names and values containing dots, such as `order.placed` or `alice@example.com`. Rails was reading the part after the dot as a format, so these pages rendered empty.
15
+ - Ruby 3.3 or newer is required.
16
+
3
17
  ## 0.1.0 (2026-09-29)
4
18
 
5
19
  Initial release.
data/README.md CHANGED
@@ -1,451 +1,136 @@
1
1
  # RailsEventViewer
2
2
 
3
- A Rails engine for viewing and analyzing events emitted via `Rails.event`.
4
- Provides a web UI dashboard for browsing, filtering, and visualizing domain events in your Rails application.
5
-
6
- ## Features
7
-
8
- - Web-based dashboard for viewing events
9
- - Flexible storage adapters (ActiveRecord, Redis, Memory, or custom)
10
- - High-performance buffered ingestion with configurable batch writes
11
- - Fluent query interface for programmatic access
12
- - Event filtering by name, tags, and time range
13
- - Full-text search across event names and payloads
14
- - Analytics with event counts over time
15
- - Configurable retention and automatic cleanup
16
- - HTTP Basic Auth or custom authentication
3
+ A Rails engine that captures events emitted with `Rails.event` and gives you a dashboard to browse, search, and chart them.
17
4
 
18
5
  ## Requirements
19
6
 
20
7
  - Rails 8.1+
21
- - Ruby 3.2+
8
+ - Ruby 3.3+
22
9
 
23
10
  ## Installation
24
11
 
25
- Add this line to your application's Gemfile:
26
-
27
12
  ```ruby
28
13
  gem "rails_event_viewer"
29
14
  ```
30
15
 
31
- And then execute:
32
-
33
16
  ```bash
34
17
  bundle install
35
- ```
36
-
37
- Run the install generator:
38
-
39
- ```bash
40
18
  rails generate rails_event_viewer:install
41
- ```
42
-
43
- This will:
44
- - Create a migration for the events table (if using ActiveRecord adapter)
45
- - Create an initializer with configuration options
46
- - Mount the engine at `/events`
47
-
48
- Run the migration:
49
-
50
- ```bash
51
- rails db:migrate
52
- ```
53
-
54
- ## Configuration
55
-
56
- Configure RailsEventViewer in `config/initializers/rails_event_viewer.rb`:
57
-
58
- ```ruby
59
- RailsEventViewer.configure do |config|
60
- # Storage adapter: :active_record, :redis, :memory, :null, or a custom class
61
- config.storage_adapter = :active_record
62
-
63
- # Adapter-specific options
64
- config.adapter_options = {}
65
-
66
- # Performance settings
67
- config.async = true # Enable buffered writes
68
- config.buffer_size = 100 # Flush after N events
69
- config.flush_interval = 2 # Flush every N seconds
70
- config.sample_rate = 1.0 # 1.0 = capture all, 0.1 = capture 10%
71
-
72
- # Retention
73
- config.retention_period = 7.days
74
-
75
- # Filtering
76
- config.captured_events = [] # Empty = capture all
77
- config.ignored_events = [] # Events to never capture
78
-
79
- # UI settings
80
- config.per_page = 25
81
-
82
- # Authentication (choose one)
83
- config.http_basic_auth_enabled = true
84
- config.http_basic_auth_user = ENV["RAILS_EVENT_VIEWER_USER"]
85
- config.http_basic_auth_password = ENV["RAILS_EVENT_VIEWER_PASSWORD"]
86
-
87
- # Or use custom authentication
88
- config.authentication = ->(controller) {
89
- controller.authenticate_user!
90
- controller.current_user.admin?
91
- }
92
- end
93
- ```
94
-
95
- ## Storage Adapters
96
-
97
- ### ActiveRecord (default)
98
-
99
- Stores events in your database. Best for most applications with full analytics support.
100
-
101
- ```ruby
102
- config.storage_adapter = :active_record
103
- ```
104
-
105
- The install generator creates the migration for you. If you skipped the generator, copy the migration from the engine instead. Do not do both, or you will get two migrations for the same table.
106
-
107
- ```bash
108
- rails rails_event_viewer:install:migrations
109
19
  rails db:migrate
110
20
  ```
111
21
 
112
- ### Redis
113
-
114
- Stores events in Redis sorted sets. Good for high-volume, ephemeral storage where you don't need long-term persistence.
115
-
116
- First, add the redis gem to your Gemfile:
117
-
118
- ```ruby
119
- gem "redis"
120
- ```
121
-
122
- Then configure the adapter:
123
-
124
- ```ruby
125
- config.storage_adapter = :redis
126
- config.adapter_options = {
127
- redis_options: { url: ENV.fetch("REDIS_URL", "redis://localhost:6379") },
128
- pool_size: 5,
129
- key_prefix: "rails_event_viewer",
130
- max_events: 10_000
131
- }
132
- ```
133
-
134
- Options:
135
- - `redis_options` - Hash passed to `Redis.new` whenever the pool opens a new connection (default: `{}`)
136
- - `pool_size` - Number of pooled Redis connections, should match your app's thread count (default: `5`)
137
- - `pool_timeout` - Seconds to wait for a free connection before raising (default: `5`)
138
- - `key_prefix` - Prefix for Redis keys (default: `"rails_event_viewer"`). The adapter wraps it in braces, so keys look like `{rails_event_viewer}:events`. The braces are a Redis Cluster hash tag that keeps every key on one node, which the adapter's multi-key commands require. Do not add braces yourself.
139
- - `max_events` - Maximum events to retain, older events are automatically trimmed (default: `10_000`)
140
-
141
- The adapter uses a connection pool rather than a single shared connection, since one Redis connection is not safe to use concurrently across multiple threads. Each pooled connection is built independently from `redis_options`. Pass `pool:` with your own `ConnectionPool` for full control.
142
-
143
- Note: Redis adapter performs analytics by fetching events into memory, which may be slower than ActiveRecord for large datasets.
144
-
145
- Filtered queries and analytics scan every stored event. Keep `max_events` modest, around 10,000, if you filter often.
146
-
147
- ### Memory
148
-
149
- In-memory storage for development and testing.
150
-
151
- ```ruby
152
- config.storage_adapter = :memory
153
- config.adapter_options = { max_events: 1000 }
154
- ```
155
-
156
- Options:
157
- - `max_events` - Maximum events to retain (default: `1000`)
158
-
159
- Note: Events are lost when the server process restarts. Not recommended for production use.
160
-
161
- ### Null
162
-
163
- Discards all events. Useful for disabling event capture in specific environments.
164
-
165
- ```ruby
166
- config.storage_adapter = :null
167
- ```
22
+ The generator creates the migration and an initializer, and mounts the dashboard at `/events`.
168
23
 
169
- ### Switching Adapters via Environment Variable
24
+ ## Usage
170
25
 
171
- A common pattern is to use different adapters per environment:
26
+ Emit events with the standard Rails API. RailsEventViewer subscribes automatically.
172
27
 
173
28
  ```ruby
174
- RailsEventViewer.configure do |config|
175
- adapter = ENV.fetch("EVENT_STORAGE", "active_record").to_sym
176
- config.storage_adapter = adapter
177
-
178
- case adapter
179
- when :redis
180
- config.adapter_options = {
181
- redis_options: { url: ENV.fetch("REDIS_URL", "redis://localhost:6379") },
182
- max_events: 10_000
183
- }
184
- when :memory
185
- config.adapter_options = { max_events: 1000 }
186
- end
187
- end
188
- ```
189
-
190
- Then switch adapters when starting your server:
191
-
192
- ```bash
193
- # Use ActiveRecord (default)
194
- bin/rails server
195
-
196
- # Use Redis
197
- EVENT_STORAGE=redis bin/rails server
198
-
199
- # Use Memory
200
- EVENT_STORAGE=memory bin/rails server
29
+ Rails.event.notify("order.placed", order_id: order.id, total: order.total)
201
30
  ```
202
31
 
203
- ### Custom Adapter
204
-
205
- Create your own adapter by including the `RailsEventViewer::Adapter` module:
32
+ Query them from code:
206
33
 
207
34
  ```ruby
208
- class MyCustomAdapter
209
- include RailsEventViewer::Adapter
210
-
211
- def write_events(events)
212
- end
213
-
214
- def fetch_events(relation)
215
- end
216
-
217
- def count_events(relation)
218
- end
219
-
220
- def distinct_event_names
221
- end
222
-
223
- def find_event(id)
224
- end
225
-
226
- def delete_before(timestamp)
227
- end
228
-
229
- def distinct_group_values(key, source: :context)
230
- end
231
-
232
- def group_instances(key, source: :context, limit: 100)
233
- end
234
-
235
- def events_over_time(since:, interval:)
236
- # Return { time => count }
237
- end
238
-
239
- def counts_by_name(limit:)
240
- # Return { name => count }, highest first
241
- end
242
-
243
- def count_since(since)
244
- # Return count of events since a time
245
- end
246
- end
247
-
248
- config.storage_adapter = MyCustomAdapter
249
- ```
250
-
251
- ## Emitting Events
252
-
253
- RailsEventViewer automatically subscribes to `Rails.event`. Emit events using the standard Rails API:
254
-
255
- ```ruby
256
- Rails.event.notify("user.created", user_id: user.id, email: user.email)
257
-
258
- Rails.event.notify(
259
- "order.placed",
260
- order_id: order.id,
261
- total: order.total,
262
- tags: { environment: Rails.env, priority: "high" }
263
- )
264
- ```
265
-
266
- ## Programmatic Access
267
-
268
- Use the fluent query interface to access events:
269
-
270
- ```ruby
271
- # Get all events
272
- RailsEventViewer.events.each { |e| puts e[:name] }
273
-
274
- # Filter by name
275
- RailsEventViewer.events.with_name("user.created").to_a
276
-
277
- # Filter by tag
278
- RailsEventViewer.events.with_tag(:environment, "production").to_a
279
-
280
- # Time range
281
- RailsEventViewer.events.since(1.hour.ago).until(30.minutes.ago).to_a
282
-
283
- # Search
284
- RailsEventViewer.events.search("user@example.com").to_a
285
-
286
- # Combine filters
287
35
  RailsEventViewer.events
288
36
  .with_name("order.placed")
289
37
  .with_tag(:priority, "high")
290
38
  .since(1.day.ago)
291
39
  .limit(50)
292
- .each { |e| process(e) }
293
-
294
- # Count
295
- RailsEventViewer.events.with_name("user.created").count
296
-
297
- # Pagination
298
- RailsEventViewer.events.offset(25).limit(25).to_a
299
- ```
300
-
301
- ## Rake Tasks
302
-
303
- ```bash
304
- # Show statistics
305
- bin/rails rails_event_viewer:stats
306
-
307
- # Clean up old events (based on retention_period)
308
- bin/rails rails_event_viewer:cleanup
309
-
310
- # Flush buffered events
311
- bin/rails rails_event_viewer:flush
312
-
313
- # Clear all events (interactive confirmation required)
314
- bin/rails rails_event_viewer:clear
315
- ```
316
-
317
- For automatic cleanup, add to your scheduler:
318
-
319
- ```yaml
320
- # config/recurring.yml (Solid Queue)
321
- production:
322
- rails_event_viewer_cleanup:
323
- command: "RailsEventViewer.adapter.delete_before(RailsEventViewer.retention_period.ago)"
324
- schedule: every day at 3am
40
+ .to_a
325
41
  ```
326
42
 
327
- Or using the whenever gem:
328
-
329
- ```ruby
330
- # config/schedule.rb (whenever gem)
331
- every 1.day, at: "3:00 am" do
332
- rake "rails_event_viewer:cleanup"
333
- end
334
- ```
43
+ Other filters: `with_context`, `search`, `until`, `offset`, and `count`.
335
44
 
336
- ## Event Filtering
45
+ ## Configuration
337
46
 
338
- Control which events are captured:
47
+ `config/initializers/rails_event_viewer.rb`:
339
48
 
340
49
  ```ruby
341
- # Only capture specific events
342
- config.captured_events = ["user.created", "order.placed"]
50
+ RailsEventViewer.configure do |config|
51
+ config.storage_adapter = :active_record # :active_record, :redis, :memory, :null, or a class
343
52
 
344
- # Or use regex patterns
345
- config.captured_events = [/^user\./, /^order\./]
53
+ config.async = true # buffer writes in a background thread
54
+ config.buffer_size = 100 # flush after this many events
55
+ config.flush_interval = 2 # or after this many seconds
56
+ config.sample_rate = 1.0 # 0.1 keeps 10% of events
346
57
 
347
- # Ignore specific events
348
- config.ignored_events = ["internal.heartbeat", /^debug\./]
58
+ config.captured_events = [] # empty captures everything
59
+ config.ignored_events += [/^debug\./] # strings or regexes
60
+ config.retention_period = 7.days # used by the cleanup task
61
+ end
349
62
  ```
350
63
 
351
- ## Sampling
352
-
353
- For high-volume applications, reduce storage by sampling if needed:
354
-
355
- ```ruby
356
- # Capture only 10% of events
357
- config.sample_rate = 0.1
358
- ```
64
+ Internal Rails events such as `active_record.*` and `action_controller.*` are ignored by default. Remove a pattern from `ignored_events` to capture it.
359
65
 
360
66
  ## Authentication
361
67
 
362
- If neither option below is configured, the dashboard is open in development and test, and returns `403 Forbidden` in every other environment, including staging. Enabling HTTP Basic Auth with a blank user or password also returns `403 Forbidden`.
363
-
364
- Set `RAILS_ENV` on every deployed server. If it is missing, Rails falls back to development and the dashboard is open.
365
-
366
- ### HTTP Basic Auth
68
+ Without authentication, the dashboard is open in development and test, and returns `403` everywhere else.
367
69
 
368
70
  ```ruby
369
71
  config.http_basic_auth_enabled = true
370
72
  config.http_basic_auth_user = ENV["RAILS_EVENT_VIEWER_USER"]
371
73
  config.http_basic_auth_password = ENV["RAILS_EVENT_VIEWER_PASSWORD"]
372
- ```
373
74
 
374
- ### Custom Authentication
375
-
376
- ```ruby
377
- # Devise example
75
+ # or
378
76
  config.authentication = ->(controller) {
379
77
  controller.authenticate_user!
380
78
  controller.current_user.admin?
381
79
  }
382
80
  ```
383
81
 
384
- ## Routes
385
-
386
- The engine is mounted at the path specified in your routes (default `/events`):
387
-
388
- ```ruby
389
- # config/routes.rb
390
- mount RailsEventViewer::Engine, at: "/events"
391
- ```
82
+ Set `RAILS_ENV` on every server. Without it, Rails falls back to development and the dashboard is open.
392
83
 
393
- Available routes:
394
- - `/events` - Dashboard
395
- - `/events/events` - Event list with filtering
396
- - `/events/events/:id` - Single event detail
397
- - `/events/analytics/overview` - Analytics dashboard
398
- - `/events/groups` - Event groups
84
+ ## Storage adapters
399
85
 
400
- ## How Event Capture Works
86
+ | Adapter | Use it for |
87
+ |---|---|
88
+ | `:active_record` | The default. Stores events in your database, with full analytics. |
89
+ | `:redis` | High volume, short-lived storage. Keeps the newest `max_events`. |
90
+ | `:memory` | Development and tests. Lost on restart. |
91
+ | `:null` | Disables storage. |
401
92
 
402
- RailsEventViewer subscribes to `Rails.event` (the Structured Event Reporter introduced in Rails 8.1),
403
- which is built on top of `ActiveSupport::Notifications`. This means internal Rails instrumentation
404
- events (`sql.active_record`, `process_action.action_controller`, etc.) are received by the subscriber.
93
+ Redis needs the `redis` gem:
405
94
 
406
- By default, all internal Rails framework events are excluded via the built-in `ignored_events` patterns.
407
- This prevents noise from flooding your storage.
95
+ ```ruby
96
+ config.storage_adapter = :redis
97
+ config.adapter_options = {
98
+ redis_options: { url: ENV["REDIS_URL"] },
99
+ pool_size: 5,
100
+ max_events: 10_000
101
+ }
102
+ ```
408
103
 
409
- **Captured by default:**
410
- - Application events emitted via `Rails.event.notify("my.event", ...)`
104
+ Filtered queries on Redis scan every stored event, so keep `max_events` modest.
411
105
 
412
- **Ignored by default** (via `ignored_events`):
413
- - `active_record.*`, `action_controller.*`, `action_view.*`, `active_job.*`
414
- - `action_mailer.*`, `active_storage.*`, `action_cable.*`, `rails.*`
415
- - `turbo.*`, `cache_*`, `*.sql`, `solid_*`
106
+ For a custom adapter, include `RailsEventViewer::Adapter` and implement the methods it lists.
416
107
 
417
- To capture internal Rails events, remove the relevant pattern from `ignored_events`:
108
+ ## Rake tasks
418
109
 
419
- ```ruby
420
- RailsEventViewer.configure do |config|
421
- config.ignored_events = RailsEventViewer.ignored_events.reject { |p| p == /^active_record\./ }
422
- end
110
+ ```bash
111
+ bin/rails rails_event_viewer:stats # counts and top events
112
+ bin/rails rails_event_viewer:cleanup # delete events older than retention_period
113
+ bin/rails rails_event_viewer:flush # write buffered events now
114
+ bin/rails rails_event_viewer:clear # delete everything, asks first
423
115
  ```
424
116
 
425
- ## Development
117
+ Schedule `cleanup` daily, for example with Solid Queue in `config/recurring.yml`:
426
118
 
427
- After checking out the repo, run:
428
-
429
- ```bash
430
- cd test/dummy
431
- bin/rails db:migrate
432
- bin/rails server
119
+ ```yaml
120
+ production:
121
+ rails_event_viewer_cleanup:
122
+ command: "RailsEventViewer.adapter.delete_before(RailsEventViewer.retention_period.ago)"
123
+ schedule: every day at 3am
433
124
  ```
434
125
 
435
- Run the test suite:
126
+ ## Development
436
127
 
437
128
  ```bash
129
+ bundle install
438
130
  bundle exec rake test
131
+ cd test/dummy && bin/rails db:migrate && bin/rails server
439
132
  ```
440
133
 
441
- ## Contributing
442
-
443
- 1. Fork it
444
- 2. Create your feature branch (`git checkout -b feature/my-new-feature`)
445
- 3. Commit your changes (`git commit -am 'Add some feature'`)
446
- 4. Push to the branch (`git push origin feature/my-new-feature`)
447
- 5. Create a Pull Request
448
-
449
134
  ## License
450
135
 
451
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
136
+ MIT.
@@ -0,0 +1,28 @@
1
+ document.addEventListener("DOMContentLoaded", () => {
2
+ document.querySelectorAll("[data-event-viewer-width]").forEach((bar) => {
3
+ bar.style.width = `${bar.dataset.eventViewerWidth}%`
4
+ })
5
+ })
6
+
7
+ document.addEventListener("click", (event) => {
8
+ const copyButton = event.target.closest("[data-event-viewer-copy]")
9
+ if (copyButton) {
10
+ if (!navigator.clipboard || copyButton.dataset.eventViewerCopied) return
11
+
12
+ navigator.clipboard.writeText(copyButton.dataset.eventViewerCopy).then(() => {
13
+ const label = copyButton.textContent
14
+ copyButton.dataset.eventViewerCopied = "true"
15
+ copyButton.textContent = "Copied!"
16
+ setTimeout(() => {
17
+ copyButton.textContent = label
18
+ delete copyButton.dataset.eventViewerCopied
19
+ }, 1500)
20
+ })
21
+ return
22
+ }
23
+
24
+ const row = event.target.closest("[data-event-viewer-href]")
25
+ if (row && !event.target.closest("a, button")) {
26
+ window.location = row.dataset.eventViewerHref
27
+ }
28
+ })