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,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNexus
4
+ class AnalyticsController < ApplicationController
5
+ before_action :verify_access
6
+
7
+ def index
8
+ @time_range = parse_time_range
9
+ @error_trends = collect_error_trends
10
+ @platform_health = collect_platform_health
11
+ @correlation_insights = collect_correlation_insights
12
+ @baseline_monitoring = collect_baseline_monitoring
13
+ @occurrence_patterns = collect_occurrence_patterns
14
+ @severity_breakdown = collect_severity_breakdown
15
+ @top_errors = collect_top_errors
16
+ @hourly_heatmap = collect_hourly_heatmap
17
+ end
18
+
19
+ private
20
+
21
+ def verify_access
22
+ config = RailsNexus.configuration
23
+ return if config.auth_block.nil?
24
+ unless config.auth_block&.call(self)
25
+ render plain: "Forbidden", status: :forbidden
26
+ end
27
+ end
28
+
29
+ def parse_time_range
30
+ days = params[:days]&.to_i || 7
31
+ days = [days, 1].max
32
+ days = [days, 90].min
33
+ days.days.ago..Time.current
34
+ end
35
+
36
+ # ─── Error Trends ────────────────────────────────────────────
37
+ def collect_error_trends
38
+ {
39
+ daily: daily_trend,
40
+ hourly: hourly_trend,
41
+ minute_by_minute: minute_trend,
42
+ total_count: LoggedException.where(created_at: @time_range).count,
43
+ previous_count: previous_period_count,
44
+ trend_direction: calculate_trend_direction
45
+ }
46
+ end
47
+
48
+ def daily_trend
49
+ (0...[(@time_range.last - @time_range.first).to_i / 86400, 30].min).map do |i|
50
+ date = i.days.ago.to_date
51
+ {
52
+ date: date,
53
+ count: LoggedException.where(created_at: date.beginning_of_day..date.end_of_day).count,
54
+ unique_classes: LoggedException.where(created_at: date.beginning_of_day..date.end_of_day).distinct.count(:exception_class)
55
+ }
56
+ end.reverse
57
+ end
58
+
59
+ def hourly_trend
60
+ 24.times.map do |i|
61
+ hour = i.hours.ago
62
+ {
63
+ hour: hour.strftime("%H:00"),
64
+ count: LoggedException.where(created_at: hour.beginning_of_hour..hour.end_of_hour).count
65
+ }
66
+ end.reverse
67
+ end
68
+
69
+ def minute_trend
70
+ 60.times.map do |i|
71
+ minute = i.minutes.ago
72
+ {
73
+ minute: minute.strftime("%H:%M"),
74
+ count: LoggedException.where(created_at: minute.beginning_of_minute..minute.end_of_minute).count
75
+ }
76
+ end.reverse
77
+ end
78
+
79
+ def previous_period_count
80
+ duration = (@time_range.last - @time_range.first)
81
+ start_time = @time_range.first - duration
82
+ end_time = @time_range.first
83
+ LoggedException.where(created_at: start_time..end_time).count
84
+ end
85
+
86
+ def calculate_trend_direction
87
+ current = LoggedException.where(created_at: @time_range).count
88
+ previous = previous_period_count
89
+ return "stable" if previous == 0
90
+ ratio = current.to_f / previous
91
+ return "up" if ratio > 1.1
92
+ return "down" if ratio < 0.9
93
+ "stable"
94
+ end
95
+
96
+ # ─── Platform Health ─────────────────────────────────────────
97
+ def collect_platform_health
98
+ platforms = %w[web ios android api bot]
99
+ platforms.map do |platform|
100
+ scope = LoggedException.where(platform: platform, created_at: @time_range)
101
+ {
102
+ platform: platform,
103
+ total: scope.count,
104
+ unique_classes: scope.distinct.count(:exception_class),
105
+ unique_controllers: scope.distinct.count(:controller_name),
106
+ last_error: scope.order(created_at: :desc).first&.created_at,
107
+ top_error: scope.group(:exception_class).order("count_all DESC").limit(1).count.keys.first,
108
+ error_rate_per_hour: calculate_rate(scope),
109
+ health_score: calculate_health_score(scope)
110
+ }
111
+ end
112
+ end
113
+
114
+ def calculate_rate(scope)
115
+ hours = [(@time_range.last - @time_range.first) / 3600.0, 1].max
116
+ (scope.count / hours).round(2)
117
+ end
118
+
119
+ def calculate_health_score(scope)
120
+ count = scope.count
121
+ return 100 if count == 0
122
+ # Score: 100 = no errors, 0 = many errors
123
+ # Using logarithmic scale
124
+ score = 100 - (Math.log(count + 1) * 10).round
125
+ [score, 0].max
126
+ end
127
+
128
+ # ─── Correlation Insights ────────────────────────────────────
129
+ def collect_correlation_insights
130
+ {
131
+ by_controller: correlation_by_controller,
132
+ by_exception_class: correlation_by_exception_class,
133
+ by_time_of_day: correlation_by_time_of_day,
134
+ by_day_of_week: correlation_by_day_of_week,
135
+ co_occurring: find_co_occurring_errors
136
+ }
137
+ end
138
+
139
+ def correlation_by_controller
140
+ LoggedException.where(created_at: @time_range)
141
+ .group(:controller_name)
142
+ .order("count_all DESC")
143
+ .limit(10)
144
+ .count
145
+ .map { |controller, count| { controller: controller, count: count, percentage: calculate_percentage(count) } }
146
+ end
147
+
148
+ def correlation_by_exception_class
149
+ LoggedException.where(created_at: @time_range)
150
+ .group(:exception_class)
151
+ .order("count_all DESC")
152
+ .limit(10)
153
+ .count
154
+ .map { |klass, count| { exception_class: klass, count: count, percentage: calculate_percentage(count) } }
155
+ end
156
+
157
+ def correlation_by_time_of_day
158
+ (0..23).map do |hour|
159
+ count = LoggedException.where(created_at: @time_range)
160
+ .where("HOUR(created_at) = ?", hour)
161
+ .count
162
+ { hour: hour, count: count }
163
+ end
164
+ end
165
+
166
+ def correlation_by_day_of_week
167
+ %w[Mon Tue Wed Thu Fri Sat Sun].each_with_index.map do |day, index|
168
+ count = LoggedException.where(created_at: @time_range)
169
+ .where("DAYOFWEEK(created_at) = ?", index + 1)
170
+ .count
171
+ { day: day, count: count }
172
+ end
173
+ end
174
+
175
+ def find_co_occurring_errors
176
+ # Find errors that happen within 5 minutes of each other
177
+ recent = LoggedException.where(created_at: 24.hours.ago..)
178
+ .order(:created_at)
179
+ .limit(1000)
180
+
181
+ pairs = Hash.new(0)
182
+ recent.each_cons(2) do |a, b|
183
+ next if a.id == b.id
184
+ diff = (b.created_at - a.created_at).abs
185
+ next if diff > 300 # 5 minutes
186
+ key = [a.exception_class, b.exception_class].sort
187
+ pairs[key] += 1
188
+ end
189
+
190
+ pairs.sort_by { |_, count| -count }
191
+ .first(5)
192
+ .map { |pair, count| { error_a: pair[0], error_b: pair[1], co_occurrences: count } }
193
+ end
194
+
195
+ def calculate_percentage(count)
196
+ total = LoggedException.where(created_at: @time_range).count
197
+ return 0 if total == 0
198
+ (count.to_f / total * 100).round(1)
199
+ end
200
+
201
+ # ─── Baseline Monitoring ─────────────────────────────────────
202
+ def collect_baseline_monitoring
203
+ {
204
+ baseline: calculate_baseline,
205
+ anomalies: detect_anomalies,
206
+ spike_detection: detect_spikes,
207
+ baseline_health: baseline_health_status
208
+ }
209
+ end
210
+
211
+ def calculate_baseline
212
+ # Calculate mean and standard deviation for daily error counts
213
+ daily_counts = 30.times.map do |i|
214
+ date = i.days.ago.to_date
215
+ LoggedException.where(created_at: date.beginning_of_day..date.end_of_day).count
216
+ end
217
+
218
+ mean = daily_counts.sum.to_f / daily_counts.size
219
+ variance = daily_counts.sum { |x| (x - mean) ** 2 } / daily_counts.size
220
+ std_dev = Math.sqrt(variance)
221
+
222
+ {
223
+ mean: mean.round(2),
224
+ std_dev: std_dev.round(2),
225
+ min: daily_counts.min,
226
+ max: daily_counts.max,
227
+ samples: daily_counts.size
228
+ }
229
+ end
230
+
231
+ def detect_anomalies
232
+ baseline = calculate_baseline
233
+ return [] if baseline[:std_dev] == 0
234
+
235
+ # Check last 7 days for anomalies (> 2 std deviations)
236
+ anomalies = []
237
+ 7.times do |i|
238
+ date = i.days.ago.to_date
239
+ count = LoggedException.where(created_at: date.beginning_of_day..date.end_of_day).count
240
+ z_score = (count - baseline[:mean]) / baseline[:std_dev]
241
+
242
+ if z_score.abs > 2
243
+ anomalies << {
244
+ date: date,
245
+ count: count,
246
+ z_score: z_score.round(2),
247
+ type: z_score > 0 ? "spike" : "drop",
248
+ severity: z_score.abs > 3 ? "critical" : "warning"
249
+ }
250
+ end
251
+ end
252
+ anomalies
253
+ end
254
+
255
+ def detect_spikes
256
+ # Check for sudden spikes in the last hour
257
+ now = Time.current
258
+ last_hour = now - 1.hour
259
+ previous_hour = last_hour - 1.hour
260
+
261
+ current_count = LoggedException.where(created_at: last_hour..now).count
262
+ previous_count = LoggedException.where(created_at: previous_hour..last_hour).count
263
+
264
+ return { detected: false } if previous_count == 0
265
+
266
+ ratio = current_count.to_f / previous_count
267
+ {
268
+ detected: ratio > 3,
269
+ current_count: current_count,
270
+ previous_count: previous_count,
271
+ ratio: ratio.round(2),
272
+ severity: ratio > 5 ? "critical" : ratio > 3 ? "warning" : "normal"
273
+ }
274
+ end
275
+
276
+ def baseline_health_status
277
+ baseline = calculate_baseline
278
+ anomalies = detect_anomalies
279
+ spikes = detect_spikes
280
+
281
+ if spikes[:detected] && spikes[:severity] == "critical"
282
+ "critical"
283
+ elsif anomalies.any? { |a| a[:severity] == "critical" }
284
+ "critical"
285
+ elsif anomalies.any?
286
+ "warning"
287
+ else
288
+ "healthy"
289
+ end
290
+ end
291
+
292
+ # ─── Occurrence Patterns ─────────────────────────────────────
293
+ def collect_occurrence_patterns
294
+ days = [(@time_range.last - @time_range.first).to_i / 86400, 7].max
295
+ LoggedException.detect_occurrence_patterns(days: days)
296
+ end
297
+
298
+ # ─── Severity Breakdown ──────────────────────────────────────
299
+ def collect_severity_breakdown
300
+ LoggedException.where(created_at: @time_range)
301
+ .group(:exception_class)
302
+ .order("count_all DESC")
303
+ .limit(20)
304
+ .count
305
+ .map do |klass, count|
306
+ severity = classify_severity(klass, count)
307
+ { exception_class: klass, count: count, severity: severity }
308
+ end
309
+ end
310
+
311
+ def classify_severity(klass, count)
312
+ case klass
313
+ when /Timeout|Slow|Performance|Memory|OOM/i
314
+ "warning"
315
+ when /404|NotFound|Missing|Routing/i
316
+ "info"
317
+ when /500|InternalServer|Fatal|Critical/i
318
+ "critical"
319
+ else
320
+ count > 100 ? "warning" : "danger"
321
+ end
322
+ end
323
+
324
+ # ─── Top Errors ──────────────────────────────────────────────
325
+ def collect_top_errors
326
+ LoggedException.where(created_at: @time_range)
327
+ .group(:exception_class, :controller_name, :action_name)
328
+ .order("count_all DESC")
329
+ .limit(10)
330
+ .count
331
+ .map do |(klass, controller, action), count|
332
+ {
333
+ exception_class: klass,
334
+ controller: controller,
335
+ action: action,
336
+ count: count,
337
+ last_seen: LoggedException.where(exception_class: klass, controller_name: controller, action_name: action, created_at: @time_range).maximum(:created_at)
338
+ }
339
+ end
340
+ end
341
+
342
+ # ─── Hourly Heatmap ──────────────────────────────────────────
343
+ def collect_hourly_heatmap
344
+ # 7 days x 24 hours heatmap
345
+ heatmap = {}
346
+ 7.times do |day_offset|
347
+ date = day_offset.days.ago.to_date
348
+ day_name = date.strftime("%A")
349
+ heatmap[day_name] = {}
350
+
351
+ 24.times do |hour|
352
+ count = LoggedException.where(
353
+ created_at: date.beginning_of_day + hour.hours..date.beginning_of_day + (hour + 1).hours
354
+ ).count
355
+ heatmap[day_name][hour] = count
356
+ end
357
+ end
358
+ heatmap
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,15 @@
1
+ module RailsNexus
2
+ class ApplicationController < ActionController::Base
3
+ include Pagy::Backend
4
+
5
+ private
6
+
7
+ def rails_nexus_require_auth!
8
+ auth_block = RailsNexus.configuration.auth_block
9
+ return if auth_block.nil?
10
+ return if auth_block.call(self)
11
+
12
+ head :forbidden
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNexus
4
+ class BackupController < ApplicationController
5
+ before_action :verify_access
6
+ before_action :check_backup_enabled
7
+
8
+ # GET /rails_nexus/backup
9
+ def index
10
+ @backup_service = BackupService.new
11
+ @health = @backup_service.health_status
12
+ @summary = @backup_service.summary
13
+ @models = @backup_service.models
14
+ @schedule = @backup_service.cron_schedule
15
+ end
16
+
17
+ # GET /rails_nexus/backup/files
18
+ def files
19
+ @backup_service = BackupService.new
20
+ @files = @backup_service.backup_files
21
+ end
22
+
23
+ # POST /rails_nexus/backup/trigger/:model
24
+ def trigger
25
+ @backup_service = BackupService.new
26
+ result = @backup_service.trigger_backup(params[:model])
27
+
28
+ if result[:success]
29
+ redirect_to backup_path, notice: "Backup '#{params[:model]}' triggered (PID: #{result[:pid]})"
30
+ else
31
+ redirect_to backup_path, alert: "Failed: #{result[:error]}"
32
+ end
33
+ end
34
+
35
+ # GET /rails_nexus/backup/health
36
+ def health
37
+ @backup_service = BackupService.new
38
+ @health = @backup_service.health_status
39
+ @summary = @backup_service.summary
40
+ end
41
+
42
+ # GET /rails_nexus/backup/settings
43
+ def settings
44
+ @backup_config = BackupService.load_config
45
+ end
46
+
47
+ # PATCH /rails_nexus/backup/settings
48
+ def update_settings
49
+ result = BackupService.save_config(backup_settings_params)
50
+
51
+ if result[:success]
52
+ redirect_to backup_settings_path, notice: "Backup settings saved successfully."
53
+ else
54
+ redirect_to backup_settings_path, alert: "Failed to save: #{result[:error]}"
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def verify_access
61
+ config = RailsNexus.configuration
62
+ return if config.auth_block.nil?
63
+ unless config.auth_block&.call(self)
64
+ render plain: "Forbidden", status: :forbidden
65
+ end
66
+ end
67
+
68
+ def check_backup_enabled
69
+ unless RailsNexus.configuration.backup_enabled
70
+ render plain: "Backup management not enabled", status: :forbidden
71
+ end
72
+ end
73
+
74
+ def backup_settings_params
75
+ params.require(:backup_config).permit(
76
+ :enabled,
77
+ :config_path,
78
+ :models_path,
79
+ :dump_path,
80
+ :notify_command,
81
+ :alert_threshold_hours,
82
+ :rsync_host,
83
+ :rsync_port,
84
+ :rsync_user,
85
+ :rsync_path,
86
+ :encrypt_password,
87
+ :auto_cleanup_days
88
+ )
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNexus
4
+ class CronJobsController < ApplicationController
5
+ before_action :verify_access
6
+ before_action :set_cron_job, only: [:show, :destroy]
7
+
8
+ def index
9
+ @stats = CronJob.summary
10
+ cron_jobs = CronJob.recent
11
+
12
+ if params[:status].present?
13
+ cron_jobs = cron_jobs.by_status(params[:status])
14
+ end
15
+
16
+ if params[:name].present?
17
+ cron_jobs = cron_jobs.where(name: params[:name])
18
+ end
19
+
20
+ if params[:date_range].present?
21
+ days = params[:date_range].to_i
22
+ cron_jobs = cron_jobs.where("created_at >= ?", days.days.ago)
23
+ end
24
+
25
+ requested_size = params[:per_page].to_i
26
+ page_size = requested_size.between?(1, 100) ? requested_size : 30
27
+ @pagy, @cron_jobs = pagy(cron_jobs, items: page_size)
28
+ end
29
+
30
+ def show
31
+ end
32
+
33
+ def destroy
34
+ @cron_job.destroy
35
+ redirect_to cron_jobs_path, notice: "Cron job run deleted."
36
+ end
37
+
38
+ def clear_old
39
+ days = params[:days].to_i || 30
40
+ CronJob.where("created_at < ?", days.days.ago).delete_all
41
+ redirect_to cron_jobs_path, notice: "Deleted cron jobs older than #{days} days."
42
+ end
43
+
44
+ def retry_job
45
+ cron_job = CronJob.find(params[:id])
46
+ # Re-run the job (implementation depends on job scheduler)
47
+ redirect_to cron_jobs_path, notice: "Retrying job: #{cron_job.name}"
48
+ end
49
+
50
+ private
51
+
52
+ def set_cron_job
53
+ @cron_job = CronJob.find(params[:id])
54
+ end
55
+
56
+ def verify_access
57
+ config = RailsNexus.configuration
58
+ return if config.auth_block.nil?
59
+ unless config.auth_block&.call(self)
60
+ render plain: "Forbidden", status: :forbidden
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNexus
4
+ class DatabaseHealthController < ApplicationController
5
+ before_action :verify_access
6
+
7
+ def index
8
+ @period = params[:period]&.to_i || 7
9
+ @period = [@period, 1].max
10
+ @period = [@period, 90].min
11
+
12
+ time_range = @period.days.ago..Time.current
13
+
14
+ @pool_by_error = collect_pool_by_error(time_range)
15
+ @summary = collect_summary(time_range)
16
+ @live_pool = collect_live_pool
17
+ @peak_utilization = @pool_by_error.map { |e| e[:utilization] }.max || 0
18
+ @total_dead = @pool_by_error.sum { |e| e[:dead] }
19
+ @total_waiting = @pool_by_error.sum { |e| e[:waiting] }
20
+ end
21
+
22
+ private
23
+
24
+ def verify_access
25
+ config = RailsNexus.configuration
26
+ return if config.auth_block.nil?
27
+ unless config.auth_block&.call(self)
28
+ render plain: "Forbidden", status: :forbidden
29
+ end
30
+ end
31
+
32
+ def collect_pool_by_error(time_range)
33
+ # Get all errors with system_health data in the time range
34
+ exceptions = LoggedException.where(created_at: time_range)
35
+ .where.not(system_health: nil)
36
+ .order(created_at: :desc)
37
+
38
+ errors_with_pool = []
39
+
40
+ exceptions.find_each do |exception|
41
+ health = parse_health(exception.system_health)
42
+ next unless health&.dig(:db_pool)
43
+
44
+ pool = health[:db_pool]
45
+ next if pool[:error]
46
+
47
+ errors_with_pool << {
48
+ id: exception.id,
49
+ exception_class: exception.exception_class,
50
+ utilization: pool[:utilization] || 0,
51
+ busy: pool[:busy] || 0,
52
+ idle: pool[:idle] || 0,
53
+ dead: pool[:dead] || 0,
54
+ waiting: pool[:waiting] || 0,
55
+ pool_size: pool[:size] || 0,
56
+ last_seen: exception.created_at
57
+ }
58
+ end
59
+
60
+ # Sort by utilization descending
61
+ errors_with_pool.sort_by { |e| -e[:utilization] }
62
+ end
63
+
64
+ def collect_summary(time_range)
65
+ exceptions = LoggedException.where(created_at: time_range)
66
+ .where.not(system_health: nil)
67
+
68
+ total_errors = exceptions.count
69
+ errors_with_pool = 0
70
+ total_utilization = 0
71
+ max_utilization = 0
72
+
73
+ exceptions.find_each do |exception|
74
+ health = parse_health(exception.system_health)
75
+ next unless health&.dig(:db_pool)
76
+ next if health[:db_pool][:error]
77
+
78
+ errors_with_pool += 1
79
+ utilization = health[:db_pool][:utilization] || 0
80
+ total_utilization += utilization
81
+ max_utilization = [max_utilization, utilization].max
82
+ end
83
+
84
+ {
85
+ total_errors: total_errors,
86
+ errors_with_pool: errors_with_pool,
87
+ avg_utilization: errors_with_pool > 0 ? (total_utilization / errors_with_pool).round(1) : 0,
88
+ max_utilization: max_utilization
89
+ }
90
+ end
91
+
92
+ def collect_live_pool
93
+ pool = ActiveRecord::Base.connection_pool
94
+ connections = pool.connections
95
+ active_conns = connections.select(&:active?)
96
+ idle_conns = connections.reject(&:active?)
97
+
98
+ dead_count = 0
99
+ begin
100
+ dead_count = pool.instance_variable_get(:@dead_connections)&.size || 0
101
+ rescue StandardError
102
+ # Ignore
103
+ end
104
+
105
+ waiting = pool.instance_variable_get(:@waiters)&.size || 0 rescue 0
106
+
107
+ {
108
+ size: pool.size,
109
+ connections: connections.size,
110
+ active: active_conns.size,
111
+ busy: active_conns.size,
112
+ idle: idle_conns.size,
113
+ dead: dead_count,
114
+ waiting: waiting,
115
+ utilization: pool.size > 0 ? (connections.size.to_f / pool.size * 100).round(1) : 0,
116
+ adapter: ActiveRecord::Base.connection.adapter_name
117
+ }
118
+ rescue StandardError => e
119
+ { error: e.message }
120
+ end
121
+
122
+ def parse_health(data)
123
+ return nil if data.blank?
124
+ return data if data.is_a?(Hash)
125
+ JSON.parse(data, symbolize_names: true) rescue nil
126
+ end
127
+ end
128
+ end