rails_nexus 2.0.0 → 2.0.2
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 +4 -4
- data/app/controllers/rails_nexus/backup_controller.rb +74 -53
- data/app/models/rails_nexus/backup_config.rb +90 -0
- data/app/services/rails_nexus/backup_runner.rb +62 -0
- data/app/services/rails_nexus/backup_service.rb +215 -214
- data/app/views/layouts/rails_nexus/application.html.erb +8 -0
- data/app/views/rails_nexus/backup/_form.html.erb +185 -0
- data/app/views/rails_nexus/backup/config_history.html.erb +67 -0
- data/app/views/rails_nexus/backup/edit.html.erb +15 -0
- data/app/views/rails_nexus/backup/history.html.erb +60 -0
- data/app/views/rails_nexus/backup/index.html.erb +91 -147
- data/app/views/rails_nexus/backup/new.html.erb +15 -0
- data/config/routes.rb +9 -6
- data/lib/generators/rails_nexus/install_generator.rb +1 -13
- data/lib/generators/rails_nexus/templates/migration.rb +119 -11
- data/lib/generators/rails_nexus/templates/rails_nexus.rb +1 -1
- data/lib/rails_nexus/version.rb +1 -1
- data/lib/tasks/rails_nexus.rake +71 -0
- metadata +8 -16
- data/app/models/rails_nexus/database_stat.rb +0 -128
- data/app/models/rails_nexus/event.rb +0 -101
- data/app/models/rails_nexus/metric.rb +0 -107
- data/app/models/rails_nexus/nginx_metric.rb +0 -112
- data/app/models/rails_nexus/server_metric.rb +0 -168
- data/app/views/rails_nexus/backup/files.html.erb +0 -53
- data/app/views/rails_nexus/backup/settings.html.erb +0 -158
- data/config/initializers/rails_nexus.rb +0 -18
- data/db/migrate/20240330122311_create_rails_nexus_logged_exceptions.rb +0 -22
- data/lib/generators/rails_nexus/templates/migration_advanced_features.rb +0 -30
- data/lib/generators/rails_nexus/templates/migration_cron_jobs.rb +0 -22
- data/lib/generators/rails_nexus/templates/migration_new_schema.rb +0 -120
- data/lib/generators/rails_nexus/templates/migration_platform_detection.rb +0 -11
- data/lib/generators/rails_nexus/templates/migration_webhook_deliveries.rb +0 -21
- data/lib/generators/rails_nexus/templates/migration_workflow.rb +0 -29
|
@@ -1,275 +1,276 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
4
5
|
|
|
5
6
|
module RailsNexus
|
|
6
7
|
class BackupService
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def initialize
|
|
12
|
-
saved = self.class.load_config
|
|
13
|
-
@config_path = saved[:config_path] || RailsNexus.configuration.backup_config_path || default_config_path
|
|
14
|
-
@models_path = saved[:models_path] || RailsNexus.configuration.backup_models_path || default_models_path
|
|
15
|
-
@dump_path = saved[:dump_path] || RailsNexus.configuration.backup_dump_path || default_dump_path
|
|
8
|
+
# Execute a backup for a given config
|
|
9
|
+
def self.run(config)
|
|
10
|
+
new(config).run
|
|
16
11
|
end
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config
|
|
15
|
+
@record = nil
|
|
16
|
+
@errors = []
|
|
17
|
+
end
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
def run
|
|
20
|
+
@record = RailsNexus::Backup.start!(
|
|
21
|
+
model_name: @config.name,
|
|
22
|
+
triggered_by: "service"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
# Ensure storage directory exists
|
|
27
|
+
FileUtils.mkdir_p(@config.storage_path_expanded)
|
|
28
|
+
|
|
29
|
+
# Step 1: Dump database
|
|
30
|
+
dump_path = dump_database
|
|
31
|
+
return fail_record("Database dump failed") unless dump_path
|
|
32
|
+
|
|
33
|
+
# Step 2: Compress if configured
|
|
34
|
+
final_path = @config.compress? ? compress(dump_path) : dump_path
|
|
35
|
+
|
|
36
|
+
# Step 3: Encrypt if configured
|
|
37
|
+
final_path = encrypt(final_path) if @config.encrypt?
|
|
38
|
+
|
|
39
|
+
# Step 4: Sync to remote if configured
|
|
40
|
+
rsync_result = sync_remote(final_path) if @config.rsync_enabled?
|
|
41
|
+
|
|
42
|
+
# Step 5: Cleanup old backups
|
|
43
|
+
cleanup_old_backups
|
|
44
|
+
|
|
45
|
+
# Step 6: Record success
|
|
46
|
+
file_size = File.exist?(final_path) ? File.size(final_path) : 0
|
|
47
|
+
@record.succeed!(
|
|
48
|
+
file_path: final_path,
|
|
49
|
+
file_size: file_size
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Step 7: Notify
|
|
53
|
+
notify_success
|
|
54
|
+
|
|
55
|
+
{ success: true, record: @record, file_path: final_path }
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
@record.fail!(error_message: e.message) if @record
|
|
58
|
+
notify_failure(e.message)
|
|
59
|
+
{ success: false, error: e.message, record: @record }
|
|
60
|
+
ensure
|
|
61
|
+
# Clean up temp files
|
|
62
|
+
cleanup_temp_files
|
|
63
|
+
end
|
|
27
64
|
end
|
|
28
65
|
|
|
29
|
-
|
|
30
|
-
def self.save_config(params)
|
|
31
|
-
config = load_config
|
|
66
|
+
private
|
|
32
67
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
68
|
+
def dump_database
|
|
69
|
+
case @config.adapter
|
|
70
|
+
when "mysql"
|
|
71
|
+
dump_mysql
|
|
72
|
+
when "postgresql"
|
|
73
|
+
dump_postgresql
|
|
74
|
+
when "sqlite"
|
|
75
|
+
dump_sqlite
|
|
76
|
+
else
|
|
77
|
+
raise "Unsupported adapter: #{@config.adapter}"
|
|
36
78
|
end
|
|
79
|
+
end
|
|
37
80
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
81
|
+
# ─── MySQL Dump ──────────────────────────────────────────────────
|
|
82
|
+
def dump_mysql
|
|
83
|
+
raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
|
|
84
|
+
cmd = build_mysql_command(raw_path)
|
|
41
85
|
|
|
42
|
-
|
|
43
|
-
|
|
86
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
87
|
+
unless status.success?
|
|
88
|
+
@errors << "mysqldump failed: #{stderr}"
|
|
89
|
+
return nil
|
|
90
|
+
end
|
|
44
91
|
|
|
45
|
-
|
|
46
|
-
rescue StandardError => e
|
|
47
|
-
{ success: false, error: e.message }
|
|
92
|
+
raw_path
|
|
48
93
|
end
|
|
49
94
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
{
|
|
54
|
-
|
|
55
|
-
|
|
95
|
+
def build_mysql_command(output_path)
|
|
96
|
+
parts = ["mysqldump"]
|
|
97
|
+
parts << "--user=#{@config.username}" if @config.username.present?
|
|
98
|
+
parts << "--password=#{@config.password}" if @config.password.present?
|
|
99
|
+
parts << "--host=#{@config.host}" if @config.host.present?
|
|
100
|
+
parts << "--port=#{@config.port}" if @config.port.present?
|
|
101
|
+
parts << "--result-file=#{output_path}"
|
|
102
|
+
parts << "--quick"
|
|
103
|
+
parts << "--single-transaction" if @config.mysql?
|
|
104
|
+
|
|
105
|
+
# Skip tables
|
|
106
|
+
skip = @config.skip_tables_list
|
|
107
|
+
skip.each { |t| parts << "--ignore-table=#{@config.database_name}.#{t}" }
|
|
108
|
+
|
|
109
|
+
parts << @config.database_name
|
|
110
|
+
parts.join(" ")
|
|
56
111
|
end
|
|
57
112
|
|
|
58
|
-
#
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
113
|
+
# ─── PostgreSQL Dump ─────────────────────────────────────────────
|
|
114
|
+
def dump_postgresql
|
|
115
|
+
raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
|
|
116
|
+
cmd = build_postgresql_command(raw_path)
|
|
62
117
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return [] unless configured?
|
|
118
|
+
env = {}
|
|
119
|
+
env["PGPASSWORD"] = @config.password if @config.password.present?
|
|
66
120
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
121
|
+
stdout, stderr, status = Open3.capture3(env, cmd)
|
|
122
|
+
unless status.success?
|
|
123
|
+
@errors << "pg_dump failed: #{stderr}"
|
|
124
|
+
return nil
|
|
125
|
+
end
|
|
71
126
|
|
|
72
|
-
|
|
73
|
-
def backup_files
|
|
74
|
-
return [] unless @dump_path && Dir.exist?(@dump_path)
|
|
75
|
-
|
|
76
|
-
Dir.glob(File.join(@dump_path, "*")).select { |f| File.file?(f) }.map do |path|
|
|
77
|
-
{
|
|
78
|
-
name: File.basename(path),
|
|
79
|
-
path: path,
|
|
80
|
-
size: File.size(path),
|
|
81
|
-
size_human: human_size(File.size(path)),
|
|
82
|
-
created_at: File.mtime(path),
|
|
83
|
-
age_hours: ((Time.current - File.mtime(path)) / 3600).round(1),
|
|
84
|
-
encrypted: path.end_with?(".enc"),
|
|
85
|
-
compressed: path.end_with?(".gz") || path.end_with?(".tar.gz")
|
|
86
|
-
}
|
|
87
|
-
end.sort_by { |f| -f[:created_at].to_i }
|
|
127
|
+
raw_path
|
|
88
128
|
end
|
|
89
129
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
{
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
last_backup: files.first&.dig(:created_at),
|
|
98
|
-
last_backup_age_hours: files.first&.dig(:age_hours),
|
|
99
|
-
oldest_backup: files.last&.dig(:created_at),
|
|
100
|
-
models_count: models.size,
|
|
101
|
-
healthy: last_backup_healthy?,
|
|
102
|
-
alerts: generate_alerts
|
|
103
|
-
}
|
|
104
|
-
end
|
|
130
|
+
def build_postgresql_command(output_path)
|
|
131
|
+
parts = ["pg_dump"]
|
|
132
|
+
parts << "--username=#{@config.username}" if @config.username.present?
|
|
133
|
+
parts << "--host=#{@config.host}" if @config.host.present?
|
|
134
|
+
parts << "--port=#{@config.port}" if @config.port.present?
|
|
135
|
+
parts << "--file=#{output_path}"
|
|
136
|
+
parts << "--format=plain"
|
|
105
137
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return nil unless File.exist?(schedule_file)
|
|
110
|
-
|
|
111
|
-
content = File.read(schedule_file)
|
|
112
|
-
{
|
|
113
|
-
file: schedule_file,
|
|
114
|
-
entries: parse_schedule(content),
|
|
115
|
-
raw: content
|
|
116
|
-
}
|
|
117
|
-
end
|
|
138
|
+
# Skip tables
|
|
139
|
+
skip = @config.skip_tables_list
|
|
140
|
+
skip.each { |t| parts << "--exclude-table=#{t}" }
|
|
118
141
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return { success: false, error: "Invalid model: #{model_name}" } unless valid_model?(model_name)
|
|
142
|
+
parts << @config.database_name
|
|
143
|
+
parts.join(" ")
|
|
144
|
+
end
|
|
123
145
|
|
|
124
|
-
|
|
125
|
-
|
|
146
|
+
# ─── SQLite Dump ─────────────────────────────────────────────────
|
|
147
|
+
def dump_sqlite
|
|
148
|
+
raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
|
|
149
|
+
db_path = @config.database_name
|
|
126
150
|
|
|
127
|
-
|
|
128
|
-
|
|
151
|
+
unless File.exist?(db_path)
|
|
152
|
+
@errors << "SQLite database not found: #{db_path}"
|
|
153
|
+
return nil
|
|
154
|
+
end
|
|
129
155
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
156
|
+
stdout, stderr, status = Open3.capture3("sqlite3 #{db_path} .dump")
|
|
157
|
+
unless status.success?
|
|
158
|
+
@errors << "sqlite3 dump failed: #{stderr}"
|
|
159
|
+
return nil
|
|
160
|
+
end
|
|
133
161
|
|
|
134
|
-
|
|
162
|
+
File.write(raw_path, stdout)
|
|
163
|
+
raw_path
|
|
135
164
|
end
|
|
136
165
|
|
|
137
|
-
#
|
|
138
|
-
def
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if summary_data[:last_backup].nil?
|
|
145
|
-
{ status: "warning", message: "No backups found", details: summary_data }
|
|
146
|
-
elsif summary_data[:last_backup_age_hours] > threshold * 2
|
|
147
|
-
{ status: "critical", message: "Last backup #{summary_data[:last_backup_age_hours]}h ago (>#{threshold * 2}h)", details: summary_data }
|
|
148
|
-
elsif summary_data[:last_backup_age_hours] > threshold
|
|
149
|
-
{ status: "warning", message: "Last backup #{summary_data[:last_backup_age_hours]}h ago (>#{threshold}h)", details: summary_data }
|
|
150
|
-
else
|
|
151
|
-
{ status: "healthy", message: "Last backup #{summary_data[:last_backup_age_hours]}h ago", details: summary_data }
|
|
166
|
+
# ─── Compression ─────────────────────────────────────────────────
|
|
167
|
+
def compress(file_path)
|
|
168
|
+
gz_path = "#{file_path}.gz"
|
|
169
|
+
File.open(file_path, "rb") do |input|
|
|
170
|
+
Zlib::GzipWriter.open(gz_path) do |gz|
|
|
171
|
+
gz.write(input.read)
|
|
172
|
+
end
|
|
152
173
|
end
|
|
174
|
+
File.delete(file_path) if File.exist?(gz_path)
|
|
175
|
+
gz_path
|
|
153
176
|
end
|
|
154
177
|
|
|
155
|
-
#
|
|
156
|
-
def
|
|
157
|
-
|
|
158
|
-
|
|
178
|
+
# ─── Encryption ──────────────────────────────────────────────────
|
|
179
|
+
def encrypt(file_path)
|
|
180
|
+
enc_path = "#{file_path}.enc"
|
|
181
|
+
password = @config.encrypt_password
|
|
159
182
|
|
|
160
|
-
|
|
183
|
+
cmd = "openssl aes-256-cbc -salt -pbkdf2 -in #{file_path} -out #{enc_path} -pass pass:#{password}"
|
|
184
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
161
185
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
186
|
+
unless status.success?
|
|
187
|
+
@errors << "Encryption failed: #{stderr}"
|
|
188
|
+
return file_path
|
|
189
|
+
end
|
|
165
190
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
enabled: true,
|
|
169
|
-
config_path: nil,
|
|
170
|
-
models_path: nil,
|
|
171
|
-
dump_path: nil,
|
|
172
|
-
notify_command: nil,
|
|
173
|
-
alert_threshold_hours: 24,
|
|
174
|
-
rsync_host: nil,
|
|
175
|
-
rsync_port: 22,
|
|
176
|
-
rsync_user: nil,
|
|
177
|
-
rsync_path: nil,
|
|
178
|
-
encrypt_password: nil,
|
|
179
|
-
auto_cleanup_days: 30
|
|
180
|
-
}
|
|
191
|
+
File.delete(file_path) if File.exist?(enc_path)
|
|
192
|
+
enc_path
|
|
181
193
|
end
|
|
182
194
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
195
|
+
# ─── RSync Remote Sync ──────────────────────────────────────────
|
|
196
|
+
def sync_remote(file_path)
|
|
197
|
+
return unless @config.rsync_enabled?
|
|
198
|
+
return unless @config.rsync_host.present?
|
|
199
|
+
|
|
200
|
+
remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
|
|
201
|
+
|
|
202
|
+
cmd = [
|
|
203
|
+
"rsync",
|
|
204
|
+
"-avz",
|
|
205
|
+
"--progress",
|
|
206
|
+
"-e", "ssh -p #{@config.rsync_port}",
|
|
207
|
+
file_path,
|
|
208
|
+
remote
|
|
209
|
+
].join(" ")
|
|
210
|
+
|
|
211
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
212
|
+
unless status.success?
|
|
213
|
+
@errors << "RSync failed: #{stderr}"
|
|
214
|
+
end
|
|
186
215
|
|
|
187
|
-
|
|
188
|
-
Rails.root.join("config", "backup", "models").to_s
|
|
216
|
+
{ success: status.success?, output: stdout }
|
|
189
217
|
end
|
|
190
218
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
219
|
+
# ─── Cleanup ─────────────────────────────────────────────────────
|
|
220
|
+
def cleanup_old_backups
|
|
221
|
+
storage = @config.storage_path_expanded
|
|
222
|
+
return unless Dir.exist?(storage)
|
|
194
223
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
name_match = content.match(/Model\.new\(:(\w+)/)
|
|
198
|
-
desc_match = content.match(/'([^']+)'/)
|
|
199
|
-
|
|
200
|
-
return nil unless name_match
|
|
201
|
-
|
|
202
|
-
{
|
|
203
|
-
name: name_match[1],
|
|
204
|
-
description: desc_match ? desc_match[1] : name_match[1],
|
|
205
|
-
file: path,
|
|
206
|
-
has_mysql: content.include?("database MySQL"),
|
|
207
|
-
has_local: content.include?("store_with Local"),
|
|
208
|
-
has_rsync: content.include?("sync_with RSync"),
|
|
209
|
-
has_encryption: content.include?("encrypt_with OpenSSL"),
|
|
210
|
-
has_compression: content.include?("compress_with Gzip"),
|
|
211
|
-
skip_tables: content.scan(/skip_tables\s*=\s*\[([^\]]+)\]/).flatten.first,
|
|
212
|
-
keep_count: content.scan(/keep\s*=\s*(\d+)/).flatten.first&.to_i
|
|
213
|
-
}
|
|
214
|
-
end
|
|
224
|
+
pattern = File.join(storage, "#{@config.name}_*")
|
|
225
|
+
files = Dir.glob(pattern).sort_by { |f| File.mtime(f) }
|
|
215
226
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
frequency: match[0].strip,
|
|
221
|
-
time: match[1],
|
|
222
|
-
command: match[2]
|
|
223
|
-
}
|
|
227
|
+
# Keep only keep_count most recent
|
|
228
|
+
while files.size > @config.keep_count
|
|
229
|
+
old_file = files.shift
|
|
230
|
+
File.delete(old_file) if File.exist?(old_file)
|
|
224
231
|
end
|
|
225
|
-
entries
|
|
226
232
|
end
|
|
227
233
|
|
|
228
|
-
def
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
234
|
+
def cleanup_temp_files
|
|
235
|
+
# Clean up any intermediate files (uncompressed, unencrypted)
|
|
236
|
+
storage = @config.storage_path_expanded
|
|
237
|
+
return unless Dir.exist?(storage)
|
|
232
238
|
|
|
233
|
-
|
|
234
|
-
|
|
239
|
+
Dir.glob(File.join(storage, "#{@config.name}_*.sql")).each do |f|
|
|
240
|
+
File.delete(f) if File.exist?("#{f}.gz") || File.exist?("#{f}.enc")
|
|
241
|
+
end
|
|
235
242
|
end
|
|
236
243
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return
|
|
244
|
+
# ─── Notifications ───────────────────────────────────────────────
|
|
245
|
+
def notify_success
|
|
246
|
+
return unless @config.notify_on_success?
|
|
247
|
+
execute_notify_command("success")
|
|
248
|
+
end
|
|
240
249
|
|
|
241
|
-
|
|
242
|
-
|
|
250
|
+
def notify_failure(error_message)
|
|
251
|
+
return unless @config.notify_on_failure?
|
|
252
|
+
execute_notify_command("failure", error_message)
|
|
243
253
|
end
|
|
244
254
|
|
|
245
|
-
def
|
|
246
|
-
|
|
247
|
-
files = backup_files
|
|
248
|
-
threshold = saved_config[:alert_threshold_hours]&.to_i || RailsNexus.configuration.backup_alert_threshold_hours || 24
|
|
255
|
+
def execute_notify_command(status, error = nil)
|
|
256
|
+
return if @config.notify_command.blank?
|
|
249
257
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
258
|
+
cmd = @config.notify_command
|
|
259
|
+
.gsub("{STATUS}", status.to_s)
|
|
260
|
+
.gsub("{MODEL}", @config.name)
|
|
261
|
+
.gsub("{ERROR}", error.to_s)
|
|
262
|
+
.gsub("{TIME}", Time.current.iso8601)
|
|
255
263
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
alerts << { level: "info", message: "#{old_files.size} backups older than 30 days (#{human_size(old_files.sum { |f| f[:size] })})" }
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
alerts
|
|
264
|
+
Open3.capture3(cmd)
|
|
265
|
+
rescue StandardError
|
|
266
|
+
# Don't fail backup because notification failed
|
|
263
267
|
end
|
|
264
268
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
exp = units.size - 1 if exp >= units.size
|
|
271
|
-
|
|
272
|
-
"%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
|
|
269
|
+
# ─── Fail Record ─────────────────────────────────────────────────
|
|
270
|
+
def fail_record(message)
|
|
271
|
+
@record.fail!(error_message: message) if @record
|
|
272
|
+
notify_failure(message)
|
|
273
|
+
{ success: false, error: message, record: @record }
|
|
273
274
|
end
|
|
274
275
|
end
|
|
275
276
|
end
|
|
@@ -82,6 +82,14 @@
|
|
|
82
82
|
<%# ─── Tools section ──────────────────────────────────────── %>
|
|
83
83
|
<div class="rn-sidebar-section-divider"></div>
|
|
84
84
|
|
|
85
|
+
<% if main_app.respond_to?(:root_path) %>
|
|
86
|
+
<%= link_to main_app.root_path,
|
|
87
|
+
class: "rn-nav-item rn-nav-item--muted" do %>
|
|
88
|
+
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /></svg>
|
|
89
|
+
Admin Panel
|
|
90
|
+
<% end %>
|
|
91
|
+
<% end %>
|
|
92
|
+
|
|
85
93
|
<%= link_to feed_logged_exceptions_path(format: :rss),
|
|
86
94
|
class: "rn-nav-item rn-nav-item--muted" do %>
|
|
87
95
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 00-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.375.375 0 11-.75 0 .375.375 0 01.75 0z" /></svg>
|