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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +430 -0
- data/Rakefile +6 -0
- data/app/assets/config/rails_nexus_manifest.js +2 -0
- data/app/assets/javascripts/rails_nexus/controllers.js +603 -0
- data/app/assets/stylesheets/rails_nexus/application.css +1964 -0
- data/app/controllers/rails_nexus/analytics_controller.rb +361 -0
- data/app/controllers/rails_nexus/application_controller.rb +15 -0
- data/app/controllers/rails_nexus/backup_controller.rb +91 -0
- data/app/controllers/rails_nexus/cron_jobs_controller.rb +64 -0
- data/app/controllers/rails_nexus/database_health_controller.rb +128 -0
- data/app/controllers/rails_nexus/logged_exceptions_controller.rb +227 -0
- data/app/controllers/rails_nexus/n1_patterns_controller.rb +10 -0
- data/app/controllers/rails_nexus/settings_controller.rb +100 -0
- data/app/controllers/rails_nexus/source_code_controller.rb +39 -0
- data/app/controllers/rails_nexus/stats_controller.rb +494 -0
- data/app/controllers/rails_nexus/workflow_controller.rb +96 -0
- data/app/helpers/rails_nexus/application_helper.rb +5 -0
- data/app/helpers/rails_nexus/logged_exceptions_helper.rb +254 -0
- data/app/helpers/rails_nexus/source_code_helper.rb +107 -0
- data/app/javascript/controllers/rails_nexus_controller.js +380 -0
- data/app/javascript/controllers/rails_nexus_detail_controller.js +165 -0
- data/app/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
- data/app/javascript/controllers/rails_nexus_theme_controller.js +31 -0
- data/app/javascript/rails_nexus/application.js +17 -0
- data/app/jobs/rails_nexus/application_job.rb +4 -0
- data/app/mailers/rails_nexus/application_mailer.rb +6 -0
- data/app/models/rails_nexus/application_record.rb +5 -0
- data/app/models/rails_nexus/backup.rb +107 -0
- data/app/models/rails_nexus/base_record.rb +26 -0
- data/app/models/rails_nexus/comment.rb +13 -0
- data/app/models/rails_nexus/cron_job.rb +72 -0
- data/app/models/rails_nexus/database_stat.rb +128 -0
- data/app/models/rails_nexus/event.rb +101 -0
- data/app/models/rails_nexus/logged_exception.rb +864 -0
- data/app/models/rails_nexus/logged_exception_n1.rb +87 -0
- data/app/models/rails_nexus/metric.rb +107 -0
- data/app/models/rails_nexus/nginx_metric.rb +112 -0
- data/app/models/rails_nexus/server_metric.rb +168 -0
- data/app/models/rails_nexus/webhook_delivery.rb +64 -0
- data/app/services/rails_nexus/backup_service.rb +275 -0
- data/app/views/layouts/rails_nexus/application.html.erb +185 -0
- data/app/views/rails_nexus/analytics/index.html.erb +363 -0
- data/app/views/rails_nexus/backup/files.html.erb +53 -0
- data/app/views/rails_nexus/backup/index.html.erb +211 -0
- data/app/views/rails_nexus/backup/settings.html.erb +158 -0
- data/app/views/rails_nexus/cron_jobs/index.html.erb +141 -0
- data/app/views/rails_nexus/database_health/index.html.erb +139 -0
- data/app/views/rails_nexus/logged_exceptions/_comments_list.html.erb +50 -0
- data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +190 -0
- data/app/views/rails_nexus/logged_exceptions/_feed.html.erb +3 -0
- data/app/views/rails_nexus/logged_exceptions/_filter_group.html.erb +10 -0
- data/app/views/rails_nexus/logged_exceptions/_show.html.erb +554 -0
- data/app/views/rails_nexus/logged_exceptions/_workflow_panel.html.erb +118 -0
- data/app/views/rails_nexus/logged_exceptions/clear.turbo_stream.erb +6 -0
- data/app/views/rails_nexus/logged_exceptions/destroy.turbo_stream.erb +2 -0
- data/app/views/rails_nexus/logged_exceptions/destroy_all.turbo_stream.erb +6 -0
- data/app/views/rails_nexus/logged_exceptions/feed.rss.builder +20 -0
- data/app/views/rails_nexus/logged_exceptions/index.html.erb +323 -0
- data/app/views/rails_nexus/logged_exceptions/query.html.erb +3 -0
- data/app/views/rails_nexus/logged_exceptions/query.turbo_stream.erb +6 -0
- data/app/views/rails_nexus/logged_exceptions/show.html.erb +3 -0
- data/app/views/rails_nexus/logged_exceptions/show.turbo_stream.erb +5 -0
- data/app/views/rails_nexus/n1_patterns/index.html.erb +153 -0
- data/app/views/rails_nexus/settings/index.html.erb +140 -0
- data/app/views/rails_nexus/stats/index.html.erb +569 -0
- data/config/importmap.rb +15 -0
- data/config/initializers/date_formats.rb +5 -0
- data/config/initializers/rails_nexus.rb +18 -0
- data/config/locales/en.yml +47 -0
- data/config/routes.rb +58 -0
- data/db/migrate/20240330122311_create_rails_nexus_logged_exceptions.rb +22 -0
- data/lib/generators/rails_nexus/backup_generator.rb +70 -0
- data/lib/generators/rails_nexus/customize_generator.rb +159 -0
- data/lib/generators/rails_nexus/install_generator.rb +61 -0
- data/lib/generators/rails_nexus/templates/INSTALL.md +9 -0
- data/lib/generators/rails_nexus/templates/backup/backup_helper.sh +152 -0
- data/lib/generators/rails_nexus/templates/backup/config.rb +27 -0
- data/lib/generators/rails_nexus/templates/backup/models/daily_backup.rb +24 -0
- data/lib/generators/rails_nexus/templates/backup/models/full_backup.rb +35 -0
- data/lib/generators/rails_nexus/templates/backup/models/sync_backup.rb +43 -0
- data/lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf +15 -0
- data/lib/generators/rails_nexus/templates/backup/schedule.rb +28 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_controller.js +310 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_detail_controller.js +98 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_theme_controller.js +30 -0
- data/lib/generators/rails_nexus/templates/migration.rb +24 -0
- data/lib/generators/rails_nexus/templates/migration_advanced_features.rb +30 -0
- data/lib/generators/rails_nexus/templates/migration_cron_jobs.rb +22 -0
- data/lib/generators/rails_nexus/templates/migration_new_schema.rb +120 -0
- data/lib/generators/rails_nexus/templates/migration_platform_detection.rb +11 -0
- data/lib/generators/rails_nexus/templates/migration_webhook_deliveries.rb +21 -0
- data/lib/generators/rails_nexus/templates/migration_workflow.rb +29 -0
- data/lib/generators/rails_nexus/templates/rails_nexus.rb +96 -0
- data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +129 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_exceptions.html.erb +123 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_show.html.erb +297 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/index.html.erb +113 -0
- data/lib/rails_nexus/breadcrumbs.rb +161 -0
- data/lib/rails_nexus/cleanup.rb +32 -0
- data/lib/rails_nexus/configuration.rb +319 -0
- data/lib/rails_nexus/engine.rb +135 -0
- data/lib/rails_nexus/logger.rb +188 -0
- data/lib/rails_nexus/notifications.rb +105 -0
- data/lib/rails_nexus/ransack_config.rb +4 -0
- data/lib/rails_nexus/storm_protection.rb +124 -0
- data/lib/rails_nexus/version.rb +3 -0
- data/lib/rails_nexus.rb +133 -0
- data/lib/tasks/rails_nexus.rake +230 -0
- data/lib/tasks/rails_nexus_tasks.rake +37 -0
- data/rails_nexus.gemspec +36 -0
- 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
|