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,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
class Backup < BaseRecord
|
|
5
|
+
self.table_name = "rails_nexus_backups"
|
|
6
|
+
|
|
7
|
+
validates :model_name, presence: true
|
|
8
|
+
validates :status, presence: true, inclusion: { in: %w[running success failed] }
|
|
9
|
+
validates :started_at, presence: true
|
|
10
|
+
|
|
11
|
+
scope :recent, ->(hours = 24) { where("started_at >= ?", hours.hours.ago) }
|
|
12
|
+
scope :successful, -> { where(status: "success") }
|
|
13
|
+
scope :failed, -> { where(status: "failed") }
|
|
14
|
+
scope :running, -> { where(status: "running") }
|
|
15
|
+
scope :by_model, ->(name) { where(model_name: name) }
|
|
16
|
+
scope :latest_first, -> { order(started_at: :desc) }
|
|
17
|
+
|
|
18
|
+
# Start tracking a backup run
|
|
19
|
+
def self.start!(model_name:, triggered_by: "system")
|
|
20
|
+
create!(
|
|
21
|
+
model_name: model_name,
|
|
22
|
+
status: "running",
|
|
23
|
+
triggered_by: triggered_by,
|
|
24
|
+
started_at: Time.current
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Mark backup as completed
|
|
29
|
+
def succeed!(file_path: nil, file_size: nil)
|
|
30
|
+
update!(
|
|
31
|
+
status: "success",
|
|
32
|
+
file_path: file_path,
|
|
33
|
+
file_size: file_size,
|
|
34
|
+
duration: (Time.current - started_at).round(2),
|
|
35
|
+
completed_at: Time.current
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Mark backup as failed
|
|
40
|
+
def fail!(error_message:)
|
|
41
|
+
update!(
|
|
42
|
+
status: "failed",
|
|
43
|
+
error_message: error_message,
|
|
44
|
+
duration: (Time.current - started_at).round(2),
|
|
45
|
+
completed_at: Time.current
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get success rate for a model over the last N days
|
|
50
|
+
def self.success_rate(model_name:, days: 7)
|
|
51
|
+
records = by_model(model_name).where("started_at >= ?", days.days.ago).where.not(status: "running")
|
|
52
|
+
return 0 if records.empty?
|
|
53
|
+
(records.successful.count.to_f / records.count * 100).round(1)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get backup health summary
|
|
57
|
+
def self.health_summary(alert_threshold_hours: 24)
|
|
58
|
+
models = RailsNexus.configuration.backup_models || []
|
|
59
|
+
results = models.map do |model_name|
|
|
60
|
+
latest = by_model(model_name).successful.latest_first.first
|
|
61
|
+
age_hours = latest ? ((Time.current - latest.started_at) / 3600).round(1) : nil
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
model_name: model_name,
|
|
65
|
+
last_backup: latest&.started_at,
|
|
66
|
+
age_hours: age_hours,
|
|
67
|
+
status: age_hours.nil? ? "unknown" : (age_hours < alert_threshold_hours ? "healthy" : "stale"),
|
|
68
|
+
success_rate: success_rate(model_name: model_name),
|
|
69
|
+
last_file_size: latest&.file_size
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
healthy: results.all? { |r| r[:status] == "healthy" },
|
|
75
|
+
models: results,
|
|
76
|
+
alerts: results.select { |r| r[:status] == "stale" || r[:status] == "unknown" }
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Cleanup old backup records
|
|
81
|
+
def self.cleanup!(retention_days: 30)
|
|
82
|
+
where("started_at < ?", retention_days.days.ago).delete_all
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Format file size
|
|
86
|
+
def file_size_human
|
|
87
|
+
return "—" unless file_size
|
|
88
|
+
units = ["B", "KB", "MB", "GB"]
|
|
89
|
+
size = file_size.to_f
|
|
90
|
+
units.each do |unit|
|
|
91
|
+
return "#{size.round(1)} #{unit}" if size < 1024
|
|
92
|
+
size /= 1024
|
|
93
|
+
end
|
|
94
|
+
"#{size.round(1)} TB"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Duration formatted
|
|
98
|
+
def duration_human
|
|
99
|
+
return "—" unless duration
|
|
100
|
+
if duration < 60
|
|
101
|
+
"#{duration.round(1)}s"
|
|
102
|
+
else
|
|
103
|
+
"#{(duration / 60).floor}m #{(duration % 60).round}s"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
# Abstract base class for all RailsNexus models.
|
|
5
|
+
# Supports connecting to a separate database via configuration.
|
|
6
|
+
#
|
|
7
|
+
# When NO database config is set, this behaves identically to ApplicationRecord.
|
|
8
|
+
# The host app's default connection is used for all queries.
|
|
9
|
+
#
|
|
10
|
+
# Config options:
|
|
11
|
+
# config.database_url = "mysql2://user:pass@localhost/rails_nexus"
|
|
12
|
+
# config.database_name = "rails_nexus" # looks up in config/database.yml
|
|
13
|
+
# config.database_yml = "config/rails_nexus_database.yml"
|
|
14
|
+
#
|
|
15
|
+
class BaseRecord < ApplicationRecord
|
|
16
|
+
self.abstract_class = true
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Check if a separate database is configured
|
|
20
|
+
def separate_database?
|
|
21
|
+
config = RailsNexus.configuration
|
|
22
|
+
config.database_url.present? || config.database_name.present? || config.database_yml.present?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module RailsNexus
|
|
2
|
+
class Comment < ActiveRecord::Base
|
|
3
|
+
self.table_name = "rails_nexus_comments"
|
|
4
|
+
|
|
5
|
+
belongs_to :logged_exception, counter_cache: :comments_count
|
|
6
|
+
|
|
7
|
+
validates :author, presence: true
|
|
8
|
+
validates :body, presence: true
|
|
9
|
+
|
|
10
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
11
|
+
scope :by_type, ->(type) { where(comment_type: type) }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
class CronJob < BaseRecord
|
|
5
|
+
self.table_name = "rails_nexus_cron_jobs"
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[pending running success failed].freeze
|
|
8
|
+
|
|
9
|
+
validates :name, presence: true
|
|
10
|
+
validates :status, inclusion: { in: STATUSES }
|
|
11
|
+
|
|
12
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
13
|
+
scope :by_status, ->(status) { where(status: status) }
|
|
14
|
+
scope :failed, -> { by_status("failed") }
|
|
15
|
+
scope :successful, -> { by_status("success") }
|
|
16
|
+
scope :running, -> { by_status("running") }
|
|
17
|
+
scope :last_24h, -> { where("created_at >= ?", 24.hours.ago) }
|
|
18
|
+
scope :last_7d, -> { where("created_at >= ?", 7.days.ago) }
|
|
19
|
+
|
|
20
|
+
# Start a new cron job run
|
|
21
|
+
def self.start!(name, metadata: {})
|
|
22
|
+
create!(
|
|
23
|
+
name: name,
|
|
24
|
+
status: "running",
|
|
25
|
+
started_at: Time.current,
|
|
26
|
+
hostname: Socket.gethostname,
|
|
27
|
+
metadata: metadata
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Mark as successful
|
|
32
|
+
def succeed!(output: nil, duration: nil)
|
|
33
|
+
update!(
|
|
34
|
+
status: "success",
|
|
35
|
+
finished_at: Time.current,
|
|
36
|
+
output: output,
|
|
37
|
+
duration: duration || ((finished_at || Time.current) - (started_at || created_at))
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Mark as failed
|
|
42
|
+
def fail!(error: nil, duration: nil)
|
|
43
|
+
update!(
|
|
44
|
+
status: "failed",
|
|
45
|
+
finished_at: Time.current,
|
|
46
|
+
error_message: error,
|
|
47
|
+
duration: duration || ((finished_at || Time.current) - (started_at || created_at))
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Stats summary
|
|
52
|
+
def self.summary
|
|
53
|
+
{
|
|
54
|
+
total: count,
|
|
55
|
+
successful: successful.count,
|
|
56
|
+
failed: failed.count,
|
|
57
|
+
running: running.count,
|
|
58
|
+
last_run: recent.first,
|
|
59
|
+
failure_rate: count > 0 ? (failed.count.to_f / count * 100).round(1) : 0
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Success rate over last N days
|
|
64
|
+
def self.success_rate(days: 7)
|
|
65
|
+
recent_jobs = where("created_at >= ?", days.days.ago)
|
|
66
|
+
total = recent_jobs.count
|
|
67
|
+
return 0 if total == 0
|
|
68
|
+
|
|
69
|
+
(recent_jobs.successful.count.to_f / total * 100).round(1)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
class DatabaseStat < BaseRecord
|
|
5
|
+
self.table_name = "rails_nexus_database_stats"
|
|
6
|
+
|
|
7
|
+
validates :recorded_at, presence: true
|
|
8
|
+
|
|
9
|
+
scope :recent, ->(hours = 24) { where("recorded_at >= ?", hours.hours.ago) }
|
|
10
|
+
scope :with_tables, -> { where.not(table_name: nil) }
|
|
11
|
+
scope :pool_stats, -> { where(table_name: nil) }
|
|
12
|
+
|
|
13
|
+
# Collect database health snapshot
|
|
14
|
+
def self.collect!
|
|
15
|
+
now = Time.current
|
|
16
|
+
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
|
17
|
+
|
|
18
|
+
# Connection pool stats
|
|
19
|
+
pool = ActiveRecord::Base.connection_pool
|
|
20
|
+
stats = pool_stats(pool)
|
|
21
|
+
create!(stats.merge(recorded_at: now))
|
|
22
|
+
|
|
23
|
+
# Table stats (MySQL only)
|
|
24
|
+
if adapter.include?("mysql")
|
|
25
|
+
collect_mysql_stats(now)
|
|
26
|
+
elsif adapter.include?("postgresql")
|
|
27
|
+
collect_postgresql_stats(now)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get pool utilization over time
|
|
32
|
+
def self.pool_utilization(hours: 24)
|
|
33
|
+
pool_stats
|
|
34
|
+
.where("recorded_at >= ?", hours.hours.ago)
|
|
35
|
+
.order(:recorded_at)
|
|
36
|
+
.map { |s| { time: s.recorded_at, utilization: s.utilization, busy: s.busy, idle: s.idle } }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get slow queries
|
|
40
|
+
def self.slow_queries(limit: 10)
|
|
41
|
+
where.not(slow_queries: nil)
|
|
42
|
+
.order(recorded_at: :desc)
|
|
43
|
+
.limit(1)
|
|
44
|
+
.flat_map(&:slow_queries)
|
|
45
|
+
.first(limit)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Get N+1 patterns
|
|
49
|
+
def self.n1_patterns
|
|
50
|
+
where.not(n1_patterns: nil)
|
|
51
|
+
.order(recorded_at: :desc)
|
|
52
|
+
.limit(1)
|
|
53
|
+
.flat_map(&:n1_patterns)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def self.pool_stats(pool)
|
|
59
|
+
{
|
|
60
|
+
pool_size: pool.size,
|
|
61
|
+
busy: pool.connections.size,
|
|
62
|
+
idle: pool.size - pool.connections.size,
|
|
63
|
+
dead: 0,
|
|
64
|
+
waiting: pool.stat[:waiting] || 0,
|
|
65
|
+
utilization: pool.size > 0 ? (pool.connections.size.to_f / pool.size * 100).round(1) : 0
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.collect_mysql_stats(now)
|
|
70
|
+
conn = ActiveRecord::Base.connection
|
|
71
|
+
tables = conn.execute(<<~SQL)
|
|
72
|
+
SELECT table_name, table_rows, data_length, index_length, data_free
|
|
73
|
+
FROM information_schema.tables
|
|
74
|
+
WHERE table_schema = DATABASE()
|
|
75
|
+
ORDER BY data_length + index_length DESC
|
|
76
|
+
LIMIT 50
|
|
77
|
+
SQL
|
|
78
|
+
|
|
79
|
+
tables.each do |row|
|
|
80
|
+
create!(
|
|
81
|
+
table_name: row["TABLE_NAME"],
|
|
82
|
+
table_rows: row["TABLE_ROWS"],
|
|
83
|
+
table_size: format_size(row["DATA_LENGTH"]),
|
|
84
|
+
index_size: format_size(row["INDEX_LENGTH"]),
|
|
85
|
+
data_free: format_size(row["DATA_FREE"]),
|
|
86
|
+
recorded_at: now
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
Rails.logger.error("[RailsNexus] Failed to collect MySQL stats: #{e.message}") if defined?(Rails)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.collect_postgresql_stats(now)
|
|
94
|
+
conn = ActiveRecord::Base.connection
|
|
95
|
+
tables = conn.execute(<<~SQL)
|
|
96
|
+
SELECT schemaname, relname, n_tup_ins, n_tup_upd, n_tup_del,
|
|
97
|
+
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
|
|
98
|
+
pg_size_pretty(pg_indexes_size(relid)) as index_size
|
|
99
|
+
FROM pg_stat_user_tables
|
|
100
|
+
ORDER BY pg_total_relation_size(relid) DESC
|
|
101
|
+
LIMIT 50
|
|
102
|
+
SQL
|
|
103
|
+
|
|
104
|
+
tables.each do |row|
|
|
105
|
+
create!(
|
|
106
|
+
table_name: row["relname"],
|
|
107
|
+
table_rows: (row["n_tup_ins"].to_i + row["n_tup_upd"].to_i - row["n_tup_del"].to_i),
|
|
108
|
+
table_size: row["total_size"],
|
|
109
|
+
index_size: row["index_size"],
|
|
110
|
+
recorded_at: now
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
Rails.logger.error("[RailsNexus] Failed to collect PostgreSQL stats: #{e.message}") if defined?(Rails)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.format_size(bytes)
|
|
118
|
+
return "0 B" unless bytes
|
|
119
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
120
|
+
size = bytes.to_f
|
|
121
|
+
units.each do |unit|
|
|
122
|
+
return "#{size.round(1)} #{unit}" if size < 1024
|
|
123
|
+
size /= 1024
|
|
124
|
+
end
|
|
125
|
+
"#{size.round(1)} PB"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
class Event < BaseRecord
|
|
5
|
+
self.table_name = "rails_nexus_events"
|
|
6
|
+
|
|
7
|
+
belongs_to :eventable, polymorphic: true, optional: true
|
|
8
|
+
|
|
9
|
+
validates :event_type, presence: true, inclusion: { in: %w[
|
|
10
|
+
breadcrumb storm_start storm_end
|
|
11
|
+
assignment priority_change snooze unsnooze
|
|
12
|
+
mute unmute comment note status_change
|
|
13
|
+
backup_start backup_complete backup_failed
|
|
14
|
+
exception_created exception_resolved
|
|
15
|
+
] }
|
|
16
|
+
|
|
17
|
+
validates :created_at, presence: true
|
|
18
|
+
|
|
19
|
+
scope :recent, ->(hours = 24) { where("created_at >= ?", hours.hours.ago) }
|
|
20
|
+
scope :by_type, ->(type) { where(event_type: type) }
|
|
21
|
+
scope :breadcrumbs, -> { by_type("breadcrumb") }
|
|
22
|
+
scope :workflow_events, -> { where(event_type: %w[assignment priority_change snooze unsnooze mute unmute comment note]) }
|
|
23
|
+
scope :storm_events, -> { where(event_type: %w[storm_start storm_end]) }
|
|
24
|
+
scope :audit_trail, ->(eventable) { where(eventable: eventable).order(created_at: :desc) }
|
|
25
|
+
|
|
26
|
+
# Record a breadcrumb
|
|
27
|
+
def self.breadcrumb!(message:, metadata: {}, author: nil)
|
|
28
|
+
create!(
|
|
29
|
+
event_type: "breadcrumb",
|
|
30
|
+
message: message,
|
|
31
|
+
metadata: metadata,
|
|
32
|
+
author: author,
|
|
33
|
+
created_at: Time.current
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Record a storm event
|
|
38
|
+
def self.storm_start!(threshold:, current_rate:)
|
|
39
|
+
create!(
|
|
40
|
+
event_type: "storm_start",
|
|
41
|
+
message: "Error storm detected: #{current_rate} errors/sec (threshold: #{threshold}/sec)",
|
|
42
|
+
metadata: { threshold: threshold, current_rate: current_rate },
|
|
43
|
+
created_at: Time.current
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.storm_end!(duration_seconds:)
|
|
48
|
+
create!(
|
|
49
|
+
event_type: "storm_end",
|
|
50
|
+
message: "Error storm ended after #{duration_seconds.round(1)}s",
|
|
51
|
+
metadata: { duration_seconds: duration_seconds },
|
|
52
|
+
created_at: Time.current
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Record a workflow event
|
|
57
|
+
def self.workflow!(event_type:, eventable:, message:, author: nil, metadata: {})
|
|
58
|
+
create!(
|
|
59
|
+
event_type: event_type,
|
|
60
|
+
eventable: eventable,
|
|
61
|
+
message: message,
|
|
62
|
+
author: author,
|
|
63
|
+
metadata: metadata,
|
|
64
|
+
created_at: Time.current
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get event timeline for an exception
|
|
69
|
+
def self.timeline_for(exception)
|
|
70
|
+
where(eventable: exception).order(created_at: :asc)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Cleanup old events
|
|
74
|
+
def self.cleanup!(retention_days: 30)
|
|
75
|
+
where("created_at < ?", retention_days.days.ago).delete_all
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Human-readable event type
|
|
79
|
+
def type_label
|
|
80
|
+
case event_type
|
|
81
|
+
when "breadcrumb" then "🔍 Breadcrumb"
|
|
82
|
+
when "storm_start" then "🌪️ Storm Start"
|
|
83
|
+
when "storm_end" then "☀️ Storm End"
|
|
84
|
+
when "assignment" then "👤 Assigned"
|
|
85
|
+
when "priority_change" then " priority Changed"
|
|
86
|
+
when "snooze" then "😴 Snoozed"
|
|
87
|
+
when "unsnooze" then "⏰ Unsnoozed"
|
|
88
|
+
when "mute" then "🔇 Muted"
|
|
89
|
+
when "unmute" then "🔊 Unmuted"
|
|
90
|
+
when "comment" then "💬 Comment"
|
|
91
|
+
when "note" then "📝 Note"
|
|
92
|
+
when "backup_start" then "💾 Backup Started"
|
|
93
|
+
when "backup_complete" then "✅ Backup Complete"
|
|
94
|
+
when "backup_failed" then "❌ Backup Failed"
|
|
95
|
+
when "exception_created" then "🚨 Exception Created"
|
|
96
|
+
when "exception_resolved" then "✓ Exception Resolved"
|
|
97
|
+
else event_type.humanize
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|