rails_nexus 2.0.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 (114) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +430 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/config/rails_nexus_manifest.js +2 -0
  6. data/app/assets/javascripts/rails_nexus/controllers.js +603 -0
  7. data/app/assets/stylesheets/rails_nexus/application.css +1964 -0
  8. data/app/controllers/rails_nexus/analytics_controller.rb +361 -0
  9. data/app/controllers/rails_nexus/application_controller.rb +15 -0
  10. data/app/controllers/rails_nexus/backup_controller.rb +91 -0
  11. data/app/controllers/rails_nexus/cron_jobs_controller.rb +64 -0
  12. data/app/controllers/rails_nexus/database_health_controller.rb +128 -0
  13. data/app/controllers/rails_nexus/logged_exceptions_controller.rb +227 -0
  14. data/app/controllers/rails_nexus/n1_patterns_controller.rb +10 -0
  15. data/app/controllers/rails_nexus/settings_controller.rb +100 -0
  16. data/app/controllers/rails_nexus/source_code_controller.rb +39 -0
  17. data/app/controllers/rails_nexus/stats_controller.rb +494 -0
  18. data/app/controllers/rails_nexus/workflow_controller.rb +96 -0
  19. data/app/helpers/rails_nexus/application_helper.rb +5 -0
  20. data/app/helpers/rails_nexus/logged_exceptions_helper.rb +254 -0
  21. data/app/helpers/rails_nexus/source_code_helper.rb +107 -0
  22. data/app/javascript/controllers/rails_nexus_controller.js +380 -0
  23. data/app/javascript/controllers/rails_nexus_detail_controller.js +165 -0
  24. data/app/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
  25. data/app/javascript/controllers/rails_nexus_theme_controller.js +31 -0
  26. data/app/javascript/rails_nexus/application.js +17 -0
  27. data/app/jobs/rails_nexus/application_job.rb +4 -0
  28. data/app/mailers/rails_nexus/application_mailer.rb +6 -0
  29. data/app/models/rails_nexus/application_record.rb +5 -0
  30. data/app/models/rails_nexus/backup.rb +107 -0
  31. data/app/models/rails_nexus/base_record.rb +26 -0
  32. data/app/models/rails_nexus/comment.rb +13 -0
  33. data/app/models/rails_nexus/cron_job.rb +72 -0
  34. data/app/models/rails_nexus/database_stat.rb +128 -0
  35. data/app/models/rails_nexus/event.rb +101 -0
  36. data/app/models/rails_nexus/logged_exception.rb +864 -0
  37. data/app/models/rails_nexus/logged_exception_n1.rb +87 -0
  38. data/app/models/rails_nexus/metric.rb +107 -0
  39. data/app/models/rails_nexus/nginx_metric.rb +112 -0
  40. data/app/models/rails_nexus/server_metric.rb +168 -0
  41. data/app/models/rails_nexus/webhook_delivery.rb +64 -0
  42. data/app/services/rails_nexus/backup_service.rb +275 -0
  43. data/app/views/layouts/rails_nexus/application.html.erb +185 -0
  44. data/app/views/rails_nexus/analytics/index.html.erb +363 -0
  45. data/app/views/rails_nexus/backup/files.html.erb +53 -0
  46. data/app/views/rails_nexus/backup/index.html.erb +211 -0
  47. data/app/views/rails_nexus/backup/settings.html.erb +158 -0
  48. data/app/views/rails_nexus/cron_jobs/index.html.erb +141 -0
  49. data/app/views/rails_nexus/database_health/index.html.erb +139 -0
  50. data/app/views/rails_nexus/logged_exceptions/_comments_list.html.erb +50 -0
  51. data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +190 -0
  52. data/app/views/rails_nexus/logged_exceptions/_feed.html.erb +3 -0
  53. data/app/views/rails_nexus/logged_exceptions/_filter_group.html.erb +10 -0
  54. data/app/views/rails_nexus/logged_exceptions/_show.html.erb +554 -0
  55. data/app/views/rails_nexus/logged_exceptions/_workflow_panel.html.erb +118 -0
  56. data/app/views/rails_nexus/logged_exceptions/clear.turbo_stream.erb +6 -0
  57. data/app/views/rails_nexus/logged_exceptions/destroy.turbo_stream.erb +2 -0
  58. data/app/views/rails_nexus/logged_exceptions/destroy_all.turbo_stream.erb +6 -0
  59. data/app/views/rails_nexus/logged_exceptions/feed.rss.builder +20 -0
  60. data/app/views/rails_nexus/logged_exceptions/index.html.erb +323 -0
  61. data/app/views/rails_nexus/logged_exceptions/query.html.erb +3 -0
  62. data/app/views/rails_nexus/logged_exceptions/query.turbo_stream.erb +6 -0
  63. data/app/views/rails_nexus/logged_exceptions/show.html.erb +3 -0
  64. data/app/views/rails_nexus/logged_exceptions/show.turbo_stream.erb +5 -0
  65. data/app/views/rails_nexus/n1_patterns/index.html.erb +153 -0
  66. data/app/views/rails_nexus/settings/index.html.erb +140 -0
  67. data/app/views/rails_nexus/stats/index.html.erb +569 -0
  68. data/config/importmap.rb +15 -0
  69. data/config/initializers/date_formats.rb +5 -0
  70. data/config/initializers/rails_nexus.rb +18 -0
  71. data/config/locales/en.yml +47 -0
  72. data/config/routes.rb +58 -0
  73. data/db/migrate/20240330122311_create_rails_nexus_logged_exceptions.rb +22 -0
  74. data/lib/generators/rails_nexus/backup_generator.rb +70 -0
  75. data/lib/generators/rails_nexus/customize_generator.rb +159 -0
  76. data/lib/generators/rails_nexus/install_generator.rb +61 -0
  77. data/lib/generators/rails_nexus/templates/INSTALL.md +9 -0
  78. data/lib/generators/rails_nexus/templates/backup/backup_helper.sh +152 -0
  79. data/lib/generators/rails_nexus/templates/backup/config.rb +27 -0
  80. data/lib/generators/rails_nexus/templates/backup/models/daily_backup.rb +24 -0
  81. data/lib/generators/rails_nexus/templates/backup/models/full_backup.rb +35 -0
  82. data/lib/generators/rails_nexus/templates/backup/models/sync_backup.rb +43 -0
  83. data/lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf +15 -0
  84. data/lib/generators/rails_nexus/templates/backup/schedule.rb +28 -0
  85. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_controller.js +310 -0
  86. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_detail_controller.js +98 -0
  87. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
  88. data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_theme_controller.js +30 -0
  89. data/lib/generators/rails_nexus/templates/migration.rb +24 -0
  90. data/lib/generators/rails_nexus/templates/migration_advanced_features.rb +30 -0
  91. data/lib/generators/rails_nexus/templates/migration_cron_jobs.rb +22 -0
  92. data/lib/generators/rails_nexus/templates/migration_new_schema.rb +120 -0
  93. data/lib/generators/rails_nexus/templates/migration_platform_detection.rb +11 -0
  94. data/lib/generators/rails_nexus/templates/migration_webhook_deliveries.rb +21 -0
  95. data/lib/generators/rails_nexus/templates/migration_workflow.rb +29 -0
  96. data/lib/generators/rails_nexus/templates/rails_nexus.rb +96 -0
  97. data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +129 -0
  98. data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_exceptions.html.erb +123 -0
  99. data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_show.html.erb +297 -0
  100. data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/index.html.erb +113 -0
  101. data/lib/rails_nexus/breadcrumbs.rb +161 -0
  102. data/lib/rails_nexus/cleanup.rb +32 -0
  103. data/lib/rails_nexus/configuration.rb +319 -0
  104. data/lib/rails_nexus/engine.rb +135 -0
  105. data/lib/rails_nexus/logger.rb +188 -0
  106. data/lib/rails_nexus/notifications.rb +105 -0
  107. data/lib/rails_nexus/ransack_config.rb +4 -0
  108. data/lib/rails_nexus/storm_protection.rb +124 -0
  109. data/lib/rails_nexus/version.rb +3 -0
  110. data/lib/rails_nexus.rb +133 -0
  111. data/lib/tasks/rails_nexus.rake +230 -0
  112. data/lib/tasks/rails_nexus_tasks.rake +37 -0
  113. data/rails_nexus.gemspec +36 -0
  114. metadata +252 -0
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNexus
4
+ module LoggedExceptionsHelper
5
+ def pretty_exception_date(exception)
6
+ if RailsNexus.configuration.date_format
7
+ return exception.created_at.strftime(RailsNexus.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')}"
26
+ else
27
+ time.strftime('%b %d, %Y %I:%M %p')
28
+ end
29
+ end
30
+
31
+ def filtered?
32
+ legacy_filters = %i[
33
+ query date_ranges_filter exception_names_filter platform_filter
34
+ controller_actions_filter status_filter
35
+ ]
36
+ ransack_filters = params[:q].respond_to?(:to_unsafe_h) ? params[:q].to_unsafe_h.except("s") : {}
37
+
38
+ legacy_filters.any? { |name| params[name].present? } || ransack_filters.values.any?(&:present?)
39
+ end
40
+
41
+ def listify(text)
42
+ list_items = text.scan(/^\s*\* (.+)/).map { |match| content_tag(:li, match.first) }
43
+ content_tag(:ul, list_items)
44
+ end
45
+
46
+ def page_title(text)
47
+ title = [RailsNexus.application_name.presence, text].compact.join(" :: ")
48
+ content_for(:title, title)
49
+ end
50
+
51
+ # Rescue textilize call if RedCloth is not available.
52
+ def pretty_format(text)
53
+ begin
54
+ textilize(text).html_safe
55
+ rescue
56
+ simple_format(text).html_safe
57
+ end
58
+ end
59
+
60
+ # Sort link helper for Ransack-compatible table headers.
61
+ def sort_link(search, attribute, name = nil, **options, &block)
62
+ name ||= attribute.to_s.humanize
63
+
64
+ if defined?(Ransack) && search.respond_to?(:result)
65
+ Ransack::Helpers::FormHelper.instance_method(:sort_link).bind(self).call(search, attribute, name, **options, &block)
66
+ else
67
+ link_to name, "#", **options, &block
68
+ end
69
+ end
70
+
71
+ # Format backtrace for display with optional collapse
72
+ def format_backtrace(backtrace, limit: nil)
73
+ limit ||= RailsNexus.configuration.backtrace_limit
74
+ lines = backtrace.to_s.split("\n")
75
+
76
+ if lines.length > limit
77
+ collapsed = lines[0...limit]
78
+ remaining = lines[limit..]
79
+ {
80
+ visible: collapsed.join("\n"),
81
+ hidden: remaining.join("\n"),
82
+ hidden_count: remaining.length
83
+ }
84
+ else
85
+ { visible: lines.join("\n"), hidden: nil, hidden_count: 0 }
86
+ end
87
+ end
88
+
89
+ # Severity badge class based on exception type
90
+ def severity_for(exception)
91
+ case exception.exception_class
92
+ when /Timeout|Slow|Performance/i
93
+ "warning"
94
+ when /404|NotFound|Missing/i
95
+ "info"
96
+ else
97
+ "danger"
98
+ end
99
+ end
100
+
101
+ # Active filter pills for the filter bar
102
+ def active_filter_pills
103
+ pills = []
104
+
105
+ if params[:q].present?
106
+ params[:q].each do |key, value|
107
+ next if value.blank? || key.to_s == "s"
108
+
109
+ labels = {
110
+ "exception_class_cont" => "Exception",
111
+ "message_cont" => "Message",
112
+ "message_or_exception_class_or_controller_name_or_action_name_cont" => "Search",
113
+ "platform_eq" => "Platform",
114
+ "priority_eq" => "Priority",
115
+ "assigned_to_cont" => "Assigned to",
116
+ "fingerprint_cont" => "Fingerprint",
117
+ "occurrence_count_gteq" => "Minimum count"
118
+ }
119
+ label = labels[key.to_s] || key.to_s.sub(/_(cont|eq|gteq|lteq)$/, "").humanize
120
+ pills << { label: label, value: value, group: :q, key: key.to_s }
121
+ end
122
+ end
123
+
124
+ if params[:date_ranges_filter].present?
125
+ value = { "1" => "Today", "3" => "Last 3 days", "7" => "Last 7 days", "30" => "Last 30 days" }[params[:date_ranges_filter].to_s] || "Custom"
126
+ pills << { label: "Time", value: value, key: "date_ranges_filter" }
127
+ end
128
+
129
+ {
130
+ "controller_actions_filter" => "Source",
131
+ "status_filter" => "Status",
132
+ "query" => "Search"
133
+ }.each do |key, label|
134
+ pills << { label: label, value: params[key], key: key } if params[key].present?
135
+ end
136
+
137
+ pills
138
+ end
139
+
140
+ def filter_params_without(pill)
141
+ filters = params_filters.deep_stringify_keys
142
+
143
+ if pill[:group] == :q
144
+ filters["q"]&.delete(pill[:key])
145
+ filters.delete("q") if filters["q"].blank?
146
+ else
147
+ filters.delete(pill[:key])
148
+ end
149
+
150
+ filters
151
+ end
152
+
153
+ def exception_occurrence_count(exception)
154
+ exception.respond_to?(:occurrence_count) ? (exception.occurrence_count || 1) : 1
155
+ end
156
+
157
+ def exception_status(exception)
158
+ if exception.has_attribute?(:muted) && exception.muted?
159
+ ["Muted", "muted"]
160
+ elsif exception.has_attribute?(:snoozed_until) && exception.snoozed?
161
+ ["Snoozed", "warning"]
162
+ else
163
+ ["Active", "success"]
164
+ end
165
+ end
166
+
167
+ def sort_header_class(attribute)
168
+ sort = @q&.sorts&.find { |item| item.name == attribute.to_s }
169
+ ["sortable", ("sorted-#{sort.dir}" if sort)].compact.join(" ")
170
+ end
171
+
172
+ def exception_searchable?(attribute)
173
+ LoggedException.ransackable_attributes.include?(attribute.to_s)
174
+ end
175
+
176
+ # Simple sparkline data for stats
177
+ def exception_trend_data(days: 7)
178
+ data = (0...days).reverse_each.map do |i|
179
+ date = i.days.ago.to_date
180
+ LoggedException.where(created_at: date.beginning_of_day..date.end_of_day).count
181
+ end
182
+ data
183
+ end
184
+
185
+ # ─── Source Code Reading ──────────────────────────────────────
186
+
187
+ # Read source code snippet around a file:line
188
+ def read_source_snippet(file_path, line_number, context: 5)
189
+ return nil unless file_path.present? && line_number.present?
190
+ return nil unless File.exist?(file_path)
191
+
192
+ lines = File.readlines(file_path)
193
+ start_line = [line_number - context, 0].max
194
+ end_line = [line_number + context, lines.length - 1].min
195
+
196
+ snippet_lines = (start_line..end_line).map do |i|
197
+ {
198
+ number: i + 1,
199
+ content: lines[i]&.rstrip,
200
+ highlighted: (i + 1) == line_number
201
+ }
202
+ end
203
+
204
+ { lines: snippet_lines, start: start_line + 1, end: end_line + 1 }
205
+ rescue StandardError
206
+ nil
207
+ end
208
+
209
+ # Get git blame for a file:line range
210
+ def git_blame_for_line(file_path, line_number, context: 2)
211
+ return nil unless file_path.present? && line_number.present?
212
+ return nil unless File.exist?(file_path)
213
+
214
+ start_line = [line_number - context, 1].max
215
+ end_line = line_number + context
216
+
217
+ result = `git blame -L #{start_line},#{end_line} --porcelain #{file_path} 2>/dev/null`
218
+ return nil unless $?.success?
219
+
220
+ blame_data = {}
221
+ current_sha = nil
222
+
223
+ result.each_line do |line|
224
+ if line =~ /^([0-9a-f]{40})\s+(\d+)\s+(\d+)/
225
+ current_sha = $1
226
+ line_num = $2.to_i
227
+ blame_data[line_num] = { sha: current_sha&.slice(0, 7) } if line_num == line_number
228
+ elsif line =~ /^author\s+(.+)/
229
+ blame_data.each_value { |v| v[:author] = $1.strip if v[:sha] == current_sha&.slice(0, 7) && v[:author].nil? }
230
+ elsif line =~ /^author-time\s+(\d+)/
231
+ time = Time.at($1.to_i)
232
+ blame_data.each_value { |v| v[:date] = time.strftime("%Y-%m-%d") if v[:sha] == current_sha&.slice(0, 7) && v[:date].nil? }
233
+ elsif line =~ /^summary\s+(.+)/
234
+ blame_data.each_value { |v| v[:message] = $1.strip if v[:sha] == current_sha&.slice(0, 7) && v[:message].nil? }
235
+ end
236
+ end
237
+
238
+ blame_data[line_number]
239
+ rescue StandardError
240
+ nil
241
+ end
242
+
243
+ # Parse backtrace line into file path and line number
244
+ def parse_backtrace_line(line)
245
+ return nil unless line.present?
246
+ # Match formats: "file:line" or "file:line:in `method'"
247
+ if line =~ /^(.+?):(\d+)(?::in `(.+?)')?$/
248
+ { file: $1, line: $2.to_i, method: $3 }
249
+ else
250
+ nil
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNexus
4
+ module SourceCodeHelper
5
+ # Parse a backtrace line into components
6
+ # Returns { file: String, line: Integer, method: String, gem: String } or nil
7
+ def parse_backtrace_line(line)
8
+ return nil if line.blank?
9
+
10
+ # Match standard Ruby backtrace: "path/to/file.rb:123:in `method_name'"
11
+ if line =~ %r{\A(.+?):(\d+)(?::in `(.+?)')?\z}
12
+ {
13
+ file: $1,
14
+ line: $2.to_i,
15
+ method: $3
16
+ }
17
+ end
18
+ end
19
+
20
+ # Read source code snippet around a specific line
21
+ # Returns { lines: Array, file: String, from_line: Integer, to_line: Integer, error_line: Integer }
22
+ def read_source_snippet(file_path, error_line, context_lines: 5)
23
+ return nil unless file_path.present? && File.exist?(file_path)
24
+
25
+ total_lines = File.foreach(file_path).count
26
+ from_line = [error_line - context_lines, 1].max
27
+ to_line = [error_line + context_lines, total_lines].min
28
+
29
+ lines = []
30
+ current_line = 0
31
+
32
+ File.foreach(file_path) do |raw_line|
33
+ current_line += 1
34
+ break if current_line > to_line
35
+ next if current_line < from_line
36
+
37
+ lines << {
38
+ number: current_line,
39
+ content: raw_line.rstrip,
40
+ is_error: current_line == error_line
41
+ }
42
+ end
43
+
44
+ {
45
+ lines: lines,
46
+ file: file_path,
47
+ from_line: from_line,
48
+ to_line: to_line,
49
+ error_line: error_line
50
+ }
51
+ end
52
+
53
+ # Get git blame for a specific line
54
+ # Returns { sha: String, author: String, date: String, message: String }
55
+ def git_blame_for_line(file_path, line_number)
56
+ return nil unless file_path.present? && line_number.present?
57
+ return nil unless File.exist?(file_path)
58
+ return nil unless system("git", "rev-parse", "--git-dir", File.dirname(file_path), [:out, :err] => File::NULL)
59
+
60
+ # Get blame for single line
61
+ blame_output = `git -C "#{File.dirname(file_path)}" blame -L #{line_number},#{line_number} -p "#{File.basename(file_path)}" 2>/dev/null`
62
+ return nil unless $?.success?
63
+
64
+ # Parse porcelain blame output
65
+ result = {}
66
+ blame_output.each_line do |line|
67
+ case line
68
+ when /^([0-9a-f]{40})\s+(\d+)\s+(\d+)/
69
+ result[:sha] = $1[0..6]
70
+ result[:line] = $3.to_i
71
+ when /^author\s+(.+)/
72
+ result[:author] = $1.strip
73
+ when /^author-time\s+(\d+)/
74
+ result[:date] = Time.at($1.to_i).strftime("%Y-%m-%d")
75
+ when /^summary\s+(.+)/
76
+ result[:message] = $1.strip
77
+ when /^filename\s+(.+)/
78
+ result[:filename] = $1.strip
79
+ end
80
+ end
81
+
82
+ result.presence
83
+ end
84
+
85
+ # Get source code with git blame for multiple backtrace lines
86
+ # Returns array of { backtrace_line:, parsed:, source:, blame: }
87
+ def source_with_blame(backtrace_lines, limit: 10)
88
+ return [] if backtrace_lines.blank?
89
+
90
+ backtrace_lines.first(limit).filter_map do |line|
91
+ parsed = parse_backtrace_line(line)
92
+ next nil unless parsed
93
+
94
+ source = read_source_snippet(parsed[:file], parsed[:line])
95
+ blame = git_blame_for_line(parsed[:file], parsed[:line])
96
+
97
+ {
98
+ backtrace_line: line,
99
+ parsed: parsed,
100
+ source: source,
101
+ blame: blame,
102
+ is_app_code: parsed[:file]&.start_with?(Rails.root.to_s)
103
+ }
104
+ end
105
+ end
106
+ end
107
+ end