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.
- checksums.yaml +4 -4
- data/README.md +14 -0
- data/app/assets/stylesheets/faultline/application.css +0 -41
- data/app/assets/stylesheets/faultline/dashboard.css +961 -0
- data/app/controllers/faultline/logged_exceptions_controller.rb +81 -8
- data/app/helpers/faultline/logged_exceptions_helper.rb +84 -5
- data/app/javascript/controllers/faultline_controller.js +349 -7
- data/app/javascript/faultline/controllers/sidebar_controller.js +21 -0
- data/app/javascript/faultline/controllers/theme_controller.js +48 -0
- data/app/models/faultline/logged_exception.rb +10 -0
- data/app/views/faultline/logged_exceptions/_exceptions.html.erb +132 -84
- data/app/views/faultline/logged_exceptions/_show.html.erb +281 -60
- data/app/views/faultline/logged_exceptions/index.html.erb +192 -123
- data/app/views/faultline/logged_exceptions/show.html.erb +1 -1
- data/app/views/layouts/faultline/application.html.erb +116 -51
- data/faultline-rails.gemspec +1 -0
- data/lib/faultline/cleanup.rb +32 -0
- data/lib/faultline/configuration.rb +172 -4
- data/lib/faultline/engine.rb +22 -0
- data/lib/faultline/logger.rb +188 -0
- data/lib/faultline/notifications.rb +80 -0
- data/lib/faultline/ransack_config.rb +2 -24
- data/lib/faultline/version.rb +1 -1
- data/lib/faultline.rb +10 -0
- data/lib/generators/faultline/customize_generator.rb +117 -0
- data/lib/generators/faultline/install_generator.rb +15 -0
- data/lib/generators/faultline/templates/faultline.rb +70 -37
- data/lib/generators/faultline/templates/javascript/controllers/faultline_controller.js +310 -0
- data/lib/generators/faultline/templates/views/faultline/logged_exceptions/_exceptions.html.erb +124 -0
- data/lib/generators/faultline/templates/views/faultline/logged_exceptions/_show.html.erb +83 -0
- data/lib/generators/faultline/templates/views/faultline/logged_exceptions/index.html.erb +113 -0
- data/lib/generators/faultline/templates/views/layouts/faultline/application.html.erb +133 -0
- data/lib/tasks/faultline.rake +147 -0
- 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
|
-
|
|
11
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
7
|
-
|
|
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
|
-
|
|
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
|
+
}
|