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,494 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
class StatsController < ApplicationController
|
|
5
|
+
before_action :verify_access
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@system_stats = collect_system_stats
|
|
9
|
+
@ruby_stats = collect_ruby_stats
|
|
10
|
+
@sidekiq_stats = collect_sidekiq_stats if defined?(Sidekiq)
|
|
11
|
+
@cron_job_stats = CronJob.summary if defined?(CronJob)
|
|
12
|
+
@webhook_stats = WebhookDelivery.summary if defined?(WebhookDelivery)
|
|
13
|
+
@exception_stats = collect_exception_stats
|
|
14
|
+
@platform_stats = collect_platform_stats
|
|
15
|
+
@database_stats = collect_database_stats
|
|
16
|
+
@backup_stats = collect_backup_stats
|
|
17
|
+
@storm_stats = collect_storm_stats if defined?(RailsNexus::StormProtection)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def verify_access
|
|
23
|
+
config = RailsNexus.configuration
|
|
24
|
+
return if config.auth_block.nil?
|
|
25
|
+
unless config.auth_block&.call(self)
|
|
26
|
+
render plain: "Forbidden", status: :forbidden
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def collect_system_stats
|
|
31
|
+
stats = {}
|
|
32
|
+
|
|
33
|
+
# Memory
|
|
34
|
+
if File.exist?("/proc/meminfo")
|
|
35
|
+
meminfo = File.read("/proc/meminfo")
|
|
36
|
+
total = meminfo[/MemTotal:\s+(\d+)/, 1].to_i
|
|
37
|
+
available = meminfo[/MemAvailable:\s+(\d+)/, 1].to_i
|
|
38
|
+
used = total - available
|
|
39
|
+
stats[:memory] = {
|
|
40
|
+
total_mb: (total / 1024.0).round(1),
|
|
41
|
+
used_mb: (used / 1024.0).round(1),
|
|
42
|
+
available_mb: (available / 1024.0).round(1),
|
|
43
|
+
usage_percent: total > 0 ? (used.to_f / total * 100).round(1) : 0
|
|
44
|
+
}
|
|
45
|
+
else
|
|
46
|
+
stats[:memory] = collect_memory_via_ruby
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# CPU Load
|
|
50
|
+
if File.exist?("/proc/loadavg")
|
|
51
|
+
loadavg = File.read("/proc/loadavg").split
|
|
52
|
+
stats[:cpu] = {
|
|
53
|
+
load_1m: loadavg[0].to_f,
|
|
54
|
+
load_5m: loadavg[1].to_f,
|
|
55
|
+
load_15m: loadavg[2].to_f,
|
|
56
|
+
cores: cpu_core_count
|
|
57
|
+
}
|
|
58
|
+
else
|
|
59
|
+
stats[:cpu] = {
|
|
60
|
+
load_1m: 0,
|
|
61
|
+
load_5m: 0,
|
|
62
|
+
load_15m: 0,
|
|
63
|
+
cores: cpu_core_count
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Disk
|
|
68
|
+
stats[:disk] = collect_disk_stats
|
|
69
|
+
|
|
70
|
+
# Process
|
|
71
|
+
process_stats = {}
|
|
72
|
+
process_stats[:pid] = Process.pid
|
|
73
|
+
process_stats[:ppid] = Process.ppid
|
|
74
|
+
begin
|
|
75
|
+
process_stats[:uptime_seconds] = (Time.now - Process::Times.new.stime).to_i
|
|
76
|
+
rescue StandardError
|
|
77
|
+
process_stats[:uptime_seconds] = 0
|
|
78
|
+
end
|
|
79
|
+
pid = Process.pid
|
|
80
|
+
begin
|
|
81
|
+
process_stats[:thread_count] = Dir.glob("/proc/#{pid}/task/*").size
|
|
82
|
+
rescue StandardError
|
|
83
|
+
process_stats[:thread_count] = "N/A"
|
|
84
|
+
end
|
|
85
|
+
begin
|
|
86
|
+
process_stats[:open_files] = `ls /proc/#{pid}/fd 2>/dev/null | wc -l`.strip.to_i
|
|
87
|
+
rescue StandardError
|
|
88
|
+
process_stats[:open_files] = 0
|
|
89
|
+
end
|
|
90
|
+
stats[:process] = process_stats
|
|
91
|
+
|
|
92
|
+
stats
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def collect_memory_via_ruby
|
|
96
|
+
# Fallback for non-Linux systems
|
|
97
|
+
gc_stat = GC.stat
|
|
98
|
+
heap_pages = gc_stat[:heap_allocated_pages] || 0
|
|
99
|
+
page_size = (gc_stat[:heap_page_size] || 4096)
|
|
100
|
+
total_bytes = heap_pages * page_size
|
|
101
|
+
{
|
|
102
|
+
total_mb: (total_bytes / 1024.0 / 1024.0).round(1),
|
|
103
|
+
used_mb: 0,
|
|
104
|
+
available_mb: 0,
|
|
105
|
+
usage_percent: 0,
|
|
106
|
+
gc_stat: {
|
|
107
|
+
total_allocated: gc_stat[:total_allocated_objects],
|
|
108
|
+
total_freed: gc_stat[:total_freed_objects],
|
|
109
|
+
heap_allocated: heap_pages
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def cpu_core_count
|
|
115
|
+
if File.exist?("/proc/cpuinfo")
|
|
116
|
+
File.read("/proc/cpuinfo").scan(/^processor\s*:/).size
|
|
117
|
+
else
|
|
118
|
+
Etc.nprocessors rescue 1
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def collect_disk_stats
|
|
123
|
+
stat = Sys::Filesystem.stat("/") rescue nil
|
|
124
|
+
if stat
|
|
125
|
+
total = stat.blocks * stat.fragment_size
|
|
126
|
+
available = stat.bavail * stat.fragment_size
|
|
127
|
+
used = total - available
|
|
128
|
+
{
|
|
129
|
+
total_gb: (total / 1024.0 / 1024.0 / 1024.0).round(1),
|
|
130
|
+
used_gb: (used / 1024.0 / 1024.0 / 1024.0).round(1),
|
|
131
|
+
available_gb: (available / 1024.0 / 1024.0 / 1024.0).round(1),
|
|
132
|
+
usage_percent: total > 0 ? (used.to_f / total * 100).round(1) : 0
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
# Fallback: use `df` command
|
|
136
|
+
df_output = `df -h / 2>/dev/null`.split("\n").last&.split
|
|
137
|
+
if df_output
|
|
138
|
+
{
|
|
139
|
+
total_gb: df_output[1],
|
|
140
|
+
used_gb: df_output[2],
|
|
141
|
+
available_gb: df_output[3],
|
|
142
|
+
usage_percent: df_output[4]&.to_i || 0
|
|
143
|
+
}
|
|
144
|
+
else
|
|
145
|
+
{ total_gb: "N/A", used_gb: "N/A", available_gb: "N/A", usage_percent: 0 }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def collect_ruby_stats
|
|
151
|
+
{
|
|
152
|
+
ruby_version: RUBY_VERSION,
|
|
153
|
+
ruby_platform: RUBY_PLATFORM,
|
|
154
|
+
rails_version: Rails::VERSION::STRING,
|
|
155
|
+
bundler_version: Bundler::VERSION,
|
|
156
|
+
environment: Rails.env,
|
|
157
|
+
app_name: RailsNexus.configuration.application_name || "RailsNexus",
|
|
158
|
+
rails_nexus_version: RailsNexus::VERSION,
|
|
159
|
+
process_uptime: process_uptime,
|
|
160
|
+
timezone: Time.zone.name,
|
|
161
|
+
database_adapter: ActiveRecord::Base.connection.adapter_name
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def collect_sidekiq_stats
|
|
166
|
+
require "sidekiq/api"
|
|
167
|
+
redis = Sidekiq.redis { |c| c }
|
|
168
|
+
stats = Sidekiq::Stats.new
|
|
169
|
+
{
|
|
170
|
+
processed: stats.processed,
|
|
171
|
+
failed: stats.failed,
|
|
172
|
+
enqueued: stats.enqueued,
|
|
173
|
+
queues: stats.queues.transform_values { |v| v },
|
|
174
|
+
workers: Sidekiq::Workers.new.size,
|
|
175
|
+
redis_version: redis.info["redis_version"],
|
|
176
|
+
redis_uptime_seconds: redis.info["uptime_in_seconds"].to_i,
|
|
177
|
+
redis_memory_mb: (redis.info["used_memory"].to_f / 1024 / 1024).round(2),
|
|
178
|
+
retry_size: stats.retry_size,
|
|
179
|
+
scheduled_size: stats.scheduled_size
|
|
180
|
+
}
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
{ error: e.message }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def collect_storm_stats
|
|
186
|
+
return { enabled: false } unless defined?(RailsNexus::StormProtection)
|
|
187
|
+
RailsNexus::StormProtection.stats
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
{ enabled: false, error: e.message }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def collect_database_stats
|
|
193
|
+
stats = {}
|
|
194
|
+
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
|
195
|
+
|
|
196
|
+
# Connection pool
|
|
197
|
+
pool = ActiveRecord::Base.connection_pool
|
|
198
|
+
stats[:adapter] = ActiveRecord::Base.connection.adapter_name
|
|
199
|
+
stats[:pool_size] = pool.size
|
|
200
|
+
stats[:pool_connections] = pool.connections.size
|
|
201
|
+
stats[:pool_available] = pool.available? ? pool.connections.size : 0
|
|
202
|
+
|
|
203
|
+
# Database version
|
|
204
|
+
begin
|
|
205
|
+
stats[:version] = ActiveRecord::Base.connection.select_value("SELECT version()")
|
|
206
|
+
rescue StandardError
|
|
207
|
+
stats[:version] = "N/A"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Table stats (rails_nexus tables)
|
|
211
|
+
stats[:tables] = collect_table_stats
|
|
212
|
+
|
|
213
|
+
# Connection test
|
|
214
|
+
start_time = Time.now
|
|
215
|
+
ActiveRecord::Base.connection.execute("SELECT 1")
|
|
216
|
+
stats[:ping_ms] = ((Time.now - start_time) * 1000).round(2)
|
|
217
|
+
|
|
218
|
+
# Active connections
|
|
219
|
+
begin
|
|
220
|
+
if adapter.include?("mysql") || adapter.include?("trilogy")
|
|
221
|
+
result = ActiveRecord::Base.connection.execute("SHOW STATUS WHERE Variable_name = 'Threads_connected'")
|
|
222
|
+
row = result.first
|
|
223
|
+
stats[:active_connections] = row ? row["Value"].to_i : 0
|
|
224
|
+
elsif adapter.include?("postgresql")
|
|
225
|
+
stats[:active_connections] = ActiveRecord::Base.connection.select_value(
|
|
226
|
+
"SELECT count(*) FROM pg_stat_activity WHERE state = 'active'"
|
|
227
|
+
)
|
|
228
|
+
else
|
|
229
|
+
stats[:active_connections] = pool.connections.size
|
|
230
|
+
end
|
|
231
|
+
rescue StandardError
|
|
232
|
+
stats[:active_connections] = pool.connections.size
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Uptime
|
|
236
|
+
begin
|
|
237
|
+
if adapter.include?("mysql") || adapter.include?("trilogy")
|
|
238
|
+
result = ActiveRecord::Base.connection.execute("SHOW STATUS WHERE Variable_name = 'Uptime'")
|
|
239
|
+
row = result.first
|
|
240
|
+
uptime_seconds = row ? row["Value"].to_i : 0
|
|
241
|
+
days = uptime_seconds / 86400
|
|
242
|
+
hours = (uptime_seconds % 86400) / 3600
|
|
243
|
+
stats[:uptime] = "#{days}d #{hours}h"
|
|
244
|
+
elsif adapter.include?("postgresql")
|
|
245
|
+
stats[:uptime] = ActiveRecord::Base.connection.select_value(
|
|
246
|
+
"SELECT now() - pg_postmaster_start_time()").to_s
|
|
247
|
+
else
|
|
248
|
+
stats[:uptime] = "N/A"
|
|
249
|
+
end
|
|
250
|
+
rescue StandardError
|
|
251
|
+
stats[:uptime] = "N/A"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
stats
|
|
255
|
+
rescue StandardError => e
|
|
256
|
+
{ error: e.message }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def collect_backup_stats
|
|
260
|
+
stats = { detected: false, gem: nil, status: "unknown", last_backup: nil, details: {} }
|
|
261
|
+
|
|
262
|
+
# Check for backup gem
|
|
263
|
+
if defined?(Backup)
|
|
264
|
+
stats[:detected] = true
|
|
265
|
+
stats[:gem] = "backup"
|
|
266
|
+
stats[:version] = Backup::VERSION if Backup.const_defined?(:VERSION)
|
|
267
|
+
|
|
268
|
+
begin
|
|
269
|
+
# Check for backup configuration
|
|
270
|
+
config_path = Rails.root.join("config", "backup")
|
|
271
|
+
if config_path.exist?
|
|
272
|
+
stats[:details][:config_path] = config_path.to_s
|
|
273
|
+
stats[:status] = "configured"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Check for backup logs
|
|
277
|
+
log_path = Rails.root.join("log", "backup")
|
|
278
|
+
if log_path.exist?
|
|
279
|
+
log_files = log_path.glob("*.log").sort_by(&:mtime).reverse
|
|
280
|
+
if log_files.any?
|
|
281
|
+
last_log = log_files.first
|
|
282
|
+
stats[:details][:log_file] = last_log.to_s
|
|
283
|
+
stats[:details][:log_size] = (last_log.size / 1024.0).round(1)
|
|
284
|
+
|
|
285
|
+
# Parse last backup time from log
|
|
286
|
+
log_content = last_log.read(1000) # Read first 1KB
|
|
287
|
+
if log_content.match(/started at (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/)
|
|
288
|
+
stats[:last_backup] = Time.parse($1)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Check for backup models
|
|
294
|
+
models = Backup::Model.all rescue []
|
|
295
|
+
if models.any?
|
|
296
|
+
stats[:details][:models] = models.map do |m|
|
|
297
|
+
{
|
|
298
|
+
name: m.name,
|
|
299
|
+
schedule: m.schedule&.to_s,
|
|
300
|
+
triggers: m.triggers.map(&:name)
|
|
301
|
+
}
|
|
302
|
+
end
|
|
303
|
+
stats[:status] = "configured"
|
|
304
|
+
end
|
|
305
|
+
rescue StandardError => e
|
|
306
|
+
stats[:details][:error] = e.message
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Check for pg_dump (PostgreSQL)
|
|
311
|
+
if system("which pg_dump > /dev/null 2>&1")
|
|
312
|
+
stats[:detected] = true
|
|
313
|
+
stats[:gem] ||= "pg_dump"
|
|
314
|
+
stats[:details][:pg_dump] = `pg_dump --version 2>/dev/null`.strip
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Check for mysqldump (MySQL)
|
|
318
|
+
if system("which mysqldump > /dev/null 2>&1")
|
|
319
|
+
stats[:detected] = true
|
|
320
|
+
stats[:gem] ||= "mysqldump"
|
|
321
|
+
stats[:details][:mysqldump] = `mysqldump --version 2>/dev/null`.strip
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Check for AWS CLI (RDS backups)
|
|
325
|
+
if system("which aws > /dev/null 2>&1")
|
|
326
|
+
stats[:detected] = true
|
|
327
|
+
stats[:gem] ||= "aws-cli"
|
|
328
|
+
stats[:details][:aws_cli] = `aws --version 2>/dev/null`.strip
|
|
329
|
+
|
|
330
|
+
# Check for RDS snapshots if configured
|
|
331
|
+
begin
|
|
332
|
+
rds_client = Aws::RDS::Client.new
|
|
333
|
+
snapshots = rds_client.describe_db_snapshots(db_snapshot_identifier: "automated-*")
|
|
334
|
+
stats[:details][:rds_snapshots] = snapshots.db_snapshots.count
|
|
335
|
+
stats[:status] = "configured" if snapshots.db_snapshots.any?
|
|
336
|
+
rescue StandardError
|
|
337
|
+
# AWS not configured or no access
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Check for docker backup solutions
|
|
342
|
+
if File.exist?("/var/lib/docker")
|
|
343
|
+
stats[:detected] = true
|
|
344
|
+
stats[:gem] ||= "docker"
|
|
345
|
+
stats[:details][:docker] = `docker --version 2>/dev/null`.strip
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Check for crontab entries related to backups
|
|
349
|
+
begin
|
|
350
|
+
crontab = `crontab -l 2>/dev/null`
|
|
351
|
+
if crontab.include?("backup") || crontab.include?("dump")
|
|
352
|
+
stats[:details][:crontab_backup] = true
|
|
353
|
+
stats[:status] = "scheduled" if stats[:status] == "unknown"
|
|
354
|
+
end
|
|
355
|
+
rescue StandardError
|
|
356
|
+
# Cannot read crontab
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Check for backup-related environment variables
|
|
360
|
+
backup_env_vars = %w[BACKUP_DIR BACKUP_S3_BUCKET BACKUP_GCS_BUCKET DATABASE_BACKUP_URL]
|
|
361
|
+
found_env = backup_env_vars.select { |var| ENV[var] }
|
|
362
|
+
if found_env.any?
|
|
363
|
+
stats[:detected] = true
|
|
364
|
+
stats[:details][:env_vars] = found_env
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Determine overall status
|
|
368
|
+
if stats[:last_backup]
|
|
369
|
+
hours_since = (Time.now - stats[:last_backup]) / 3600
|
|
370
|
+
if hours_since < 24
|
|
371
|
+
stats[:status] = "healthy"
|
|
372
|
+
elsif hours_since < 168 # 7 days
|
|
373
|
+
stats[:status] = "warning"
|
|
374
|
+
else
|
|
375
|
+
stats[:status] = "stale"
|
|
376
|
+
end
|
|
377
|
+
elsif stats[:status] == "unknown"
|
|
378
|
+
stats[:status] = "not_configured"
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
stats
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def collect_table_stats
|
|
385
|
+
tables = []
|
|
386
|
+
|
|
387
|
+
# RailsNexus tables
|
|
388
|
+
rails_nexus_tables = %w[rails_nexus_exceptions rails_nexus_cron_jobs rails_nexus_webhook_deliveries]
|
|
389
|
+
|
|
390
|
+
rails_nexus_tables.each do |table_name|
|
|
391
|
+
begin
|
|
392
|
+
next unless ActiveRecord::Base.connection.table_exists?(table_name)
|
|
393
|
+
|
|
394
|
+
count = ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM #{table_name}")
|
|
395
|
+
tables << {
|
|
396
|
+
name: table_name,
|
|
397
|
+
row_count: count,
|
|
398
|
+
size_mb: 0,
|
|
399
|
+
status: count > 0 ? "active" : "empty"
|
|
400
|
+
}
|
|
401
|
+
rescue StandardError
|
|
402
|
+
# Table might not exist yet
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Try to get table sizes if available
|
|
407
|
+
begin
|
|
408
|
+
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
|
409
|
+
if adapter.include?("mysql") || adapter.include?("trilogy")
|
|
410
|
+
result = ActiveRecord::Base.connection.execute(<<~SQL)
|
|
411
|
+
SELECT table_name, ROUND((data_length + index_length) / 1024 / 1024, 2) as size_mb
|
|
412
|
+
FROM information_schema.tables
|
|
413
|
+
WHERE table_schema = DATABASE()
|
|
414
|
+
AND table_name IN ('rails_nexus_exceptions', 'rails_nexus_cron_jobs', 'rails_nexus_webhook_deliveries')
|
|
415
|
+
SQL
|
|
416
|
+
result.each do |row|
|
|
417
|
+
table = tables.find { |t| t[:name] == row["table_name"] }
|
|
418
|
+
table[:size_mb] = row["size_mb"] if table
|
|
419
|
+
end
|
|
420
|
+
elsif adapter.include?("postgresql")
|
|
421
|
+
result = ActiveRecord::Base.connection.execute(<<~SQL)
|
|
422
|
+
SELECT relname as table_name,
|
|
423
|
+
pg_size_pretty(pg_total_relation_size(relid)) as size_pretty,
|
|
424
|
+
pg_total_relation_size(relid) as size_bytes
|
|
425
|
+
FROM pg_catalog.pg_statio_user_tables
|
|
426
|
+
WHERE relname IN ('rails_nexus_exceptions', 'rails_nexus_cron_jobs', 'rails_nexus_webhook_deliveries')
|
|
427
|
+
SQL
|
|
428
|
+
result.each do |row|
|
|
429
|
+
table = tables.find { |t| t[:name] == row["table_name"] }
|
|
430
|
+
table[:size_mb] = (row["size_bytes"].to_f / 1024 / 1024).round(2) if table
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
rescue StandardError
|
|
434
|
+
# Ignore size collection errors
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
tables
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def collect_platform_stats
|
|
441
|
+
{
|
|
442
|
+
breakdown: LoggedException.platform_stats,
|
|
443
|
+
web: LoggedException.platform_stats.find { |p| p.platform == "web" },
|
|
444
|
+
ios: LoggedException.platform_stats.find { |p| p.platform == "ios" },
|
|
445
|
+
android: LoggedException.platform_stats.find { |p| p.platform == "android" },
|
|
446
|
+
api: LoggedException.platform_stats.find { |p| p.platform == "api" },
|
|
447
|
+
bot: LoggedException.platform_stats.find { |p| p.platform == "bot" }
|
|
448
|
+
}
|
|
449
|
+
rescue StandardError
|
|
450
|
+
{}
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def collect_exception_stats
|
|
454
|
+
{
|
|
455
|
+
total: LoggedException.count,
|
|
456
|
+
today: LoggedException.where("created_at >= ?", Time.current.beginning_of_day).count,
|
|
457
|
+
this_week: LoggedException.where("created_at >= ?", 1.week.ago).count,
|
|
458
|
+
this_month: LoggedException.where("created_at >= ?", 1.month.ago).count,
|
|
459
|
+
unique_classes: LoggedException.distinct.count(:exception_class),
|
|
460
|
+
top_classes: LoggedException.group(:exception_class).order("count_all DESC").limit(5).count,
|
|
461
|
+
hourly_trend: hourly_trend_data
|
|
462
|
+
}
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def hourly_trend_data
|
|
466
|
+
return [] unless defined?(LoggedException)
|
|
467
|
+
24.times.map do |i|
|
|
468
|
+
hour = i.hours.ago
|
|
469
|
+
{
|
|
470
|
+
hour: hour.strftime("%H:00"),
|
|
471
|
+
count: LoggedException.where(
|
|
472
|
+
created_at: hour.beginning_of_hour..hour.end_of_hour
|
|
473
|
+
).count
|
|
474
|
+
}
|
|
475
|
+
end.reverse
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def process_uptime
|
|
479
|
+
start_time = File.read("/proc/#{Process.pid}/stat").split[21].to_i rescue nil
|
|
480
|
+
if start_time
|
|
481
|
+
uptime_seconds = Time.now.to_i - (Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i - start_time)
|
|
482
|
+
seconds = Time.now.to_i - start_time
|
|
483
|
+
days = seconds / 86400
|
|
484
|
+
hours = (seconds % 86400) / 3600
|
|
485
|
+
minutes = (seconds % 3600) / 60
|
|
486
|
+
"#{days}d #{hours}h #{minutes}m"
|
|
487
|
+
else
|
|
488
|
+
"N/A"
|
|
489
|
+
end
|
|
490
|
+
rescue StandardError
|
|
491
|
+
"N/A"
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module RailsNexus
|
|
2
|
+
class WorkflowController < ApplicationController
|
|
3
|
+
before_action :find_exception
|
|
4
|
+
|
|
5
|
+
# POST /rails_nexus/logged_exceptions/:id/assign
|
|
6
|
+
def assign
|
|
7
|
+
@exception.assign_to(params[:assigned_to], author: current_author)
|
|
8
|
+
respond_to do |format|
|
|
9
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("workflow-panel", partial: "rails_nexus/logged_exceptions/workflow_panel", locals: { exception: @exception }) }
|
|
10
|
+
format.html { redirect_back fallback_location: logged_exception_path(@exception), notice: "Assigned to #{params[:assigned_to]}" }
|
|
11
|
+
format.json { render json: { status: "ok", assigned_to: @exception.assigned_to } }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# POST /rails_nexus/logged_exceptions/:id/priority
|
|
16
|
+
def set_priority
|
|
17
|
+
@exception.set_priority(params[:priority], author: current_author)
|
|
18
|
+
respond_to do |format|
|
|
19
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("workflow-panel", partial: "rails_nexus/logged_exceptions/workflow_panel", locals: { exception: @exception }) }
|
|
20
|
+
format.html { redirect_back fallback_location: logged_exception_path(@exception), notice: "Priority set to #{params[:priority]}" }
|
|
21
|
+
format.json { render json: { status: "ok", priority: @exception.priority } }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# POST /rails_nexus/logged_exceptions/:id/snooze
|
|
26
|
+
def snooze
|
|
27
|
+
duration = parse_duration(params[:duration])
|
|
28
|
+
@exception.snooze(duration, author: current_author)
|
|
29
|
+
respond_to do |format|
|
|
30
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("workflow-panel", partial: "rails_nexus/logged_exceptions/workflow_panel", locals: { exception: @exception }) }
|
|
31
|
+
format.html { redirect_back fallback_location: logged_exception_path(@exception), notice: "Snoozed for #{duration.inspect}" }
|
|
32
|
+
format.json { render json: { status: "ok", snoozed_until: @exception.snoozed_until } }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# POST /rails_nexus/logged_exceptions/:id/mute
|
|
37
|
+
def mute
|
|
38
|
+
@exception.mute!(author: current_author)
|
|
39
|
+
respond_to do |format|
|
|
40
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("workflow-panel", partial: "rails_nexus/logged_exceptions/workflow_panel", locals: { exception: @exception }) }
|
|
41
|
+
format.html { redirect_back fallback_location: logged_exception_path(@exception), notice: "Muted" }
|
|
42
|
+
format.json { render json: { status: "ok", muted: @exception.muted } }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# POST /rails_nexus/logged_exceptions/:id/unmute
|
|
47
|
+
def unmute
|
|
48
|
+
@exception.unmute!(author: current_author)
|
|
49
|
+
respond_to do |format|
|
|
50
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("workflow-panel", partial: "rails_nexus/logged_exceptions/workflow_panel", locals: { exception: @exception }) }
|
|
51
|
+
format.html { redirect_back fallback_location: logged_exception_path(@exception), notice: "Unmuted" }
|
|
52
|
+
format.json { render json: { status: "ok", muted: @exception.muted } }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# POST /rails_nexus/logged_exceptions/:id/comment
|
|
57
|
+
def add_comment
|
|
58
|
+
@exception.add_comment(author: current_author, body: params[:body], comment_type: params[:comment_type] || "comment")
|
|
59
|
+
respond_to do |format|
|
|
60
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("comments-list", partial: "rails_nexus/logged_exceptions/comments_list", locals: { exception: @exception }) }
|
|
61
|
+
format.html { redirect_back fallback_location: logged_exception_path(@exception), notice: "Comment added" }
|
|
62
|
+
format.json { render json: { status: "ok", comments_count: @exception.comments_count } }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def find_exception
|
|
69
|
+
@exception = RailsNexus::LoggedException.find(params[:id])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def current_author
|
|
73
|
+
# Try to get current user from the host app's auth system
|
|
74
|
+
if respond_to?(:current_user, true) && current_user
|
|
75
|
+
current_user.respond_to?(:email) ? current_user.email : current_user.to_s
|
|
76
|
+
elsif RailsNexus.configuration.current_user_block
|
|
77
|
+
RailsNexus.configuration.current_user_block.call(self)
|
|
78
|
+
else
|
|
79
|
+
"anonymous"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_duration(str)
|
|
84
|
+
case str.to_s
|
|
85
|
+
when /(\d+)\s*hour/
|
|
86
|
+
$1.to_i.hours
|
|
87
|
+
when /(\d+)\s*day/
|
|
88
|
+
$1.to_i.days
|
|
89
|
+
when /(\d+)\s*week/
|
|
90
|
+
$1.to_i.weeks
|
|
91
|
+
else
|
|
92
|
+
24.hours # default: 1 day
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|