faultline-rails 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -0
  3. data/app/assets/stylesheets/faultline/application.css +0 -41
  4. data/app/assets/stylesheets/faultline/dashboard.css +961 -0
  5. data/app/controllers/faultline/logged_exceptions_controller.rb +81 -8
  6. data/app/helpers/faultline/logged_exceptions_helper.rb +84 -5
  7. data/app/javascript/controllers/faultline_controller.js +349 -7
  8. data/app/javascript/faultline/controllers/sidebar_controller.js +21 -0
  9. data/app/javascript/faultline/controllers/theme_controller.js +48 -0
  10. data/app/models/faultline/logged_exception.rb +10 -0
  11. data/app/views/faultline/logged_exceptions/_exceptions.html.erb +132 -84
  12. data/app/views/faultline/logged_exceptions/_show.html.erb +281 -60
  13. data/app/views/faultline/logged_exceptions/index.html.erb +192 -123
  14. data/app/views/faultline/logged_exceptions/show.html.erb +1 -1
  15. data/app/views/layouts/faultline/application.html.erb +116 -51
  16. data/faultline-rails.gemspec +1 -0
  17. data/lib/faultline/cleanup.rb +32 -0
  18. data/lib/faultline/configuration.rb +172 -4
  19. data/lib/faultline/engine.rb +22 -0
  20. data/lib/faultline/logger.rb +188 -0
  21. data/lib/faultline/notifications.rb +80 -0
  22. data/lib/faultline/ransack_config.rb +2 -24
  23. data/lib/faultline/version.rb +1 -1
  24. data/lib/faultline.rb +10 -0
  25. data/lib/generators/faultline/customize_generator.rb +117 -0
  26. data/lib/generators/faultline/install_generator.rb +15 -0
  27. data/lib/generators/faultline/templates/faultline.rb +70 -37
  28. data/lib/generators/faultline/templates/javascript/controllers/faultline_controller.js +310 -0
  29. data/lib/generators/faultline/templates/views/faultline/logged_exceptions/_exceptions.html.erb +124 -0
  30. data/lib/generators/faultline/templates/views/faultline/logged_exceptions/_show.html.erb +83 -0
  31. data/lib/generators/faultline/templates/views/faultline/logged_exceptions/index.html.erb +113 -0
  32. data/lib/generators/faultline/templates/views/layouts/faultline/application.html.erb +133 -0
  33. data/lib/tasks/faultline.rake +147 -0
  34. metadata +15 -1
@@ -4,20 +4,24 @@ module Faultline
4
4
  class LoggedExceptionsController < ApplicationController
5
5
  before_action :faultline_require_auth!
6
6
 
7
- helper_method :params_filters
7
+ helper_method :params_filters, :prev_exception, :next_exception
8
8
 
9
9
  def index
10
- @exception_names = LoggedException.class_names
11
- @controller_actions = LoggedException.controller_actions
10
+ load_exception_names
11
+ load_controller_actions
12
12
  @q = ransack_search
13
13
  @exceptions = @q.result(distinct: true)
14
- .paginate(page: params[:page], per_page: Faultline.configuration.per_page || 30)
14
+ .paginate(page: params[:page], per_page: page_size)
15
+ load_stats if Faultline.configuration.show_stats
15
16
  end
16
17
 
17
18
  def query
19
+ load_exception_names
20
+ load_controller_actions
18
21
  @q = ransack_search
19
22
  @exceptions = @q.result(distinct: true)
20
- .paginate(page: params[:page], per_page: Faultline.configuration.per_page || 30)
23
+ .paginate(page: params[:page], per_page: page_size)
24
+ load_stats if Faultline.configuration.show_stats
21
25
 
22
26
  respond_to do |format|
23
27
  format.turbo_stream
@@ -35,6 +39,8 @@ module Faultline
35
39
 
36
40
  def show
37
41
  @exception = LoggedException.find(params[:id])
42
+ load_nav_exceptions if Faultline.configuration.enable_navigation
43
+ load_exception_tabs
38
44
 
39
45
  respond_to do |format|
40
46
  format.turbo_stream
@@ -60,9 +66,11 @@ module Faultline
60
66
  end
61
67
  exceptions.delete_all
62
68
 
69
+ load_exception_names
70
+ load_controller_actions
63
71
  @q = ransack_search
64
72
  @exceptions = @q.result(distinct: true)
65
- .paginate(page: params[:page], per_page: Faultline.configuration.per_page || 30)
73
+ .paginate(page: params[:page], per_page: page_size)
66
74
 
67
75
  respond_to do |format|
68
76
  format.turbo_stream
@@ -73,9 +81,11 @@ module Faultline
73
81
  def clear
74
82
  LoggedException.delete_all
75
83
 
84
+ load_exception_names
85
+ load_controller_actions
76
86
  @q = ransack_search
77
87
  @exceptions = @q.result(distinct: true)
78
- .paginate(page: params[:page], per_page: Faultline.configuration.per_page || 30)
88
+ .paginate(page: params[:page], per_page: page_size)
79
89
 
80
90
  respond_to do |format|
81
91
  format.turbo_stream
@@ -96,6 +106,12 @@ module Faultline
96
106
  main_app.respond_to?(:root_path) ? main_app.root_path : "/"
97
107
  end
98
108
 
109
+ def page_size
110
+ requested = params[:per_page].to_i
111
+ options = Faultline.configuration.page_size_options
112
+ options.include?(requested) ? requested : (Faultline.configuration.per_page || 30)
113
+ end
114
+
99
115
  def ransack_search
100
116
  if defined?(Ransack)
101
117
  LoggedException.ransack(params[:q])
@@ -111,7 +127,8 @@ module Faultline
111
127
  query: params[:query],
112
128
  date_ranges_filter: params[:date_ranges_filter],
113
129
  exception_names_filter: params[:exception_names_filter],
114
- controller_actions_filter: params[:controller_actions_filter]
130
+ controller_actions_filter: params[:controller_actions_filter],
131
+ per_page: params[:per_page]
115
132
  }.compact
116
133
  end
117
134
 
@@ -130,5 +147,61 @@ module Faultline
130
147
 
131
148
  exceptions
132
149
  end
150
+
151
+ def load_exception_names
152
+ @exception_names = LoggedException.class_names
153
+ end
154
+
155
+ def load_controller_actions
156
+ @controller_actions = LoggedException.controller_actions
157
+ end
158
+
159
+ def load_stats
160
+ total = LoggedException.count
161
+ today = LoggedException.where("created_at >= ?", Time.zone.today.beginning_of_day).count
162
+ week = LoggedException.where("created_at >= ?", 7.days.ago.beginning_of_day).count
163
+ yesterday = LoggedException.where("created_at >= ?", 1.day.ago.beginning_of_day)
164
+ .where("created_at < ?", Time.zone.today.beginning_of_day).count
165
+
166
+ @stats = {
167
+ total: total,
168
+ today: today,
169
+ this_week: week,
170
+ yesterday: yesterday,
171
+ top_classes: LoggedException.group(:exception_class).limit(5).count,
172
+ top_controllers: LoggedException.group(:controller_name).limit(5).count
173
+ }
174
+ end
175
+
176
+ def load_nav_exceptions
177
+ scope = filtered_scope.sorted
178
+ ids = scope.pluck(:id)
179
+ current_idx = ids.index(@exception.id)
180
+
181
+ if current_idx
182
+ @prev_exception_id = ids[current_idx + 1]
183
+ @next_exception_id = ids[current_idx - 1]
184
+ end
185
+ end
186
+
187
+ def prev_exception
188
+ return nil unless @prev_exception_id
189
+ OpenStruct.new(id: @prev_exception_id)
190
+ end
191
+
192
+ def next_exception
193
+ return nil unless @next_exception_id
194
+ OpenStruct.new(id: @next_exception_id)
195
+ end
196
+
197
+ def load_exception_tabs
198
+ @exception_tabs = {
199
+ overview: true,
200
+ backtrace: @exception.backtrace.present?,
201
+ request: @exception.request.present?,
202
+ environment: @exception.environment.present?,
203
+ user_info: @exception.user_info.present?
204
+ }
205
+ end
133
206
  end
134
207
  end
@@ -3,10 +3,28 @@
3
3
  module Faultline
4
4
  module LoggedExceptionsHelper
5
5
  def pretty_exception_date(exception)
6
- if Date.today == exception.created_at.to_date
7
- "Today, #{exception.created_at.strftime(Time::DATE_FORMATS[:exc_time])}"
6
+ if Faultline.configuration.date_format
7
+ return exception.created_at.strftime(Faultline.configuration.date_format)
8
+ end
9
+
10
+ time = exception.created_at
11
+ diff = Time.current - time
12
+
13
+ case
14
+ when diff < 60
15
+ "#{(diff).to_i}s ago"
16
+ when diff < 3600
17
+ "#{(diff / 60).to_i}m ago"
18
+ when diff < 86400
19
+ "#{(diff / 3600).to_i}h ago"
20
+ when diff < 604800
21
+ "#{(diff / 86400).to_i}d ago"
22
+ when Date.today == time.to_date
23
+ "Today, #{time.strftime('%I:%M %p')}"
24
+ when Date.yesterday == time.to_date
25
+ "Yesterday, #{time.strftime('%I:%M %p')}"
8
26
  else
9
- exception.created_at.strftime(Time::DATE_FORMATS[:exc_date])
27
+ time.strftime('%b %d, %Y %I:%M %p')
10
28
  end
11
29
  end
12
30
 
@@ -35,16 +53,77 @@ module Faultline
35
53
  end
36
54
 
37
55
  # Sort link helper for Ransack-compatible table headers.
38
- # Falls back to plain link if Ransack is not available.
39
56
  def sort_link(search, attribute, name = nil, **options, &block)
40
57
  name ||= attribute.to_s.humanize
41
58
 
42
59
  if defined?(Ransack) && search.respond_to?(:result)
43
- # Delegate to Ransack's built-in sort_link helper
44
60
  Ransack::Helpers::FormHelper.instance_method(:sort_link).bind(self).call(search, attribute, name, **options, &block)
45
61
  else
46
62
  link_to name, "#", **options, &block
47
63
  end
48
64
  end
65
+
66
+ # Format backtrace for display with optional collapse
67
+ def format_backtrace(backtrace, limit: nil)
68
+ limit ||= Faultline.configuration.backtrace_limit
69
+ lines = backtrace.to_s.split("\n")
70
+
71
+ if lines.length > limit
72
+ collapsed = lines[0...limit]
73
+ remaining = lines[limit..]
74
+ {
75
+ visible: collapsed.join("\n"),
76
+ hidden: remaining.join("\n"),
77
+ hidden_count: remaining.length
78
+ }
79
+ else
80
+ { visible: lines.join("\n"), hidden: nil, hidden_count: 0 }
81
+ end
82
+ end
83
+
84
+ # Severity badge class based on exception type
85
+ def severity_for(exception)
86
+ case exception.exception_class
87
+ when /Timeout|Slow|Performance/i
88
+ "warning"
89
+ when /404|NotFound|Missing/i
90
+ "info"
91
+ else
92
+ "danger"
93
+ end
94
+ end
95
+
96
+ # Active filter pills for the filter bar
97
+ def active_filter_pills
98
+ pills = []
99
+
100
+ if params[:q].present?
101
+ params[:q].each do |key, value|
102
+ next if value.blank?
103
+ pills << { label: key.to_s.sub(/_(cont|eq|gteq|lteq)$/, "").humanize, value: value, param: "q[#{key}]" }
104
+ end
105
+ end
106
+
107
+ if params[:date_ranges_filter].present?
108
+ label = { "1" => "Today", "3" => "Last 3 days", "7" => "Last 7 days", "30" => "Last 30 days" }[params[:date_ranges_filter]] || "Custom"
109
+ pills << { label: label, value: params[:date_ranges_filter], param: "date_ranges_filter" }
110
+ end
111
+
112
+ pills
113
+ end
114
+
115
+ # Count occurrences of a specific exception class
116
+ def exception_count_for(exception_class)
117
+ LoggedException.where(exception_class: exception_class).count
118
+ end
119
+
120
+ # Simple sparkline data for stats
121
+ def exception_trend_data(days: 7)
122
+ data = (0...days).reverse_each.map do |i|
123
+ date = i.days.ago.to_date
124
+ LoggedException.where(created_at: date.beginning_of_day..date.end_of_day).count
125
+ end
126
+ data
127
+ end
49
128
  end
50
129
  end
@@ -1,27 +1,375 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ["activity"]
4
+ static targets = ["activity", "shortcutsDialog", "tabs", "tabContent", "exceptionList", "backtraceHidden", "backtraceContent"]
5
+ static values = { shortcuts: Boolean }
5
6
 
6
7
  connect() {
8
+ this.selectedIndex = -1
9
+ this.rows = []
10
+
7
11
  this.showActivity = this.showActivity.bind(this)
8
12
  this.hideActivity = this.hideActivity.bind(this)
9
13
 
10
14
  document.addEventListener("turbo:before-fetch-request", this.showActivity)
11
15
  document.addEventListener("turbo:before-fetch-response", this.hideActivity)
12
16
  document.addEventListener("turbo:submit-end", this.hideActivity)
17
+
18
+ if (this.shortcutsValue) {
19
+ this.boundKeydown = this.handleKeydown.bind(this)
20
+ document.addEventListener("keydown", this.boundKeydown)
21
+ }
13
22
  }
14
23
 
15
24
  disconnect() {
16
25
  document.removeEventListener("turbo:before-fetch-request", this.showActivity)
17
26
  document.removeEventListener("turbo:before-fetch-response", this.hideActivity)
18
27
  document.removeEventListener("turbo:submit-end", this.hideActivity)
28
+
29
+ if (this.boundKeydown) {
30
+ document.removeEventListener("keydown", this.boundKeydown)
31
+ }
32
+ }
33
+
34
+ // ─── Keyboard Navigation ──────────────────────────────────
35
+
36
+ handleKeydown(event) {
37
+ // Ignore when typing in an input/textarea/select
38
+ const tag = event.target.tagName.toLowerCase()
39
+ if (tag === "input" || tag === "textarea" || tag === "select") {
40
+ if (event.key === "Escape") {
41
+ event.target.blur()
42
+ event.preventDefault()
43
+ }
44
+ return
45
+ }
46
+
47
+ switch (event.key) {
48
+ case "/":
49
+ event.preventDefault()
50
+ this.focusSearch()
51
+ break
52
+ case "j":
53
+ case "ArrowDown":
54
+ event.preventDefault()
55
+ this.navigateDown()
56
+ break
57
+ case "k":
58
+ case "ArrowUp":
59
+ event.preventDefault()
60
+ this.navigateUp()
61
+ break
62
+ case "Enter":
63
+ event.preventDefault()
64
+ this.openSelected()
65
+ break
66
+ case "p":
67
+ case "ArrowLeft":
68
+ event.preventDefault()
69
+ this.navigateToPrev()
70
+ break
71
+ case "n":
72
+ case "ArrowRight":
73
+ event.preventDefault()
74
+ this.navigateToNext()
75
+ break
76
+ case "t":
77
+ event.preventDefault()
78
+ this.toggleTheme()
79
+ break
80
+ case "Escape":
81
+ event.preventDefault()
82
+ this.closeDetails(event)
83
+ this.closeShortcuts()
84
+ break
85
+ case "?":
86
+ event.preventDefault()
87
+ this.toggleShortcuts()
88
+ break
89
+ case "1": case "2": case "3": case "4": case "5":
90
+ event.preventDefault()
91
+ this.switchTabByIndex(parseInt(event.key) - 1)
92
+ break
93
+ }
94
+ }
95
+
96
+ focusSearch() {
97
+ const search = this.element.querySelector(".fl-search .fl-input, input[type='search']")
98
+ if (search) {
99
+ search.focus()
100
+ search.select()
101
+ }
102
+ }
103
+
104
+ refreshRows() {
105
+ if (this.hasExceptionListTarget) {
106
+ this.rows = Array.from(this.exceptionListTarget.querySelectorAll("tr[data-url]"))
107
+ } else {
108
+ this.rows = Array.from(this.element.querySelectorAll("tr[data-url]"))
109
+ }
110
+ }
111
+
112
+ navigateDown() {
113
+ this.refreshRows()
114
+ if (this.rows.length === 0) return
115
+
116
+ this.selectedIndex = Math.min(this.selectedIndex + 1, this.rows.length - 1)
117
+ this.highlightRow()
118
+ }
119
+
120
+ navigateUp() {
121
+ this.refreshRows()
122
+ if (this.rows.length === 0) return
123
+
124
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0)
125
+ this.highlightRow()
126
+ }
127
+
128
+ highlightRow() {
129
+ this.rows.forEach((row, i) => {
130
+ row.classList.toggle("selected", i === this.selectedIndex)
131
+ })
132
+
133
+ const row = this.rows[this.selectedIndex]
134
+ if (row) {
135
+ row.scrollIntoView({ block: "nearest", behavior: "smooth" })
136
+ }
137
+ }
138
+
139
+ openSelected() {
140
+ this.refreshRows()
141
+ if (this.selectedIndex < 0 || this.selectedIndex >= this.rows.length) return
142
+
143
+ const row = this.rows[this.selectedIndex]
144
+ const url = row.dataset.url
145
+ if (url) {
146
+ // Fetch and load into the detail panel
147
+ this.selectRow({ currentTarget: row, preventDefault: () => {} })
148
+ }
149
+ }
150
+
151
+ // ─── Row Selection ────────────────────────────────────────
152
+
153
+ selectRow(event) {
154
+ event.preventDefault()
155
+ const row = event.currentTarget
156
+ const url = row.dataset.url
157
+ if (!url) return
158
+
159
+ // Highlight selected row
160
+ this.refreshRows()
161
+ this.rows.forEach(r => r.classList.remove("selected"))
162
+ row.classList.add("selected")
163
+
164
+ // Show detail panel
165
+ const detailPanel = document.getElementById("exception-detail-desktop")
166
+ if (detailPanel) {
167
+ detailPanel.hidden = false
168
+ detailPanel.classList.remove("hidden")
169
+ }
170
+
171
+ // Load content via Turbo
172
+ const frame = document.getElementById("exception-details")
173
+ if (frame) {
174
+ frame.innerHTML = ""
175
+ frame.src = url
176
+ }
177
+ }
178
+
179
+ stopPropagation(event) {
180
+ event.stopPropagation()
181
+ }
182
+
183
+ // ─── Close Details ────────────────────────────────────────
184
+
185
+ closeDetails(event) {
186
+ event.preventDefault()
187
+
188
+ // Deselect all rows
189
+ this.refreshRows()
190
+ this.rows.forEach(r => r.classList.remove("selected"))
191
+ this.selectedIndex = -1
192
+
193
+ // Hide detail panel
194
+ const detailPanel = document.getElementById("exception-detail-desktop")
195
+ if (detailPanel) {
196
+ detailPanel.hidden = true
197
+ detailPanel.classList.add("hidden")
198
+ }
199
+
200
+ // Clear the turbo frame
201
+ const frame = document.getElementById("exception-details")
202
+ if (frame) {
203
+ frame.innerHTML = `
204
+ <div class="text-center py-12">
205
+ <div class="w-10 h-10 rounded-full flex items-center justify-center mx-auto" style="background: var(--fl-bg-secondary)">
206
+ <svg class="w-5 h-5" style="color: var(--fl-text-muted)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
207
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
208
+ </svg>
209
+ </div>
210
+ <p class="mt-2 text-xs" style="color: var(--fl-text-muted)">Select an exception to view details</p>
211
+ <p class="mt-1 text-xs" style="color: var(--fl-text-muted)">Click a row or press <span class="fl-kbd">↓</span> <span class="fl-kbd">↑</span> to navigate</p>
212
+ </div>
213
+ `
214
+ }
215
+ }
216
+
217
+ // ─── Tab Switching ────────────────────────────────────────
218
+
219
+ switchTab(event) {
220
+ const tabName = event.currentTarget.dataset.flTabParam || event.currentTarget.dataset.flTab
221
+ this.activateTab(tabName)
222
+ }
223
+
224
+ switchTabByIndex(index) {
225
+ if (!this.hasTabsTarget) return
226
+ const tabs = this.tabsTarget.querySelectorAll(".fl-tab")
227
+ if (index >= 0 && index < tabs.length) {
228
+ const tabName = tabs[index].dataset.flTabParam || tabs[index].dataset.flTab
229
+ this.activateTab(tabName)
230
+ }
19
231
  }
20
232
 
233
+ activateTab(tabName) {
234
+ // Update tab buttons
235
+ if (this.hasTabsTarget) {
236
+ this.tabsTarget.querySelectorAll(".fl-tab").forEach(tab => {
237
+ const isActive = (tab.dataset.flTabParam || tab.dataset.flTab) === tabName
238
+ tab.classList.toggle("active", isActive)
239
+ })
240
+ }
241
+
242
+ // Show/hide tab content
243
+ this.tabContentTargets.forEach(content => {
244
+ const isTarget = content.dataset.flTabContent === tabName
245
+ content.style.display = isTarget ? "" : "none"
246
+ })
247
+ }
248
+
249
+ // ─── Theme Toggle ─────────────────────────────────────────
250
+
251
+ toggleTheme() {
252
+ const themeController = this.application.getControllerForElementAndIdentifier(
253
+ document.documentElement,
254
+ "faultline--theme"
255
+ )
256
+ if (themeController) {
257
+ themeController.toggle()
258
+ } else {
259
+ // Fallback: direct DOM manipulation
260
+ const root = document.documentElement
261
+ const isDark = root.classList.contains("dark")
262
+ root.classList.remove("dark", "light")
263
+ root.classList.add(isDark ? "light" : "dark")
264
+ localStorage.setItem("faultline-theme", isDark ? "light" : "dark")
265
+ }
266
+ }
267
+
268
+ // ─── Keyboard Shortcuts Dialog ────────────────────────────
269
+
270
+ toggleShortcuts() {
271
+ if (!this.hasShortcutsDialogTarget) return
272
+ const isOpen = this.shortcutsDialogTarget.classList.contains("open")
273
+ if (isOpen) {
274
+ this.closeShortcuts()
275
+ } else {
276
+ this.shortcutsDialogTarget.classList.add("open")
277
+ }
278
+ }
279
+
280
+ closeShortcuts() {
281
+ if (this.hasShortcutsDialogTarget) {
282
+ this.shortcutsDialogTarget.classList.remove("open")
283
+ }
284
+ }
285
+
286
+ // ─── Navigation (prev/next) ───────────────────────────────
287
+
288
+ navigateToPrev() {
289
+ const prevLink = document.querySelector(".fl-nav-link[href*='logged_exceptions']")
290
+ if (prevLink && prevLink.querySelector(".fl-nav-link-label")?.textContent === "Previous") {
291
+ prevLink.click()
292
+ }
293
+ }
294
+
295
+ navigateToNext() {
296
+ const links = document.querySelectorAll(".fl-nav-link[href*='logged_exceptions']")
297
+ const nextLink = Array.from(links).find(link =>
298
+ link.querySelector(".fl-nav-link-label")?.textContent === "Next"
299
+ )
300
+ if (nextLink) nextLink.click()
301
+ }
302
+
303
+ // ─── Copy to Clipboard ───────────────────────────────────
304
+
305
+ copyId(event) {
306
+ const id = event.currentTarget.dataset.faultlineIdValue
307
+ if (id) this.copyToClipboard(id, event.currentTarget)
308
+ }
309
+
310
+ copyMessage(event) {
311
+ const message = event.currentTarget.dataset.faultlineMessageValue
312
+ if (message) this.copyToClipboard(message, event.currentTarget)
313
+ }
314
+
315
+ copyBacktrace(event) {
316
+ const backtrace = event.currentTarget.dataset.faultlineBacktraceValue
317
+ if (backtrace) this.copyToClipboard(backtrace, event.currentTarget)
318
+ }
319
+
320
+ async copyToClipboard(text, button) {
321
+ try {
322
+ await navigator.clipboard.writeText(text)
323
+ const originalHTML = button.innerHTML
324
+ button.classList.add("copied")
325
+ button.innerHTML = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg> Copied!`
326
+ setTimeout(() => {
327
+ button.classList.remove("copied")
328
+ button.innerHTML = originalHTML
329
+ }, 2000)
330
+ } catch (err) {
331
+ // Fallback for older browsers
332
+ const textarea = document.createElement("textarea")
333
+ textarea.value = text
334
+ textarea.style.position = "fixed"
335
+ textarea.style.opacity = "0"
336
+ document.body.appendChild(textarea)
337
+ textarea.select()
338
+ document.execCommand("copy")
339
+ document.body.removeChild(textarea)
340
+ }
341
+ }
342
+
343
+ // ─── Backtrace Expand ─────────────────────────────────────
344
+
345
+ expandBacktrace(event) {
346
+ event.preventDefault()
347
+ const hidden = this.backtraceHiddenTarget
348
+ if (hidden) {
349
+ hidden.style.display = hidden.style.display === "none" ? "" : "none"
350
+ event.currentTarget.textContent = hidden.style.display === "none"
351
+ ? `Show ${hidden.textContent.split("\n").length} more frames`
352
+ : "Hide additional frames"
353
+ }
354
+ }
355
+
356
+ // ─── Submit Helpers ───────────────────────────────────────
357
+
21
358
  submit() {
22
359
  this.showActivity()
360
+ const form = this.element.querySelector("form[data-turbo-frame]")
361
+ if (form) form.requestSubmit()
362
+ }
363
+
364
+ debouncedSubmit(event) {
365
+ clearTimeout(this._debounceTimer)
366
+ this._debounceTimer = setTimeout(() => {
367
+ this.submit()
368
+ }, 400)
23
369
  }
24
370
 
371
+ // ─── Loading State ────────────────────────────────────────
372
+
25
373
  showActivity() {
26
374
  this.activityTarget?.classList.remove("hidden")
27
375
  }
@@ -29,10 +377,4 @@ export default class extends Controller {
29
377
  hideActivity() {
30
378
  this.activityTarget?.classList.add("hidden")
31
379
  }
32
-
33
- closeDetails(event) {
34
- event.preventDefault()
35
- const details = document.getElementById("exception-details")
36
- if (details) details.innerHTML = ""
37
- }
38
380
  }
@@ -0,0 +1,21 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["drawer", "overlay"]
5
+
6
+ toggle() {
7
+ this.open()
8
+ }
9
+
10
+ open() {
11
+ this.drawerTarget.classList.remove("-translate-x-full")
12
+ this.overlayTarget.classList.remove("hidden")
13
+ document.body.style.overflow = "hidden"
14
+ }
15
+
16
+ close() {
17
+ this.drawerTarget.classList.add("-translate-x-full")
18
+ this.overlayTarget.classList.add("hidden")
19
+ document.body.style.overflow = ""
20
+ }
21
+ }